### Adr/Proposals/ADR 002 Structured Hook Logging # ADR-002: Structured Hook Logging via Inline Phase Emitters > **Status**: Accepted | **Date**: 2026-03-25 | **Accepted**: 2026-03-25 | **Deciders**: cameron ## Context peon-ping's hook scripts (`peon.sh` and `peon.ps1`) are silent-failure-by-design: every `try/except` falls back to defaults, every missing config key gets a safe value. This means the system keeps running even when it's broken — but gives users zero visibility into why sounds stopped, notifications vanished, or hooks started timing out. The current diagnostic surface is: - **Unix**: A commented-out `echo` line in `peon.sh` that dumps raw stdin to `/tmp/peon-ping-debug.log`. Requires editing source. Additionally, the Python block's stderr is redirected to `/dev/null` (`2>/dev/null` on the `python3 -c` invocation), meaning any diagnostic output from the decision pipeline is silently swallowed. - **Windows**: `PEON_DEBUG=1` env var in `win-play.ps1` that emits `Write-Warning` for audio failures only — covers none of config loading, event routing, pack selection, or state management. - **All platforms**: `.state.json` is readable but has no timestamps and no event history. Three pressures make this urgent now: 1. **Dual-codebase parity.** The Windows hook script (`peon.ps1`, deployed from `install.ps1`) is a separate ~1,650-line implementation with its own failure modes and essentially no diagnostics. 2. **Pipeline complexity.** The Unix Python block is ~765 lines handling 11 event types across 7 CESP categories, with pack rotation (3 modes), path rules, trainer reminders, notification templates, and multi-IDE adapter translation. When something breaks mid-pipeline, the only signal is absence of sound. 3. **Worktree concurrency.** Sprint dispatchers running 5-20 parallel agents in worktrees fire hooks concurrently against shared global state. Suppression decisions (delegate mode, debounce, cooldowns) are invisible — there's no way to tell which agent's hook fired, what it decided, or whether it was killed by timeout. The fundamental tension is: **observability vs. performance and simplicity.** The hook runs in a constrained environment (8-second self-imposed timeout, 10-second Claude Code timeout, ~120-200ms typical execution) and must never break audio playback. Any logging architecture must have zero cost when disabled and negligible cost when enabled. ## Decision We will add inline phase-emitting log calls at each decision point in both `peon.sh` (Python block) and `peon.ps1` (PowerShell), writing append-only log lines to daily-rotated files under `$PEON_DIR/logs/`. Logging is gated on a `debug` boolean in `config.json` (default `false`) with a `PEON_DEBUG=1` env var override. A `peon debug on|off` CLI command toggles the config key. A `peon logs` CLI command reads log files with basic filtering. Log format is human-readable key=value lines, one per phase. Every line carries a short invocation ID (`inv=`) so concurrent hook executions can be correlated even when interleaved: ``` 2026-03-25T14:22:01.003 [hook] inv=7a3f event=Stop session=abc123 cwd=/home/user/proj 2026-03-25T14:22:01.005 [config] inv=7a3f loaded=/home/user/.openpeon/config.json volume=0.5 pack=glados 2026-03-25T14:22:01.008 [route] inv=7a3f category=task.complete suppressed=false 2026-03-25T14:22:01.010 [sound] inv=7a3f file=mission-complete.wav pack=glados candidates=4 2026-03-25T14:22:01.012 [play] inv=7a3f backend=afplay pid=48291 async=true 2026-03-25T14:22:01.013 [exit] inv=7a3f duration_ms=10 exit=0 ``` Values containing spaces or special characters are double-quoted. Values without spaces are unquoted. This keeps simple cases scannable while remaining unambiguous: ``` 2026-03-25T14:22:01.003 [hook] inv=7a3f event=Stop session=abc123 cwd="/home/user/my project" 2026-03-25T14:22:01.010 [notify] inv=7a3f desktop=true template="✅ {project}: done" rendered="✅ my project: done" ``` Each invocation logs 6-10 lines covering phases: `[hook]`, `[config]`, `[state]`, `[route]`, `[sound]`, `[play]`, `[notify]`, `[trainer]`, `[exit]`. Every suppression, debounce, fallback, and error path logs *why* it happened. If a hook is killed by timeout before emitting `[exit]`, the incomplete invocation is identifiable by its `inv=` prefix — all preceding lines for that invocation are present and the absence of `[exit]` is itself diagnostic. Log files are daily (`peon-ping-YYYY-MM-DD.log`), pruned on each invocation based on `debug_retention_days` (default 7). Files live in `$PEON_DIR/logs/`, created on first write. ## Rationale ### Key Factors 1. **Inline emitters over a logging framework.** The Python block runs inside a bash here-doc and the PowerShell script is embedded in `install.ps1`. Neither environment supports importing external logging libraries cleanly. Inline `log("phase", "key=val ...")` calls — where `log` is a no-op lambda when disabled — match the existing code structure and keep the dependency footprint at zero. 2. **Daily file rotation over size-based rotation.** Size-based rotation requires tracking file size on every write and handling rollover atomically during concurrent appends. Date-based rotation is trivially correct: each invocation opens today's file and deletes files whose datestamp is older than the retention window. Solo developers generate 10-50 invocations/day; sprint dispatch operators running 20 concurrent agents may hit 200-500. At ~100 bytes per log line and 8 lines per invocation, even the sprint case produces ~400 KB/day — well under 1 MB per daily file. 3. **Key=value format over JSON lines.** The PRD explicitly identifies the primary consumer as a human reading a file. Key=value lines are immediately scannable, `grep`-able, and don't require `jq` to read. Values containing spaces or special characters are double-quoted; values without spaces are unquoted. Both implementations must follow this convention — the shared test fixture includes edge-case inputs (paths with spaces, notification templates with braces and emoji) to enforce it. If machine-parseable output becomes necessary later, a `debug_format: "jsonl"` config key can be added — the call sites pass structured data already, so adding a JSON formatter is a small refactor (~10 sites per platform). 4. **Single boolean over leveled logging.** Adding `info`/`debug`/`trace` levels introduces a decision at every call site ("is this info or debug?") and a usability question for users ("which level do I need?"). The PRD's three user segments (solo debugger, sprint operator, contributor) all need the same information: the full decision chain. Starting with a single boolean that emits everything keeps the implementation simple. Levels can be added later if the single level proves too noisy, with the no-op-when-disabled pattern unchanged. 5. **Global log directory over per-worktree logs.** Worktrees are temporary directories cleaned up when the agent finishes. Per-worktree logs would be lost. Global logs with `cwd` and `session` fields per line let users filter by worktree path (`grep "/tmp/worktrees/feature-auth"`) while keeping all activity visible in one place for sprint operators. ## Consequences ### Positive - Users can diagnose the five most common failure modes (missing audio backend, bad config, pack not installed, timeout, state lock contention) from log output alone, without reading source or filing a GitHub issue. - Sprint operators can correlate concurrent hook invocations by session ID and worktree path, making delegate-mode suppression, debounce decisions, and timeout kills visible. - Contributors testing new adapters or modifying the Python block can see the full event-to-sound decision chain without adding temporary print statements. ### Negative - Every code change to the decision pipeline requires adding or updating log calls in *two* independent implementations (Python and PowerShell). There is no shared code — format drift between platforms is a real risk. Mitigated by shared test fixtures: known JSON input producing expected log output, validated by both BATS and Pester. - The `debug` config key and `peon debug`/`peon logs` CLI commands add surface area to the CLI and config schema. These are permanent additions that must be maintained across both platforms. - On Windows, `Add-Content` uses a lock-open-seek-write-close cycle rather than POSIX `O_APPEND` atomic semantics. Under heavy concurrent load (20+ agents in sprint dispatch), lock contention may cause an `IOException`, which the try/catch guard handles by disabling logging for the remainder of that invocation. This means some Windows log entries may be dropped under extreme concurrency — an acceptable tradeoff given the "logging must never break the hook" principle, but an asymmetry with Unix where `O_APPEND` writes under `PIPE_BUF` are guaranteed atomic. ### Neutral - The `PEON_DEBUG=1` env var in `win-play.ps1` continues to work as before (stderr `Write-Warning` for audio failures). When the new logging is also active, both outputs fire — they are additive, not conflicting. - Log files are not a stable API. Users who build tooling on top of the log format do so at their own risk. This is documented explicitly. ## Alternatives Considered ### Alternative 1: External Logging Library (Python `logging` / PowerShell `Start-Transcript`) **Description**: Use Python's built-in `logging` module in `peon.sh`'s Python block and PowerShell's `Start-Transcript` or a third-party module in `peon.ps1`. Configure handlers for file output with rotation. **Pros**: - Python `logging` provides leveled output, configurable formatters, and `RotatingFileHandler` out of the box. - `Start-Transcript` captures all PowerShell output automatically — zero manual instrumentation. **Cons**: - The Python block runs inside a bash here-doc (`python3 -c "..."`). Importing `logging` and configuring handlers adds ~10 lines of boilerplate and a measurable startup cost (~15-30ms for module import + handler setup) on every invocation, even when logging is disabled, unless the import itself is gated behind the debug check — which negates the benefit of using the module. - `Start-Transcript` captures *everything* (verbose/debug streams, command output, errors) with no phase structure. The output is a wall of text, not parseable log entries. It also writes to a separate file per session, not a shared daily log — breaking the "one log directory, filter by session" model. - Neither approach produces cross-platform-identical output. Format drift risk remains, just hidden behind framework differences. **Why not chosen**: The overhead of importing `logging` on every invocation (even when disabled) violates the zero-cost-when-disabled requirement. `Start-Transcript` produces unstructured output that doesn't serve the debugging use cases. Inline emitters are lighter, more predictable, and produce identical format on both platforms by construction. ### Alternative 2: Structured JSON Log Lines (JSONL) **Description**: Each log entry is a JSON object on one line. Tools like `jq` can filter and transform. Format: `{"ts":"2026-03-25T14:22:01.003","phase":"hook","event":"Stop","session":"abc123","cwd":"/home/user/proj"}`. **Pros**: - Machine-parseable out of the box. Enables future tooling (log viewers, CI analysis, `peon logs --json`). - No ambiguity in parsing — keys and values are always properly quoted and escaped. - Could support a `peon logs --filter 'phase=route AND suppressed=true'` command trivially. **Cons**: - Less scannable for humans. Reading raw JSONL requires mental parsing or `jq` for any non-trivial query. The primary user segment (solo developer reading a log file to find why sounds stopped) would have a worse experience. - JSON serialization on every log call adds overhead: `json.dumps()` in Python, `ConvertTo-Json` in PowerShell. Small per-call, but 6-10 calls per invocation adds up vs. simple string formatting. - Requires escaping values that contain special characters (file paths with spaces, notification template strings with quotes). Key=value format handles this with simpler conventions. **Why not chosen**: The primary consumer is a human reading a file. Key=value lines serve that use case better. If machine-parseable output becomes necessary, adding `debug_format: "jsonl"` as a config option later is straightforward — the inline emitters already pass structured data, so adding a JSON code path is a small refactor, not a rewrite. ### Alternative 3: Stderr-Based Logging — Remove `2>/dev/null` and Print Diagnostics to Stderr **Description**: Instead of building file-based logging infrastructure, remove the `2>/dev/null` redirect on the Python block invocation (`peon.sh:3780`), gate `print(..., file=sys.stderr)` calls behind the debug flag, and let users capture diagnostics via shell redirection (`peon.sh 2>>debug.log`). On Windows, use `Write-Warning` (which already writes to the warning stream) for diagnostic output beyond the existing audio-only scope. **Pros**: - Dramatically simpler implementation. No log directory management, no daily rotation, no `peon logs` CLI command, no file I/O in the hot path. The only code change is adding `print()` calls to stderr behind a boolean check. - Zero new infrastructure — uses the OS-provided diagnostic channel that already exists. - Users who want persistent logs can redirect stderr themselves. Users who want one-off debugging see output immediately in the terminal. **Cons**: - Claude Code captures hook stdout but does **not** surface hook stderr to the user — stderr from hooks is discarded by the host process. Removing `2>/dev/null` in `peon.sh` would let stderr flow to Claude Code's process, but the user would never see it. This is the fundamental reason stderr-based logging doesn't work for the primary use case: a user who runs `peon debug on` and then uses Claude Code normally needs diagnostics to land in a file they can read later, not in a stream that Claude Code silently discards. - On Windows, `peon.ps1` is invoked by Claude Code as a hook subprocess. PowerShell's warning stream (`Write-Warning`) is similarly not surfaced to the user — it would only be visible if the user ran `peon.ps1` manually from a terminal, which is not the normal execution path. - No historical log — stderr is ephemeral. Sprint operators debugging an issue that happened 10 minutes ago in one of 20 concurrent agents have nothing to look at. The "what just happened" use case requires persistent storage. - No worktree correlation — stderr output from concurrent hooks interleaves with no session or invocation identifier unless the user manually sets up per-agent redirection, which is impractical in automated dispatch. **Why not chosen**: The hook's execution context makes stderr non-viable as the primary diagnostic channel. Claude Code discards hook stderr, so the user never sees it. The `2>/dev/null` on `peon.sh`'s Python block exists because stderr output was never useful in hook context — removing it wouldn't change that, because the output would flow to Claude Code's process and be discarded there instead. File-based logging is more infrastructure, but it's the only approach that produces output the user can actually read after the fact. ### Alternative 4: Do Nothing — Improve Ad-Hoc Debugging Guides **Description**: Instead of building logging infrastructure, document the existing debugging approaches better: how to uncomment the debug line in `peon.sh`, how to use `PEON_DEBUG=1` on Windows, how to read `.state.json`, and common failure patterns. **Pros**: - Zero code changes. No new config keys, no new CLI commands, no dual-implementation maintenance burden. - No risk of logging I/O causing timeout issues. - Unblocks no-one but also breaks nothing. **Cons**: - Does not actually solve the problem. Users still can't diagnose issues without editing source code (Unix) or getting only audio-layer diagnostics (Windows). The five most common failure modes (missing backend, bad config, missing pack, timeout, state lock) remain invisible. - Worktree concurrency debugging remains impossible — `.state.json` has no per-invocation history. - Every GitHub issue about "peon-ping doesn't work" will still require a back-and-forth to establish basic facts about the execution environment, because there's no log to attach. - Contributors and adapter authors still resort to temporary print statements. **Why not chosen**: The problem is real and recurring (GitHub #402, #397). Documentation can help users who already know what to look for, but it cannot surface information that the system doesn't record. The maintenance cost of inline log calls is modest compared to the ongoing support cost of debugging blind. ## Implementation Notes ### Phase 1: Core Logging in Hook Scripts - Add `debug` (boolean, default `false`) and `debug_retention_days` (integer, default `7`) to `config.json`. - In the Python block (`peon.sh:~3016`): define a `log()` function gated on `cfg.get('debug', False) or os.environ.get('PEON_DEBUG') == '1'`. When disabled, `log` is a no-op lambda assigned before any I/O. When enabled, it opens `$PEON_DIR/logs/peon-ping-YYYY-MM-DD.log` in append mode and writes formatted lines. - In `peon.ps1`: equivalent `$peonLog` function using `Add-Content`. Same gating logic. - Add log calls at each phase: `[hook]`, `[config]`, `[state]`, `[route]`, `[sound]`, `[play]`, `[notify]`, `[trainer]`, `[exit]`. - Rotation: when logging is enabled and the invocation opens a *new* daily log file (today's file didn't exist yet), prune files older than `debug_retention_days` via `os.listdir()` / `Get-ChildItem`. This avoids redundant directory scans on every invocation — pruning only runs once per day, on the first hook invocation that creates the new file. - All logging wraps in try/catch — any I/O failure disables logging for the rest of the invocation. Logging must never break the hook. ### Phase 2: CLI Commands - `peon debug on|off|status` — modifies `debug` in config.json. - `peon logs [--last N] [--session ID] [--clear]` — reads/filters/manages log files. - Update `completions.bash` and `completions.fish`. ### Phase 3: Documentation - README.md "Debugging" section, README_zh.md translated equivalent, `docs/public/llms.txt`. ### Cross-Platform Test Fixture To prevent format drift between the Python and PowerShell implementations, create a shared test fixture: a set of known JSON hook inputs with expected log output. Both BATS tests (validating `peon.sh` output) and Pester tests (validating `peon.ps1` output) assert against the same expected output. Any format divergence fails CI on both platforms. ## Validation - **Zero-overhead when disabled**: Benchmark `peon.sh` with `debug: false` vs. a version with no logging code at all. Target: <1ms difference (measured as the delta in `$_PEON_PYOUT` generation time). - **Actionable diagnosis**: Given the 5 common failure modes (missing audio backend, bad config, pack not installed, hook timeout, state lock contention), a tester enables debug logging, triggers the failure, and identifies the root cause from log output alone — all 5 must be diagnosable without reading source. - **Log rotation**: After 7 simulated days of logging (create 10 dated log files), rotation prunes files older than `debug_retention_days`. Verified in both BATS and Pester. - **Worktree safety**: 10 concurrent hook invocations appending to the same log file produce 10 complete, non-interleaved log entries. Verified via a BATS test that runs hooks in parallel and asserts line integrity. - **Cross-platform parity**: The shared test fixture (same JSON input, same expected log output) passes on both macOS (BATS) and Windows (Pester) in CI. - **Revisit trigger**: If typical invocation time with logging enabled exceeds 250ms (current baseline ~120-200ms + 5ms logging budget), investigate whether log I/O is the cause and consider buffering writes to a single flush. ## Related Decisions - [ADR-001: Async Audio and Safe State on Windows](docs/adr/proposals/ADR-001-async-audio-and-safe-state-on-windows.md) — M0 reliability work that established the 8-second safety timeout, atomic state writes, and `PEON_DEBUG` env var in `win-play.ps1`. ADR-001 was never formally written; its `docs_ref` in the roadmap is aspirational. The decisions it covers (detached audio, atomic state I/O) are prerequisites for this ADR's logging design. ## References - [PRD-002: Hook Observability](docs/prds/PRD-002-hook-observability.md) — product requirements driving this decision - [CESP v1.0 Specification](https://github.com/PeonPing/openpeon) — the event category schema that logging must cover - [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks) — hook timeout behavior, JSON payload schema - [POSIX write(2) atomicity](https://pubs.opengroup.org/onlinepubs/9699919799/functions/write.html) — guarantees for concurrent `O_APPEND` writes under `PIPE_BUF` - [GitHub #402](https://github.com/PeonPing/peon-ping/issues/402), [#397](https://github.com/PeonPing/peon-ping/issues/397) — open bugs that motivated this work --- ## Revision History | Date | Status | Notes | |------|--------|-------| | 2026-03-25 | Proposed | Initial proposal | | 2026-03-25 | Proposed | Post-review revisions: added stderr alternative (B2), defined key=value escaping convention (B1), added per-line invocation ID for concurrent correlation (S4), corrected hook frequency estimates for sprint dispatch (S1), optimized rotation to once-per-day (S2), documented Windows `Add-Content` atomicity asymmetry (S3), renumbered to ADR-002 to avoid collision with M0's reserved ADR-001 (M3), restored roadmap `docs_ref` to PRD (M2) | | 2026-03-25 | Accepted | Accepted as part of HOOKLOG sprint — gates implementation of v2/m4 structured hook logging | --- ### Adr/ADR 001 Tts Backend Architecture # ADR-001: TTS Backend Architecture — Independent Scripts over Plugin Registry > **Status**: Accepted | **Date**: 2026-03-28 | **Deciders**: cameron ## Context peon-ping is adding text-to-speech (TTS) as a new output modality alongside pre-recorded sound files (v2/m5, PRD-003). The hook pipeline currently plays audio through a single `play_sound()` function that uses a platform `case` switch — one monolithic function handling macOS (`afplay`), WSL2 (PowerShell `SoundPlayer`), Linux (six-backend priority chain), SSH/devcontainer (relay HTTP), and MSYS2 (player chain + PowerShell fallback). This pattern grew organically and works, but it concentrates all platform logic in ~110 lines of shell with no separation between "what to play" and "how to play it." TTS introduces a second axis of variation. Sound playback varies by **platform** (macOS vs. Windows vs. Linux). TTS varies by both **platform** (native engines differ per OS) and **engine** (native, ElevenLabs API, Piper local neural, future others). The roadmap explicitly plans three backend categories shipping at different times: - **Platform-native** (Phase 1): macOS `say`, Windows SAPI5, Linux `espeak-ng`/`piper` - **ElevenLabs** (future): Cloud API with audio caching, API key management, cost controls - **Piper** (future): Local neural TTS with model management, BYO binary These backends have fundamentally different operational characteristics. Native backends are synchronous CLI commands that speak directly to the audio device. ElevenLabs is an HTTP API that returns audio files requiring caching and playback. Piper is a local binary that writes WAV to stdout or file. A single abstraction that handles all three elegantly would need to bridge direct-to-device speech, cached file playback, and piped audio — three different I/O models. Meanwhile, the existing `win-play.ps1` establishes a precedent: platform-specific audio logic extracted into a standalone script, invoked via `Start-Process` as a fire-and-forget background process. The Windows adapter ecosystem (`.ps1` counterparts for every `.sh` adapter) shows the codebase already handles dual-platform scripts as a known pattern. The forces in tension: 1. **Extensibility**: New TTS engines must be addable without modifying the core hook pipeline. The roadmap plans at least three backends, and community contributions could add more. 2. **Simplicity**: peon-ping is a shell script, not a framework. Each layer of abstraction adds cognitive overhead for contributors and debugging surface area for users. 3. **Operational diversity**: Backends differ in I/O model (direct speech vs. file output vs. HTTP), lifecycle (stateless vs. cached), and configuration (none vs. API keys vs. model paths). 4. **Platform parity**: Both `peon.sh` (Unix) and `peon.ps1` (Windows) must implement the same TTS behavior, doubling the surface area of any abstraction layer. ## Decision We will implement TTS backends as **independent, self-contained scripts** — one per backend per platform — invoked by the hook pipeline through a minimal calling convention rather than a formal plugin interface. Each backend is a script that accepts speech parameters as command-line arguments and handles its own audio output asynchronously. The hook pipeline's only responsibility is: resolve the speech text, select the backend based on config, and invoke the corresponding script as a background process. There is no backend registry, no discovery mechanism, no shared backend library, and no abstract interface definition. **Calling convention** (the entire "contract"): ``` # Unix (bash scripts) — text on stdin, options as arguments echo "" | scripts/tts-native.sh "" "" "" # Windows (PowerShell scripts) — text on stdin, options as named params "" | scripts/tts-native.ps1 -voice "" -rate -vol ``` Speech text is passed on **stdin**, not as a CLI argument. Dynamic text from template interpolation (`{project}`, `{summary}`) can contain shell metacharacters, quotes, newlines, and other content that is unsafe to pass as positional arguments without careful escaping. Stdin avoids this class of problems entirely — the backend reads one line from stdin and speaks it, with no shell interpretation of the content. Each script: - Reads speech text from stdin (one line) - Handles its own platform detection internally (for native: macOS `say` vs. Linux `espeak-ng`/`piper`) - Manages its own dependencies (ElevenLabs: HTTP + caching; Piper: binary + model detection) - Exits cleanly on failure (no error propagation to hook — TTS failure is never a hook failure) - Runs as a background process via the same `nohup ... &` / `Start-Process -WindowStyle Hidden` pattern as `play_sound()` **Speech text resolution** happens in the hook pipeline's Python block (Unix) / PowerShell block (Windows) — centralized, before any backend is invoked: 1. If the selected sound entry has `speech_text` and TTS is enabled → interpolate template variables 2. Else if a notification template exists for this category → use rendered template text 3. Else → use default template `"{project} — {status}"` 4. If resolved text is empty after interpolation → skip TTS entirely **Pipeline integration** in `_run_sound_and_notify` (or equivalent): ``` # Pseudocode — actual implementation adapts to mode config if mode == "sound-then-speak": play_sound(file, volume) # existing, backgrounded speak(text, voice, rate, vol) # new, backgrounded after sound elif mode == "speak-only": speak(text, voice, rate, vol) # replaces sound elif mode == "speak-then-sound": speak(text, voice, rate, vol) # new, backgrounded play_sound(file, volume) # existing, after TTS ``` TTS gets its own PID tracking (`.tts.pid`) separate from `.sound.pid`, enabling independent kill-previous behavior and the sequencing modes. **File organization:** ``` scripts/ tts-native.sh # macOS say / Linux espeak-ng|piper tts-native.ps1 # Windows SAPI5 tts-elevenlabs.sh # (future) ElevenLabs API tts-elevenlabs.ps1 # (future) ElevenLabs API tts-piper.sh # (future) Piper standalone tts-piper.ps1 # (future) Piper standalone win-play.ps1 # (existing) Windows audio playback ``` ## Rationale The current sound playback system is a cautionary example of what happens when platform variation accumulates in a single function. `play_sound()` started as a simple `afplay` call and grew to 110 lines spanning six platform paths with nested conditionals for relay modes, WSL format conversion, and player priority chains. It works, but it's the single hardest function to modify confidently — every platform path must be mentally held in context for any change. TTS would compound this problem because it adds engine variation on top of platform variation. An inline approach would mean the hook script grows a second 100+ line function with `case` branches for `native × {mac, linux, wsl, windows}`, `elevenlabs × {all platforms}`, and `piper × {all platforms}`. Each new backend multiplies the branch count. Independent scripts avoid this by giving each backend its own file with its own concerns. `tts-native.sh` can have a platform `case` for `say` vs. `espeak-ng` without carrying ElevenLabs caching logic. `tts-elevenlabs.sh` can manage its HTTP client and cache directory without knowing anything about SAPI5. The complexity of each backend is **contained** rather than **composed**. ### Key Factors 1. **Operational isolation prevents cascade failures.** A bug in ElevenLabs caching logic cannot affect native TTS. A SAPI5 PowerShell quirk cannot break `espeak-ng`. This matters in practice — peon-ping runs in hook context where any failure delays the user's IDE. Independent scripts fail independently, and the hook pipeline's error handling is trivially simple: if the background process exits non-zero, nothing happens (fire-and-forget). 2. **The calling convention is the contract, and it's deliberately minimal.** Four arguments in, audio out. No interface file, no type system, no registration. This is a shell tool — the filesystem *is* the registry (`scripts/tts-*.sh` is the set of backends). A contributor adding a new backend writes one script, adds one `case` branch in the backend selector, and they're done. The barrier to contribution is "can you write a shell script that speaks text?" — not "can you navigate a plugin framework?" 3. **Backend diversity is real and resists unification.** Native TTS speaks directly to the audio device (no file output). ElevenLabs returns MP3 bytes over HTTP that need caching and playback through the existing audio pipeline. Piper writes WAV to stdout. A unified interface that abstracts these I/O models would either be so generic it provides no value (just "make audio happen") or so specific it forces backends into unnatural patterns (e.g., requiring file output from `say`, which natively speaks to device). The independent script model lets each backend use its natural I/O pattern. 4. **`win-play.ps1` already proves the pattern.** Windows audio playback was extracted into a standalone script precisely because it needed platform-specific complexity (MediaPlayer WPF dispatcher pumping, CLI player priority chains) that didn't belong inline. It works well — invoked via `Start-Process`, fire-and-forget, self-contained. TTS backends follow the same proven model. ## Consequences ### Positive - **Each backend is independently testable.** `tts-native.sh` can be tested by invoking it directly with known arguments — no hook pipeline setup required. BATS tests call the script, Pester tests call the `.ps1`. This matches the existing test pattern for `win-play.ps1`. - **New backends don't touch existing code.** Adding ElevenLabs means creating `tts-elevenlabs.sh` and adding a `case` branch in the backend selector — two changes, both additive. Zero risk to native TTS behavior. - **Debugging is straightforward.** `peon debug on` can log the exact command invoked (`[tts] backend=native cmd="scripts/tts-native.sh 'hello' 'Alex' '1.0' '0.5'" pid=48291`). Users can run the same command manually to reproduce issues. The `[tts]` log phase slots naturally into the existing structured logging format. - **Contributors face a minimal learning curve.** "Write a script that takes text/voice/rate/volume and speaks" is a self-contained task. No framework concepts to learn, no interfaces to implement, no registration to configure. ### Negative - **Duplicated platform detection across backends.** `tts-native.sh` and a hypothetical `tts-festival.sh` would both need to detect macOS vs. Linux. This is a small amount of duplication (a `uname` check) and acceptable — the alternative (a shared platform library) adds a dependency chain to every backend script, creating the coupling we're trying to avoid. - **Each backend requires parallel bash and PowerShell implementations.** Three planned backends means six script files with mirrored logic. This is the same dual-platform cost every peon-ping feature pays (every `.sh` adapter has a `.ps1` counterpart), but it multiplies with the backend count. Acceptable because each script is self-contained and small (~50-100 lines), and the alternative — a unified cross-platform layer — would introduce a new dependency or abstraction that doesn't exist in the codebase today. - **No compile-time contract enforcement.** If a backend script ignores the volume argument or takes arguments in the wrong order, the only feedback is wrong behavior at runtime. For a shell-based CLI tool with 3-5 backends, this is manageable — backends are tested individually, and the argument list is documented in the script headers. A formal interface would only help if we had dozens of backends. - **Backend selection is a hardcoded switch, not dynamic discovery.** The hook pipeline has a `case` block mapping config values to script paths. Adding a backend requires editing this block. Automatic discovery (scanning `scripts/tts-*.sh`) would eliminate this, but introduces ordering ambiguity and makes the "which backend runs?" question harder to answer from reading the code. Explicit is better than implicit for a system with <10 backends. ### Neutral - **Speech text resolution stays centralized in the hook pipeline.** The Python block (Unix) and PowerShell block (Windows) handle template interpolation, manifest `speech_text` lookup, and variable resolution. Backends receive already-resolved plain text. This means the resolution logic exists in two places (Python and PowerShell), but this is the same pattern as every other hook feature — the Windows port mirrors the Python logic. - **TTS PID tracking (`.tts.pid`) is separate from sound PID (`.sound.pid`).** This enables the three modes (`sound-then-speak`, `speak-only`, `speak-then-sound`) but means two PID files to manage. The kill-previous logic needs to respect both PIDs and the active mode. This is a small complexity increase with clear benefits for sequencing control. ## Alternatives Considered ### Alternative 1: Inline Platform Branching (Extend play_sound Pattern) **Description**: Add TTS as another `case` block in the main hook script, mirroring how `play_sound()` handles platform variation. A single `speak()` function contains all backend logic — native platform detection, ElevenLabs HTTP calls, Piper invocation — as branches in a nested `case` structure (outer: backend, inner: platform). **Pros**: - All TTS logic visible in one place — no script-hopping to understand the full flow - No process overhead — avoids the ~200ms PowerShell startup cost of a separate `Start-Process` on Windows (though this cost is already budgeted for `win-play.ps1` and fires in background) - Consistent with how `play_sound()` currently works — contributors familiar with the codebase already know the pattern **Cons**: - Compounds the monolith problem. `play_sound()` at 110 lines is already the hardest function to modify; adding a `speak()` of similar size with backend × platform branching doubles the cognitive load. Each new backend adds branches to both Unix and Windows code paths - Cross-backend contamination risk. A change to ElevenLabs caching could introduce a bug in native TTS if they share control flow or variables — even with careful scoping, inline proximity encourages shared state - Testing requires the full hook pipeline. You can't invoke "just the native backend" without setting up mock config, event JSON, and platform detection — all the surrounding context that the inline function depends on **Why not chosen**: `play_sound()` demonstrates the long-term trajectory of this pattern — organic growth into a function that's correct but increasingly difficult to modify with confidence. TTS would follow the same path with the added dimension of backend variation. The operational diversity of backends (direct speech vs. HTTP + cache vs. local binary) makes inline branches increasingly awkward as each backend's unique concerns bleed into shared scope. ### Alternative 2: Plugin Registry with Auto-Discovery **Description**: Create a formal backend plugin system. Backend scripts live in a `tts-backends/` directory and self-register by implementing a standard interface. The hook pipeline scans the directory, loads available backends, and dispatches to the selected one. Each backend exports metadata (name, platform support, capabilities) that the registry uses for `auto` resolution and the `peon tts voices` enumeration. A backend script would follow a defined structure: ```bash # tts-backends/native.sh BACKEND_NAME="native" BACKEND_PLATFORMS="mac linux wsl" backend_available() { ... } # returns 0 if this backend can run backend_voices() { ... } # lists available voices backend_speak() { ... } # speaks text with given parameters ``` **Pros**: - Maximum extensibility — community backends drop into a directory and "just work" without any core code changes - Formalized contract prevents argument-order mistakes and documents the expected interface - Auto-discovery enables features like `peon tts backends` listing all available engines with their capabilities **Cons**: - Overengineered for the expected scale. The roadmap plans 3-4 backends (native, ElevenLabs, Piper, maybe OpenAI). A discovery framework serves a plugin ecosystem of dozens — we're building for 3 - Introduces framework concepts foreign to the codebase. peon-ping is a shell script that reads JSON and plays sounds. Plugin registries, self-registration, and capability metadata are patterns from application frameworks, not CLI tools. Contributors now need to understand the plugin model before adding a backend - Discovery ordering creates implicit behavior. If multiple backends claim to support a platform, which wins? Priority ordering, explicit preference configuration, and conflict resolution add complexity that explicit backend selection avoids entirely - Shell-based plugin systems are fragile. Sourcing arbitrary scripts (`source tts-backends/*.sh`) in the hook's execution context risks variable collisions, function name conflicts, and error propagation. Subprocess isolation (what the independent scripts approach uses naturally) must be explicitly engineered **Why not chosen**: The plugin registry solves a problem we don't have — managing a large, open-ended set of backends contributed by a distributed community. With 3-4 planned backends, each shipping as a deliberate feature with its own roadmap entry, the editorial overhead of "add a case branch" is negligible. The registry's value proposition — "zero-touch extensibility" — comes at the cost of framework complexity that makes the first three backends harder to build, debug, and maintain. If peon-ping ever reaches 10+ TTS backends (unlikely given the TTS market), a registry can be introduced then with the independent scripts as migration targets. ### Alternative 3: Unified Audio Pipeline (TTS as a Sound Source) **Description**: Instead of TTS being a separate pipeline, treat synthesized speech as another sound source feeding into the existing `play_sound()` function. TTS backends produce audio files (WAV/MP3) written to a temp directory, and `play_sound()` plays them like any pack sound. This unifies PID tracking, volume control, kill-previous behavior, and platform playback under a single code path. **Pros**: - Maximal code reuse — every platform's audio playback is already solved in `play_sound()`. TTS backends only need to produce files, not handle audio output - Single PID tracking (`.sound.pid`) — no dual-PID complexity, modes reduce to "play this file, then play that file" - ElevenLabs and Piper naturally produce files (MP3/WAV), so this model fits them natively **Cons**: - Forces native TTS through an unnatural path. macOS `say` and `espeak-ng` speak directly to the audio device — requiring them to write files first adds latency (synthesis + write + read + play vs. just synthesis + speak) and temp file management (creation, cleanup, disk usage). A 2-second phrase would need to fully synthesize before any audio plays, rather than streaming word-by-word as `say` does natively - Eliminates streaming playback. `say` begins speaking immediately as it processes text; file-based playback requires full synthesis first. For longer phrases (trainer progress with multiple exercises), the difference is perceptible — 1-2 seconds of silence before speech begins - Conflates two concerns in `play_sound()`. Sound files are static assets selected from a manifest. TTS output is dynamically generated text. Adding "is this a real file or a temp file that needs cleanup?" logic to `play_sound()` spreads TTS concerns into the sound pipeline - The sequencing modes (`sound-then-speak`, `speak-then-sound`) become harder — they're now "play file A, then play file B" which requires chaining `play_sound()` calls with wait-for-completion logic, adding the same PID coordination complexity we were trying to avoid **Why not chosen**: The appeal of this approach — reusing `play_sound()` — breaks down precisely for the most important backend (platform-native). Native TTS's strength is low-latency direct-to-device speech, and forcing it through file intermediation sacrifices that advantage. The approach optimizes for API backends (ElevenLabs, which naturally produces files) at the cost of the baseline experience. Since native TTS is the first and default backend — the one every user encounters — penalizing it to benefit future backends is the wrong tradeoff ordering. API backends that produce files can simply call `play_sound()` internally if they want to reuse it, without forcing native backends into the same path. ### Alternative 4: Python Cross-Platform TTS (Single Script per Backend) **Description**: Leverage the Python runtime already present in the hook pipeline (`peon.sh` embeds a Python block for config loading, event parsing, and sound selection). Each TTS backend becomes a single `.py` script that handles all platforms internally — `tts-native.py` would call `say` via subprocess on macOS, `espeak-ng` on Linux, and either `pyttsx3` or `win32com.client` for SAPI5 on Windows. This halves the file count by eliminating the `.sh` / `.ps1` duplication. **Pros**: - One script per backend instead of two — three backends means three files, not six - Eliminates the "duplicated platform detection across backends" negative consequence entirely - Python's `subprocess` module provides consistent cross-platform process invocation - The hook pipeline already depends on Python (Unix side), so no new runtime dependency there **Cons**: - `peon.ps1` (native Windows) deliberately avoids Python — it's a pure PowerShell implementation with no Python dependency. Introducing Python TTS scripts would break this architectural boundary, requiring Python on Windows or maintaining a PowerShell fallback path anyway - `pyttsx3` (the main cross-platform TTS library) is a pip dependency peon-ping has never required. The existing Python usage is stdlib-only. Adding pip dependencies changes the install story and creates a new failure mode - Without `pyttsx3`, the Python scripts would just be subprocess wrappers around `say`/`espeak-ng`/SAPI5 — the same platform branching as shell scripts but in a different language, adding Python startup overhead (~100ms) without reducing complexity - Python is not available in all environments where peon-ping runs (minimal containers, some CI images, MSYS2 without Python installed) **Why not chosen**: The value proposition — halving file count — only materializes if a cross-platform Python TTS library is used, which introduces peon-ping's first pip dependency. Without it, Python scripts are just shell scripts in a different language with worse startup performance. More fundamentally, `peon.ps1` exists specifically to avoid a Python dependency on Windows, and that boundary is load-bearing — Windows users install peon-ping via `install.ps1` with no Python requirement. Forcing Python into the Windows path to reduce file count trades a real user-facing simplicity (no Python needed) for a developer-facing convenience (fewer files). ## Implementation Notes **Phase 1 ships one backend script (native) with the integration layer:** 1. Add `tts` section to `config.json` defaults — `enabled: false`, `backend: "auto"`, `voice: "default"`, `rate: 1.0`, `volume: 0.5`, `mode: "sound-then-speak"` 2. Create `scripts/tts-native.sh` — platform detection (`uname`), macOS `say`, Linux `espeak-ng`/`piper` priority chain, async via `nohup ... &` 3. Create `scripts/tts-native.ps1` — Windows TTS via `System.Speech.Synthesis.SpeechSynthesizer` (SAPI5), async via the script being invoked through `Start-Process -WindowStyle Hidden` (same pattern as `win-play.ps1`). Note: Microsoft is steering toward `Windows.Media.SpeechSynthesis` (WinRT), which offers neural voices on Windows 11 with notably higher quality. Phase 1 uses SAPI5 for broader compatibility (Windows 10 support, simpler PowerShell integration), but the independent script architecture means `tts-native.ps1` can adopt WinRT internally without affecting any other backend or the hook pipeline 4. Add speech text resolution to the Python block in `peon.sh` — template interpolation using existing `_tpl_vars` machinery, outputting `TTS_TEXT` variable 5. Add TTS invocation to `_run_sound_and_notify` — mode-aware sequencing, `.tts.pid` tracking, safety timeout 6. Add `peon tts` CLI subcommands — `on`/`off`/`status`/`test`/`voices`/`voice`/`backend` 7. Port all of the above to `peon.ps1` (PowerShell) 8. `peon update` backfills `tts` config section for existing installs **Future backends add files, not framework:** - `scripts/tts-elevenlabs.sh`: HTTP client (`curl`), text-hash caching to `$PEON_DIR/cache/tts/`, plays cached MP3 via `play_sound()` internally - `scripts/tts-piper.sh`: Detects `piper` binary and model, pipes text to `piper --output-raw | aplay` (or writes WAV + `play_sound()`) - Each backend adds one `case` branch to the backend selector in `peon.sh` and `peon.ps1` **The `auto` backend resolution** for Phase 1 is trivially `native` — it's the only backend. When ElevenLabs ships, `auto` can prefer it when an API key is configured, falling back to native. This logic lives in the hook pipeline's backend selector, not in any backend script. ## Validation - **Backend isolation holds**: Each backend can be invoked directly from the command line (`echo "test" | bash scripts/tts-native.sh "Alex" "1.0" "0.5"`) and produces speech without any hook pipeline context. BATS/Pester tests validate this independently. - **Hook latency unchanged**: Before/after timing of hook return shows no measurable increase. The structured logging `[exit] duration_ms=N` metric (from v2/m4) provides automated measurement. Target: <50ms delta. - **Adding ElevenLabs backend requires no changes to tts-native scripts**: When `tts-elevenlabs.sh` ships, the diff touches zero lines in `tts-native.sh` or `tts-native.ps1`. Only the backend selector in `peon.sh`/`peon.ps1` and config schema gain additions. - **Failure isolation**: Kill `espeak-ng` mid-utterance, corrupt the ElevenLabs cache, remove Piper's model file — each failure is contained to its backend with no impact on sound playback or other backends. The hook exits 0 regardless. - **Contributor test**: The calling convention and one example script (`tts-native.sh`) are sufficient documentation for a new backend — implementing a backend requires no reading of `peon.sh` internals. If a contributor must understand the hook pipeline to write a backend, the contract has grown too complex. ## Related Decisions - **Async Audio and Safe State on Windows**: The `Start-Process -WindowStyle Hidden` pattern, atomic state writes, and 8-second safety timeout established during the Windows native port. TTS backends inherit this pattern. (Implementation reference: `scripts/win-play.ps1` and `peon.ps1` async invocation.) - **Structured Hook Logging** (v2/m4): The `[phase] key=value` log format defined for hook observability. TTS adds a `[tts]` phase following this convention. ## References - [PRD-003: TTS Spoken Feedback](../prds/PRD-003-tts-spoken-feedback.md) — product requirements driving this decision - v2/m5 "The peon speaks to you" — parent roadmap milestone defining feature sequencing and success criteria - [macOS `say` man page](x-man-page://say) — native macOS TTS capabilities - [System.Speech.Synthesis (SAPI5)](https://learn.microsoft.com/en-us/dotnet/api/system.speech.synthesis) — Windows native TTS API - [espeak-ng](https://github.com/espeak-ng/espeak-ng) — open-source speech synthesizer for Linux - [Piper](https://github.com/rhasspy/piper) — fast local neural TTS for Linux --- ## Revision History | Date | Status | Notes | |------|--------|-------| | 2026-03-28 | Proposed | Initial proposal | | 2026-03-28 | Accepted | Adversarial review: switched calling convention from CLI args to stdin for safe text transport; added platform parity cost as negative consequence; added Python cross-platform alternative (Alt 4); noted WinRT as SAPI5 successor; fixed dead design doc references | --- ### Designs/2026 05 09 Omp Adapter Design # omp (oh-my-pi) Adapter **Date:** 2026-05-09 **Status:** Approved **Type:** New Adapter + minor `peon.sh` wiring + docs ## Problem Statement [oh-my-pi](https://github.com/can1357/oh-my-pi) (CLI binary `omp`) is a bun/TypeScript coding-agent CLI. peon-ping currently has no adapter for it, so omp users get no sound, notification, trainer, or relay support — even though every primitive peon-ping needs is already in place. The work is purely the adapter layer. ## Background ### How omp exposes lifecycle omp ships an `ExtensionAPI` (and a deprecated-but-supported `HookAPI` superset) that fires typed lifecycle events. Extensions are `default`-exporting TS modules auto-discovered from: 1. `/.omp/extensions/` (project scope) 2. `~/.omp/agent/extensions/` (user scope) 3. `~/.omp/plugins/node_modules/` (marketplace plugins) 4. `omp --extension ` / `omp --hook ` (CLI override) A directory entry is resolved as `package.json` (with `omp.extensions` field) → `index.ts` → `index.js` → directory scan. Relevant events for an audio adapter (full catalog: `docs/skills/authoring-hooks.md`): | Event | Fires | |------------------------------------|---------------------------------------------| | `session_start` | once on session load | | `turn_start` / `turn_end` | per user→agent turn | | `tool_call` / `tool_result` | wrapping every tool execution | | `auto_compaction_start` | before automatic compaction | | `session_shutdown` | on session close | ### How peon-ping accepts events `peon.sh` reads JSON on stdin and dispatches on `hook_event_name` + `source`. Existing adapters (Codex, Cursor, OpenCode, Kilo, Kiro, Gemini, Copilot, Windsurf, Antigravity, Amp, DeepAgents, OpenClaw, Rovodev) all share the same shape: receive IDE events, translate, spawn `peon.sh` with a CESP-shaped payload. `peon.sh` already has IDE-aware tables that need an `omp` entry to display correctly: - `IDE_ALIASES` (line 4887) — normalizes `source` field strings - `IDE_DISPLAY_NAMES` (line 4952) — human-readable label - `prefix_map` inside `detect_session_ide` (line 4931) — fallback IDE detection from session-id prefix ### Closest precedent `adapters/opencode/peon-ping.ts` is a thin TS plugin (~160 lines) that subscribes to OpenCode events and `spawn`s `peon.sh` with `{ hook_event_name, source: "opencode", session_id, cwd }`. The omp adapter follows the same shape, swapping the event surface. ## Proposed Solution Add a thin omp extension that mirrors the OpenCode adapter, plus the small `peon.sh` table updates so the existing IDE-detection paths recognize `omp`. ### Architecture ``` omp session │ │ omp ExtensionAPI events (session_start, turn_start, turn_end, │ tool_result, auto_compaction_start, │ session_shutdown) ▼ adapters/omp/peon-ping.ts │ │ spawn("bash", [peon.sh]) with stdin = │ { hook_event_name, source: "omp", session_id: "omp-…", │ cwd, notification_type, permission_mode } ▼ peon.sh → IDE_ALIASES["omp"] → "omp" → IDE_DISPLAY_NAMES["omp"] → "oh-my-pi" → existing routing: sounds, notifications, trainer, relay ``` The adapter does **no** event-translation logic that peon-ping doesn't already do for other IDEs. It is purely a transport. ## Implementation Plan ### 1. New files #### `adapters/omp/peon-ping.ts` (the extension) Default-exports an `ExtensionAPI` factory. On load: 1. Locate `peon.sh` from the same candidate paths as the OpenCode adapter: - `~/.claude/hooks/peon-ping/peon.sh` - `~/.openclaw/hooks/peon-ping/peon.sh` 2. If not found, log a one-line warning with install instructions and return without registering handlers. 3. Generate `sessionId = "omp-" + Date.now()` so `peon.sh`'s prefix-based IDE detection fires correctly even if `source` somehow gets dropped. 4. Register handlers: | omp event | `hook_event_name` fired | |----------------------------------------------|--------------------------| | `session_start` | `SessionStart` | | `turn_start` | `UserPromptSubmit` | | `turn_end` | `Stop` | | `tool_result` with `event.isError === true` | `PostToolUseFailure` | | `auto_compaction_start` | `PreCompact` | | `session_shutdown` | `SessionEnd` | Unlike the OpenCode adapter (which fires an extra `setTimeout`-delayed `SessionStart` because OpenCode's `session.created` event fires before the plugin can subscribe), omp's documented lifecycle delivers `session_start` *after* all extensions load — registering the handler synchronously during the factory call is sufficient. No initial-fire timeout. 5. `firePeon(event)` shape (identical to the OpenCode adapter): ```ts const proc = spawn("bash", [peonSh], { stdio: ["pipe", "ignore", "ignore"] }); proc.stdin.write(JSON.stringify({ hook_event_name: event, notification_type: "", cwd, session_id: sessionId, permission_mode: "", source: "omp", })); proc.stdin.end(); proc.unref(); ``` 6. Use only `node:*` modules + the omp `ExtensionAPI` type — no third-party deps. Tab title is set via OSC-0 `\x1b]0;…\x07`, guarded on `ctx.hasUI` so headless/subagent runs don't paint over each other's TTY. **Imports:** ```ts import * as fs from "node:fs" import * as path from "node:path" import * as os from "node:os" import { spawn } from "node:child_process" import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent" ``` The `ExtensionAPI` type is imported `type`-only, so the file resolves without that package being installed at runtime — omp itself supplies the value at extension-load time. #### `adapters/omp/package.json` (the manifest) Directory-style omp extensions need a `package.json` with the `omp.extensions` field so omp's directory loader picks the entry point reliably (per `docs/skills/authoring-extensions.md`): ```json { "name": "peon-ping", "version": "1.0.0", "description": "peon-ping adapter for oh-my-pi (omp) — routes lifecycle events through peon.sh.", "omp": { "extensions": ["./peon-ping.ts"] } } ``` This also lets us ship the adapter as a marketplace plugin later without restructuring. #### `adapters/omp.sh` (the installer) Same shape as `adapters/opencode.sh`: 1. Preflight: locate `peon.sh` (candidates above); fail with install instructions if missing. 2. Create `~/.omp/agent/extensions/peon-ping/`. 3. Download `peon-ping.ts` and `package.json` from `raw.githubusercontent.com/PeonPing/peon-ping/main/adapters/omp/…`. 4. Print success block with paths and "Restart omp to activate". 5. `--uninstall` flag removes the extension directory. No PowerShell installer for v1 (see Non-Goals). ### 2. `peon.sh` table updates Three small additions in the IDE-resolution block (lines 4887–4967): ```python IDE_ALIASES = { # ...existing entries... 'omp': 'omp', 'oh-my-pi': 'omp', 'oh_my_pi': 'omp', 'pi': 'omp', } # inside detect_session_ide, prefix_map tuple: ('omp-', 'omp'), IDE_DISPLAY_NAMES = { # ...existing entries... 'omp': 'oh-my-pi', } ``` These three edits are the entire `peon.sh` change. ### 3. README + docs - `README.md` — add omp badge in the badges row, add `omp` row to the Multi-IDE support section, point users at `bash adapters/omp.sh` (or `curl …/adapters/omp.sh | bash`). - `README_zh.md`, `README_ko.md`, `README_ja.md` — equivalent translations. - `docs/public/llms.txt` — add omp adapter line. - `CLAUDE.md`-mandated cross-repo updates (see "Change Enforcement Rules" at the top of `CLAUDE.md`): - `../homebrew-tap/Formula/peon-ping.rb` — only if a phase needs to detect omp; for an installer-only adapter no formula change is needed. Verify before claiming this is done. - `../peonping-x-bot/workspace/SOUL.md` — bump supported-tools count. - Version bump: minor (new adapter). ### 4. Tests #### `tests/omp.bats` — installer + IDE-table tests - Installer: with mocked `HOME` and a stubbed `curl` (or `PEON_PING_LOCAL_ADAPTER_DIR` env var pointing at the working tree's `adapters/omp/`), `bash adapters/omp.sh` writes `peon-ping.ts` and `package.json` into `/.omp/agent/extensions/peon-ping/`; second run is idempotent; `--uninstall` removes the directory. - IDE detection: pipe `{"hook_event_name":"Stop","source":"omp","session_id":"omp-123","cwd":"/tmp"}` through `peon.sh` against a mocked manifest; assert that the resolved IDE in logs/state is `omp` and the display name is `oh-my-pi`. - Session-id-prefix fallback: pipe the same event with `source: ""` (empty) but `session_id: "omp-456"`; assert IDE still resolves to `omp` via the prefix table. #### `adapters/omp/peon-ping.ts` runtime smoke test Out-of-scope for the BATS suite (peon-ping CI doesn't run TS), but the file must: - Compile cleanly with `bun build --target=node` (manual verification step in PR). - Match the type contract documented in `docs/skills/authoring-extensions.md`. ### 5. Things explicitly **not** in scope (YAGNI) - **Windows-native `omp.ps1`.** omp on Windows runs through bun, which calls into the same `bash` `peon.sh` if it's on `PATH`. A real PowerShell installer can be added when a user reports needing it. - **Permission-prompt mapping (`PermissionRequest`).** omp's `tool_call` blocking is a hook-author surface, not a user-facing permission UI. There is no clean signal to fire `PermissionRequest`; defer until omp exposes one. - **Subagent suppression beyond the existing `suppress_delegate_sessions` config.** omp's subagent surface is `ctx.hasUI === false`; we can use that to skip tab-title writes, but we don't try to invent a per-session suppression scheme. Users who care set `suppress_delegate_sessions: true`. - **Branch / tree / TTSR events.** Out of scope for an audio adapter. - **A `--local` flag for the installer that copies from a checkout instead of downloading.** Other adapter installers don't have one; tests instead use the `PEON_PING_LOCAL_ADAPTER_DIR` test-only env var (set in the BATS harness only) to redirect the source. No production user-visible flag. ## Testing Strategy ### Automated (BATS) Three new tests under `tests/omp.bats`: 1. **Installer happy path**: empty `~/.omp/`, run installer, assert files exist with expected contents (use `grep` for the `omp.extensions` field and `source: "omp"` literal). 2. **IDE alias + display**: drive `peon.sh` with a synthetic stdin event, assert log contains `ide=omp` and (where exposed) display `oh-my-pi`. 3. **Session-id prefix fallback**: as above with empty `source`, assert IDE still resolves to `omp`. ### Manual - `bun build --target=node adapters/omp/peon-ping.ts` succeeds (verifies type compatibility with `@oh-my-pi/pi-coding-agent` ExtensionAPI). - Drop the built file into `~/.omp/agent/extensions/peon-ping/peon-ping.ts`, start omp, send a prompt, hear the existing peon-ping pack play, check tab title updates. ### CI GitHub Actions BATS job (`macos-latest`) will pick up `tests/omp.bats` automatically — no workflow changes. ## Files Changed ### New - `adapters/omp/peon-ping.ts` - `adapters/omp/package.json` - `adapters/omp.sh` - `tests/omp.bats` ### Modified - `peon.sh` (3 small additions to IDE-resolution tables) - `README.md` (badge + adapter row + install snippet) - `README_zh.md`, `README_ko.md`, `README_ja.md` (translations) - `docs/public/llms.txt` (adapter line) - `CHANGELOG.md` (Unreleased / Added) - `VERSION` (minor bump) - `../peonping-x-bot/workspace/SOUL.md` (supported-tools count) — verify presence before editing ### Verified-not-modified - `peon.ps1` (does not exist in this tree; CLAUDE.md mention appears stale) - `../homebrew-tap/Formula/peon-ping.rb` (no formula change needed for an installer-only adapter; verify by re-reading the Phase 4 hook list before claiming done) ## Success Criteria 1. ✅ `bash adapters/omp.sh` installs the extension into `~/.omp/agent/extensions/peon-ping/` on a clean machine that has peon-ping already installed. 2. ✅ With the extension active, an omp session fires existing peon-ping sounds on session start, turn end, tool error, compaction, and session shutdown — verified manually. 3. ✅ `peon.sh` recognizes `source: "omp"` (and `omp-…` session-id fallback) and displays `oh-my-pi` as the IDE name. 4. ✅ All `tests/omp.bats` cases pass; existing tests remain green. 5. ✅ README badges + Multi-IDE matrix include omp, with translations updated. ## Open Questions / Risks - **Type-only import resilience.** If omp's `@oh-my-pi/pi-coding-agent` ever moves the `ExtensionAPI` symbol, the adapter's `import type` will fail at install time. Mitigation: keep the adapter on the documented public type path; if it breaks, fall back to a structural type definition inline (no import) — same workaround Kilo uses for OpenCode. - **`turn_start` vs. `before_agent_start`.** Both fire near the start of a turn. Approved mapping uses `turn_start` because it's the documented stable surface; `before_agent_start` is more about message injection. If field testing shows `turn_start` fires too eagerly (e.g., on continuations after compaction), we can switch — single-line change, no schema impact. - **Tab-title contention.** Writing OSC-0 from a backgrounded extension while omp itself owns the TTY can flicker. Adapter writes are guarded on `ctx.hasUI` so headless / subagent runs don't compete for the TTY; the interactive case has been stable in practice for the OpenCode adapter using the same approach. ## References - omp extension authoring: `../oh-my-pi/docs/skills/authoring-extensions.md` - omp hook event catalog: `../oh-my-pi/docs/skills/authoring-hooks.md` - omp extension loading: `../oh-my-pi/docs/extension-loading.md` - Closest precedent: `adapters/opencode/peon-ping.ts`, `adapters/opencode.sh` - peon.sh IDE-resolution tables: `peon.sh` lines 4887–4967 - CESP v1.0: https://github.com/PeonPing/openpeon --- ### Designs/Structured Hook Logging # Design Doc: Structured Hook Logging via Inline Phase Emitters > **ADR**: [ADR-002](../adr/proposals/ADR-002-structured-hook-logging.md) | **Date**: 2026-03-25 | **Author**: cameron ## Overview peon-ping's hook scripts (`peon.sh` and `peon.ps1`) are silent-failure-by-design — every error path falls back to defaults, giving users zero visibility into why sounds stopped, notifications vanished, or hooks timed out. ADR-002 decided to add inline phase-emitting log calls at each decision point in both codepaths, writing append-only key=value lines to daily-rotated files under `$PEON_DIR/logs/`. This design doc specifies how to implement that decision: where the `log()` function lives in each codebase, what each phase emitter captures, how daily rotation and pruning work, the CLI commands (`peon debug`, `peon logs`), and the cross-platform test fixture that prevents format drift between the Python and PowerShell implementations. ## Requirements The implementation is complete when: 1. **Zero overhead when disabled**: Hook execution with `debug: false` adds <1ms compared to a version with no logging code at all. The `log` function is a no-op lambda/scriptblock assigned before any I/O when disabled. 2. **Full decision chain visibility**: All 8 pipeline phases (`[hook]`, `[config]`, `[state]`, `[route]`, `[sound]`, `[play]`, `[notify]`, `[trainer]`) emit log lines that trace why a given invocation produced (or suppressed) a specific sound/notification. 3. **Five failure modes diagnosable**: Missing audio backend, bad config, pack not installed, hook timeout, and state lock contention are all identifiable from log output alone. 4. **Concurrent invocation correlation**: Every log line carries an `inv=` prefix (short random ID) so interleaved output from 20+ concurrent agents can be grouped per invocation. 5. **Cross-platform parity**: The same JSON hook input produces identical log output (modulo timestamps and paths) on both macOS/Linux (BATS) and Windows (Pester), validated by shared test fixtures in CI. 6. **Daily rotation with pruning**: Log files are named `peon-ping-YYYY-MM-DD.log`, pruned based on `debug_retention_days` (default 7), with pruning running at most once per day. 7. **CLI control**: `peon debug on|off|status` toggles the config key; `peon logs` reads and filters log files with `--last`, `--session`, and `--clear` options. 8. **Logging never breaks the hook**: All logging I/O wraps in try/catch. Any failure disables logging for the remainder of that invocation. ## Current State ### peon.sh (Unix/WSL2) The main hook script delegates the entire decision pipeline to a single Python block (`peon.sh:3016-3780`). The Python code runs inside a bash heredoc (`python3 -c "..."`) and outputs shell variables via `print()` statements that bash consumes via `eval`. Stderr is redirected to `/dev/null` on the invocation (`peon.sh:3780`: `" <<< "$INPUT" 2>/dev/null`), silencing all diagnostic output. The Python block handles these phases sequentially: 1. **Config load** (3031-3062): Read `config.json`, extract all settings 2. **Event parse** (3064-3090): JSON from stdin, map Cursor camelCase → PascalCase 3. **State load** (3092-3129): Atomic read of `.state.json` with retry 4. **Agent detection** (3095-3106): Suppress sounds for delegate-mode sessions 5. **Pack selection** (3131-3241): Session override → path rules → rotation → default 6. **Event routing** (3329-3509): Map hook events to CESP categories with suppression logic 7. **Sound selection** (3552-3614): Load manifest, no-repeat filter, random pick 8. **Notification template** (3715-3740): Resolve `{project}`, `{summary}` placeholders 9. **Trainer** (3616-3670): Check exercise goals, emit reminder sounds 10. **State write** (3672-3692): Atomic persist, relay sync 11. **Output** (3742-3779): Print shell variables for bash to eval After eval, bash handles audio playback (`play_sound()` at lines 254-505), desktop notifications (`send_notification()` at 639-858), mobile push (759-859), and tab title/color (3884-3925). The only existing debug mechanism is a commented-out line at 2992: ```bash # echo "$(date): peon hook — $INPUT" >> /tmp/peon-ping-debug.log ``` ### peon.ps1 (Windows) Embedded in `install.ps1` (lines 323-1975). Pure PowerShell — no Python dependency. Same decision pipeline but implemented with PowerShell constructs (`switch`, hashtables, `ConvertFrom-Json`). Audio playback delegates to `scripts/win-play.ps1`. The only existing debug mechanism is `PEON_DEBUG=1` in `win-play.ps1` (line 9), which emits `Write-Warning` for audio failures only — covering none of config loading, event routing, pack selection, or state management. ### CLI Top-level `case` statement at `peon.sh:924`. Existing commands: `pause`, `resume`, `mute`, `unmute`, `toggle`, `status`, `volume`, `rotation`, `packs`, `notifications`, `mobile`, `relay`, `trainer`, `help`. Tab completions in `completions.bash` (84 lines) and `completions.fish` (140 lines). ### config.json 44-line template with no `debug` or `debug_retention_days` keys. The config merge logic in `peon update` backfills new keys from the template into existing user configs. ### Tests BATS tests (`tests/peon.bats`) use `tests/setup.bash` which creates isolated temp directories with mock `afplay`, manifests, and config. Pester tests (`tests/adapters-windows.Tests.ps1`) validate PowerShell adapter syntax and behavior. CI runs BATS on `macos-latest` and Pester on `windows-latest`. ## Target State ``` /* Detailed source-code truncated for AI context efficiency. */ ``` After all phases, a user who runs `peon debug on` and then uses Claude Code normally will find a daily log file at `$PEON_DIR/logs/peon-ping-YYYY-MM-DD.log` containing 6-10 key=value lines per hook invocation, each prefixed with a timestamp and invocation ID. The log traces the full decision chain from event receipt to sound playback (or suppression reason). ## Design ### Architecture Logging is implemented as a thin function defined at the top of each decision pipeline — a Python `log()` function in `peon.sh`'s Python block and a PowerShell `$peonLog` scriptblock in `peon.ps1`. Both are gated on the same condition: `config.debug == true OR PEON_DEBUG=1`. When disabled, the function is a no-op (Python: `log = lambda *a, **kw: None`; PowerShell: `$peonLog = { }`) — zero cost, no file I/O, no string formatting. When enabled, the function: 1. Opens today's daily log file in append mode 2. Formats a key=value line with ISO-8601 timestamp and `[phase]` tag 3. Writes the line (Python: `print()` to file handle; PowerShell: `Add-Content`) 4. On first invocation of a new day's file, prunes old files beyond retention window The log function is defined once per invocation — it does not re-evaluate the debug flag on every call. This means toggling `PEON_DEBUG` mid-invocation has no effect (acceptable, since invocations last 10-200ms). ### Key Design Decisions **1. Log function defined in the pipeline, not as a library import.** The Python block runs inside a bash heredoc. Importing external modules adds startup cost and complexity. A 10-line `log()` function defined inline — with the file handle opened once at the top and closed implicitly at exit — is simpler, faster, and has zero dependency overhead. The alternative of `import logging` was evaluated in the ADR and rejected for its per-invocation import cost (~15-30ms even when disabled, unless the import itself is gated). **2. Invocation ID generated once, carried through all log lines.** Each hook invocation generates a 4-character hex ID (`inv=7a3f`) from `os.urandom(2)` (Python) or `[System.Random]::new().Next(0, 65535).ToString('x4')` (PowerShell). This is cheaper than UUIDs and sufficient for correlation — collisions within a single day's log file are unlikely at <500 invocations/day. The ID is generated before the first `log()` call and passed as a closure variable, so it appears on every line without being passed as an argument. **3. File handle opened once per invocation, not per log call.** Python's `open(path, 'a')` with `O_APPEND` semantics is called once when logging is enabled. All subsequent `log()` calls write to this handle. This avoids 6-10 open/close cycles per invocation and ensures atomic append semantics on POSIX systems (writes under `PIPE_BUF` are guaranteed atomic with `O_APPEND`). On Windows, `Add-Content` opens/writes/closes per call — this is PowerShell's design constraint, and the ADR documents the resulting atomicity asymmetry under heavy concurrency. **4. Pruning runs once per day, not per invocation.** When a log invocation opens a file for today and today's file didn't exist before this invocation, it also prunes files older than `debug_retention_days`. This is detected by attempting to create the file with exclusive mode (Python: check `os.path.exists()` before first write; PowerShell: test `Test-Path`). Subsequent invocations on the same day skip pruning entirely — they just append. **5. Values with spaces are double-quoted; values without spaces are unquoted.** This keeps simple cases (`event=Stop volume=0.5`) scannable while handling edge cases (`cwd="/home/user/my project"`) unambiguously. The quoting logic is a simple conditional: if the value contains spaces, quotes, or equals signs, wrap in double quotes with internal quotes escaped. Both implementations use the same convention, enforced by shared test fixtures with edge-case inputs. **6. Shell-side phases ([play], [notify]) log from bash/PowerShell, not Python.** The Python block handles phases [hook] through [exit] (the decision pipeline). But audio playback and notification dispatch happen in bash after `eval "$_PEON_PYOUT"`. To log [play] and [notify] phases, the Python block exports `_PEON_LOG_FILE` and `_PEON_INV_ID` as shell variables. A small bash `_peon_log()` function (5 lines) appends to the same file using the same format. On Windows, `peon.ps1` handles everything in PowerShell, so no handoff is needed. **7. Log format is explicitly unstable — no backward compatibility commitment.** The ADR states log files are "not a stable API." This design doc reinforces that: the format may change between any version. The README Debugging section will document this explicitly. Committing to format stability would constrain improvements to a feature whose primary consumer is a human reading a file, not a machine parsing it. If machine-parseable output becomes necessary, the ADR already identifies `debug_format: "jsonl"` as a small future refactor — at that point, the JSON format would get stability guarantees while key=value stays unstable. **8. `peon logs` writes to stdout without a pager.** No `$PAGER` integration. Users who want paging pipe through `less` themselves (`peon logs | less`). This keeps `peon logs` composable (`peon logs | grep route` works without `--session`), avoids platform differences in pager availability (Windows has no default pager), and avoids the complexity of detecting interactive vs. piped contexts. The `--last N` flag provides output limiting for interactive use. **9. Windows CLI commands stay inline in install.ps1.** The `debug` and `logs` CLI branches add ~60 lines to `peon.ps1` (embedded in `install.ps1`). This follows the existing pattern: every CLI command lives in the main hook script's case/switch block. Extracting to standalone scripts would add file management complexity, break the single-file deployment model, and diverge from how Unix handles it. `install.ps1` is already large; 60 more lines is marginal. ### Interface Design #### Config Keys ```json { "debug": false, "debug_retention_days": 7 } ``` Both keys are added to `config.json` (the template). The `peon update` config merge logic already backfills new template keys into existing user configs, so no migration code is needed. #### Log Line Format ``` YYYY-MM-DDTHH:MM:SS.mmm [phase] inv=XXXX key1=val1 key2=val2 ... ``` Timestamp is ISO-8601 with millisecond precision. Phase tags are bracketed. All remaining fields are `key=value` pairs separated by spaces. Values containing spaces, quotes, or `=` are double-quoted with `\"` escaping. #### Phase Emitters Each phase logs specific fields: ``` [hook] inv=XXXX event=Stop session=abc123 cwd=/path/to/project paused=false [config] inv=XXXX loaded=/path/to/config.json volume=0.5 pack=glados enabled=true [state] inv=XXXX sessions=3 rotation_index=2 last_stop=1711368121 [route] inv=XXXX category=task.complete suppressed=false reason="" [sound] inv=XXXX file=mission-complete.wav pack=glados candidates=4 no_repeat=true [play] inv=XXXX backend=afplay pid=48291 async=true volume=0.5 [notify] inv=XXXX desktop=true mobile=false template="✅ {project}: done" rendered="✅ myproj: done" [trainer] inv=XXXX active=true exercise=pushups reps=150 goal=300 reminder=false [exit] inv=XXXX duration_ms=10 exit=0 ``` Suppressed invocations log the reason: ``` [route] inv=XXXX category=none suppressed=true reason=delegate_mode [route] inv=XXXX category=task.complete suppressed=true reason=debounce_5s [route] inv=XXXX category=none suppressed=true reason=paused ``` Error paths log the failure: ``` [config] inv=XXXX error="FileNotFoundError: config.json" fallback=defaults [sound] inv=XXXX error="pack 'glados' not found" fallback=none [play] inv=XXXX error="afplay not found" backend=none ``` #### Python `log()` Function ```python # Defined at top of Python block, after config load _inv = os.urandom(2).hex() _log_enabled = cfg.get('debug', False) or os.environ.get('PEON_DEBUG') == '1' if _log_enabled: import datetime _log_dir = os.path.join(peon_dir, 'logs') os.makedirs(_log_dir, exist_ok=True) _log_date = datetime.date.today().isoformat() _log_path = os.path.join(_log_dir, f'peon-ping-{_log_date}.log') _log_is_new = not os.path.exists(_log_path) _log_fh = open(_log_path, 'a') def _log_quote(v): s = str(v) if ' ' in s or '"' in s or '=' in s or not s: return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"' return s def log(phase, **kw): ts = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S.') + \ f'{datetime.datetime.now().microsecond // 1000:03d}' parts = [f'{ts} [{phase}] inv={_inv}'] for k, v in kw.items(): parts.append(f'{k}={_log_quote(v)}') try: print(' '.join(parts), file=_log_fh, flush=True) except Exception: pass # logging must never break the hook # Prune old logs on first file of the day if _log_is_new: _retention = cfg.get('debug_retention_days', 7) try: for f in os.listdir(_log_dir): if f.startswith('peon-ping-') and f.endswith('.log'): fdate = f[len('peon-ping-'):-len('.log')] if fdate < (datetime.date.today() - datetime.timedelta(days=_retention)).isoformat(): os.remove(os.path.join(_log_dir, f)) except Exception: pass else: log = lambda phase, **kw: None ``` #### PowerShell `$peonLog` Function ```powershell $peonInv = '{0:x4}' -f [System.Random]::new().Next(0, 65535) $peonLogEnabled = ($config.debug -eq $true) -or ($env:PEON_DEBUG -eq '1') if ($peonLogEnabled) { $logDir = Join-Path $InstallDir 'logs' if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } $logDate = (Get-Date).ToString('yyyy-MM-dd') $logPath = Join-Path $logDir "peon-ping-$logDate.log" $logIsNew = -not (Test-Path $logPath) $peonLog = { param([string]$Phase, [hashtable]$Fields) $ts = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fff') $parts = "$ts [$Phase] inv=$peonInv" foreach ($kv in $Fields.GetEnumerator()) { $v = [string]$kv.Value if ($v -match '[ "=]' -or $v -eq '') { $v = '"' + ($v -replace '\\','\\' -replace '"','\"') + '"' } $parts += " $($kv.Key)=$v" } try { Add-Content -Path $logPath -Value $parts -ErrorAction Stop } catch { $script:peonLogEnabled = $false } # disable for rest of invocation } # Prune old logs on first file of the day if ($logIsNew) { $retention = if ($config.debug_retention_days) { $config.debug_retention_days } else { 7 } $cutoff = (Get-Date).AddDays(-$retention).ToString('yyyy-MM-dd') Get-ChildItem -Path $logDir -Filter 'peon-ping-*.log' -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -replace 'peon-ping-','' -lt $cutoff } | Remove-Item -Force -ErrorAction SilentlyContinue } } else { $peonLog = { } } ``` #### Bash `_peon_log()` Function (for [play] and [notify] phases) ```bash # After eval "$_PEON_PYOUT", if logging is active: if [ -n "${_PEON_LOG_FILE:-}" ]; then _peon_log() { local phase="$1"; shift local ts ts=$(date '+%Y-%m-%dT%H:%M:%S.000') printf '%s [%s] inv=%s %s\n' "$ts" "$phase" "$_PEON_INV_ID" "$*" >> "$_PEON_LOG_FILE" 2>/dev/null } else _peon_log() { :; } fi ``` The Python block exports these variables when logging is enabled: ```python if _log_enabled: print('_PEON_LOG_FILE=' + q(_log_path)) print('_PEON_INV_ID=' + q(_inv)) ``` #### CLI Commands **`peon debug on|off|status`** — Modifies `debug` in config.json. ```bash # peon debug on python3 -c " import json cfg = json.load(open('$CONFIG_PY')) cfg['debug'] = True json.dump(cfg, open('$CONFIG_PY', 'w'), indent=2) " echo "peon-ping: debug logging enabled — logs at $PEON_DIR/logs/" # peon debug off # Same pattern, cfg['debug'] = False # peon debug status # Read config.debug, count log files, show total size ``` **`peon logs [--last N] [--session ID] [--clear]`** — Reads log files. ```bash # peon logs (no args) — tail today's log, last 50 lines # peon logs --last 100 — last 100 lines across all log files # peon logs --session abc123 — grep for session=abc123 # peon logs --clear — rm $PEON_DIR/logs/peon-ping-*.log ``` On Windows, `peon.cmd` delegates to `peon.ps1` which handles `debug` and `logs` subcommands with equivalent PowerShell logic. ## Implementation Phases ### Phase 1: Core Logging in peon.sh (Python Block + Bash) **Goal**: A Unix user who runs `peon debug on` sees structured log output for every hook invocation, covering all decision phases. **Deliverables:** - `config.json`: Add `"debug": false` and `"debug_retention_days": 7` keys - `peon.sh` Python block (~3020): Insert `log()` function definition after config load - `peon.sh` Python block: Add `log()` calls at each phase: - After config load: `log('config', loaded=config_path, volume=..., pack=...)` - After event parse: `log('hook', event=..., session=..., cwd=...)` - After state load: `log('state', sessions=..., rotation_index=...)` - At route decision: `log('route', category=..., suppressed=..., reason=...)` - At sound pick: `log('sound', file=..., pack=..., candidates=...)` - At notification template: `log('notify', desktop=..., template=..., rendered=...)` - At trainer check: `log('trainer', active=..., reminder=...)` - At output: `log('exit', duration_ms=..., exit=0)` - `peon.sh` Python block output: Export `_PEON_LOG_FILE` and `_PEON_INV_ID` when logging active - `peon.sh` bash (after eval): Define `_peon_log()` shell function for `[play]` phase - `peon.sh` bash `play_sound()`: Add `_peon_log play backend=... pid=... async=...` - `peon.sh` bash `send_notification()`: Add `_peon_log notify ...` for desktop/mobile - Daily rotation: Prune on new-day file creation in Python block - Error paths: Every `except` block that currently silently falls back gets a `log()` call explaining the fallback **Test strategy:** - **Unit (BATS)**: Shared test fixture — feed known JSON event input, assert log file contains expected key=value lines in correct phase order. Fixture includes edge cases: paths with spaces, notification templates with emoji and braces, suppressed events (delegate mode, debounce), missing pack. - **Unit (BATS)**: `debug: false` produces no log file and no `logs/` directory - **Unit (BATS)**: `PEON_DEBUG=1` env var enables logging even when `debug: false` - **Unit (BATS)**: Rotation test — create 10 dated log files, set retention to 3, invoke hook, assert only 3 remain - **Integration (BATS)**: Run 5 concurrent hook invocations via `&`, assert all produce complete entries (each has `[hook]` through `[exit]`) with distinct `inv=` IDs **Infrastructure:** None — pure file I/O to existing `$PEON_DIR/`. **Documentation:** None this phase — Phase 3 covers docs. **Dependencies:** None. **Definition of done:** - [ ] `debug: false` (default) adds <1ms to hook execution time - [ ] `debug: true` produces a log file at `$PEON_DIR/logs/peon-ping-YYYY-MM-DD.log` - [ ] Log file contains `[hook]`, `[config]`, `[state]`, `[route]`, `[sound]`, `[play]`, `[notify]`, `[exit]` phases for a normal Stop event - [ ] Suppressed events (delegate mode, debounce, paused) log `[route]` with `reason=` - [ ] Error paths (missing pack, bad config) log `error=` with fallback - [ ] 5 concurrent invocations produce non-corrupted output with distinct `inv=` IDs - [ ] Rotation prunes files older than `debug_retention_days` - [ ] All existing BATS tests continue to pass (logging is invisible when disabled) ### Phase 2: PowerShell Parity in peon.ps1 **Goal**: A Windows user who runs `peon debug on` sees identical log output to Unix, validated by the same test fixtures. **Deliverables:** - `install.ps1` (peon.ps1 section): Insert `$peonLog` scriptblock definition after config load - `install.ps1` (peon.ps1 section): Add `& $peonLog` calls at each phase, matching Unix phases - `install.ps1` (peon.ps1 section): Add `[play]` logging around `win-play.ps1` invocation - `install.ps1` (peon.ps1 section): `[notify]` logging around `win-notify.ps1` invocation - `install.ps1` (peon.ps1 section): `Add-Content` with try/catch guard that disables logging on `IOException` - `tests/hook-logging.Tests.ps1`: New Pester test file validating log output against shared fixtures **Test strategy:** - **Unit (Pester)**: Same shared fixture inputs as Phase 1 — feed JSON, assert log output matches expected format character-for-character (excluding timestamp/path/inv values) - **Unit (Pester)**: `debug: false` produces no log file - **Unit (Pester)**: `PEON_DEBUG=1` env var override works - **Unit (Pester)**: Rotation prunes correctly - **Unit (Pester)**: `Add-Content` failure disables logging for remainder of invocation (simulated via read-only file) **Infrastructure:** None. **Documentation:** None this phase. **Dependencies:** Phase 1 (shared fixture format must be established first). **Definition of done:** - [ ] PowerShell log output matches Unix format for the shared test fixture inputs - [ ] `debug: false` adds no measurable overhead - [ ] `Add-Content` IOException disables logging gracefully (no hook failure) - [ ] Rotation prunes correctly on Windows - [ ] All existing Pester tests continue to pass - [ ] CI runs both BATS and Pester test suites against the shared fixture ### Phase 3: CLI Commands, Completions, and Documentation **Goal**: Users can toggle debug logging and read logs via `peon debug` and `peon logs` commands on all platforms, with tab completion and documentation. **Deliverables:** - `peon.sh` CLI section (~line 924): Add `debug)` and `logs)` case branches - `peon debug on` — set `config.debug = true`, print confirmation with log directory path - `peon debug off` — set `config.debug = false`, print confirmation - `peon debug status` — show debug state, log file count, total size, retention days - `peon logs` — tail today's log file (last 50 lines) - `peon logs --last N` — last N lines across log files (newest first) - `peon logs --session ID` — grep all log files for `session=ID` - `peon logs --clear` — delete all log files with confirmation prompt - `install.ps1` (peon.ps1 CLI section): Add equivalent `debug` and `logs` commands - `completions.bash`: Add `debug` and `logs` to top-level commands, `on off status` for debug, `--last --session --clear` for logs - `completions.fish`: Add same completions with descriptions - `README.md`: Add "Debugging" section documenting `peon debug`, `peon logs`, log format, and common failure diagnosis patterns - `README_zh.md`: Translated equivalent of Debugging section - `docs/public/llms.txt`: Update with debugging commands and log format **Test strategy:** - **Unit (BATS)**: `peon debug on` sets `config.debug` to `true` in config file - **Unit (BATS)**: `peon debug off` sets `config.debug` to `false` - **Unit (BATS)**: `peon debug status` outputs current state - **Unit (BATS)**: `peon logs --last 10` outputs correct number of lines - **Unit (BATS)**: `peon logs --session abc123` filters correctly - **Unit (BATS)**: `peon logs --clear` removes log files - **Unit (Pester)**: Equivalent tests for Windows CLI commands **Infrastructure:** None. **Documentation:** README.md, README_zh.md, docs/public/llms.txt (all part of this phase). **Dependencies:** Phase 1 (Unix logging), Phase 2 (Windows logging). **Definition of done:** - [ ] `peon debug on` enables logging, `peon debug off` disables it (both platforms) - [ ] `peon debug status` shows debug state and log statistics - [ ] `peon logs` displays recent log entries with correct formatting - [ ] `peon logs --session` filters by session ID - [ ] `peon logs --clear` removes log files with confirmation - [ ] Tab completion works in bash, zsh, and fish for all new commands - [ ] README.md Debugging section explains the 5 common failure patterns and how to diagnose each from log output - [ ] README_zh.md has translated equivalent - [ ] `docs/public/llms.txt` updated - [ ] All new CLI commands have BATS and Pester test coverage ## Migration & Rollback **Migration**: Pure addition. Two new keys (`debug`, `debug_retention_days`) added to `config.json` template. The `peon update` config merge logic already backfills missing keys from the template, so existing users get the new keys with defaults on next update. Users who never update still work — the Python/PowerShell code defaults to `False`/`7` when keys are absent. **Backward compatibility**: The existing `PEON_DEBUG=1` env var in `win-play.ps1` continues to work independently. When both the new config-based logging and the legacy `PEON_DEBUG` are active, both outputs fire — they are additive, covering different scopes (full pipeline vs. audio-only). **Rollback**: Clean git revert. No state migration, no external dependencies, no schema changes. Log files in `$PEON_DIR/logs/` can be deleted manually or via `peon logs --clear`. ## Risks | Risk | Impact | Likelihood | Mitigation | |------|--------|------------|------------| | Format drift between Python and PowerShell implementations | Users see inconsistent log output across platforms; tooling built on log format breaks | Medium | Shared test fixtures with identical expected output, validated by both BATS and Pester in CI. Any format change must update the fixture and pass both platforms. | | Log I/O causes hook timeout under heavy concurrency | Hooks killed by 10-second Claude Code timeout, sounds stop playing | Low | Log writes are <1ms each (append to open handle). Total logging budget is ~5ms for 8-10 calls. If I/O blocks (disk contention), try/catch disables logging for remainder of invocation. | | Windows `Add-Content` lock contention drops log entries | Sprint operators missing diagnostic data for some concurrent invocations | Medium (sprint dispatch only) | Documented in ADR as acceptable tradeoff. Try/catch disables logging per-invocation on IOException. Recommend `PEON_DEBUG=1` env var per-agent for targeted diagnosis. | | `datetime` import adds startup cost even when logging disabled | Violates <1ms overhead requirement | Low | `import datetime` is inside the `if _log_enabled:` branch. When disabled, no import occurs. | | Log files grow large with many concurrent agents | Disk space consumption | Low | Daily rotation with 7-day default retention. At worst case (500 invocations/day × 800 bytes × 7 days), total is ~2.8 MB. `peon logs --clear` provides manual cleanup. | ## Roadmap Connection This design implements **v2/m4** ("When something breaks, you can see why"), which currently has no features or projects defined. After this design doc is accepted: 1. Create features under m4 for each phase (or a single feature with 3 projects matching the 3 implementation phases) 2. The sprint-architect can create cards directly from the phase definitions of done 3. M4's `docs_ref` should be updated to point to this design doc (the milestone already references PRD-002) ## Known Asymmetries ### `paused.expected.txt` fixture is Unix-only The shared test fixture `tests/fixtures/hook-logging/paused.expected.txt` validates log output when the hook is invoked while peon-ping is paused (`enabled: false`). This fixture applies only to the Unix (BATS) side. On Windows, `peon.ps1` exits early when paused — `if (-not $config.enabled) { exit 0 }` fires before the logging infrastructure is initialized, so no log file is created. The Pester test validates only that the exit code is 0 in the paused case. This diverges from `peon.sh`, where the Python block runs past the enabled check and logs the paused state mid-pipeline (the `[hook] ... paused=true` line appears in the log before the script exits). The asymmetry is inherent to the implementation: Python evaluates the full decision pipeline and emits log lines along the way, while PowerShell's early-exit guard precedes all logging setup. ## Open Questions None — all resolved during design. --- ## Revision History | Date | Author | Notes | |------|--------|-------| | 2026-03-25 | cameron | Initial design | --- ### Designs/Tts Integration # Design Doc: TTS Integration Layer and Backend Contract > **ADR**: [ADR-001](../adr/ADR-001-tts-backend-architecture.md) | **Date**: 2026-03-28 | **Author**: cameron ## Overview This document designs the TTS integration layer for peon-ping — the foundation that every TTS feature builds on. ADR-001 decided that TTS backends ship as independent, self-contained scripts invoked through a minimal calling convention (text on stdin, voice/rate/volume as arguments). This design doc works out the specifics: where the hook pipeline gains TTS awareness, how speech text is resolved from multiple sources, how mode sequencing coordinates sound and speech, how backend resolution selects the right script, and how the config schema and state management extend to support TTS. The integration layer is deliberately inert on its own — it resolves text and invokes a backend script, but without a backend installed (tts-native ships separately), `peon tts on` detects no available engine and tells the user. This separation means the integration layer can be tested against mock backends without platform-specific TTS dependencies. ## Requirements The implementation is complete when: 1. The hook pipeline in `peon.sh` resolves speech text from the configured source chain and invokes the selected TTS backend as an async background process after (or instead of, per mode) sound playback — without increasing hook return latency. 2. The hook pipeline in `install.ps1` (embedded `peon.ps1` engine) provides identical TTS behavior to the Unix implementation — same config keys, same text resolution, same mode sequencing, same async contract. 3. A TTS backend script receives text on stdin with voice, rate, and volume as arguments — nothing else. No hook context, no config access, no state file. The contract is the calling convention and nothing more. 4. `config.json` gains a `tts` section with `enabled`, `backend`, `voice`, `rate`, `volume`, and `mode` fields, and `peon update` backfills this section for existing installs without overwriting user-modified values. 5. Backend resolution maps `config.tts.backend` to a script path via an explicit `case`/`switch` block. `"auto"` resolves to the best available backend by probing for installed scripts in priority order. When no backend is available, TTS is silently skipped during hooks and explicitly reported during `peon tts on`. 6. Speech text resolution follows a defined chain: manifest `speech_text` field (when present) → notification template for the active category → default template `"{project} — {status}"`. Empty resolved text skips TTS entirely. 7. TTS PID tracking (`.tts.pid`) is independent from sound PID (`.sound.pid`), enabling the three sequencing modes and independent kill-previous behavior. 8. All existing suppression rules (`headphones_only`, `meeting_detect`, `suppress_sound_when_tab_focused`, pause/mute state) apply to TTS identically to how they apply to sound playback. ## Current State ### Hook pipeline (Unix — `peon.sh`) The embedded Python block (lines ~3329–3778) handles event routing, category mapping, sound selection, notification template resolution, and trainer reminder logic. It outputs shell variables via `print()` statements: ``` SOUND_FILE=/path/to/sound.mp3 VOLUME=0.5 NOTIFY=true MSG="api-server — Task complete" TRAINER_SOUND=/path/to/trainer.mp3 TRAINER_MSG="Time for reps!" ``` Shell code `eval`s these variables, then `_run_sound_and_notify()` (line 3927) handles playback: 1. Check suppression rules (headphones, meeting, tab focus) 2. `play_sound "$SOUND_FILE" "$VOLUME"` — platform-dispatched, async via `nohup ... &` 3. `send_notification` — desktop notification with template-rendered text 4. `send_mobile_notification` — push notification if configured After `_run_sound_and_notify` returns (backgrounded via `& disown` in production), trainer reminder logic (line 3968) waits for the main sound PID to finish, pauses 0.5s, then plays the trainer sound and sends a trainer notification. ### Hook pipeline (Windows — embedded in `install.ps1`) The `peon.ps1` engine (lines ~1400–1900 of `install.ps1`) mirrors the Python logic in pure PowerShell. Sound selection, anti-repeat, icon resolution, and notification template resolution all follow the same patterns. Audio delegates to `scripts/win-play.ps1` via `Start-Process -WindowStyle Hidden`. Trainer reminder logic follows the same wait-then-play pattern. ### Sound PID tracking - `save_sound_pid()` writes PID to `$PEON_DIR/.sound.pid` - `kill_previous_sound()` reads and kills the previous PID before playing a new sound - Trainer reminder waits for `.sound.pid` to finish before playing its sound ### Notification template resolution The Python block (lines 3715–3740) resolves templates using `_tpl_vars`: ```python _tpl_vars = defaultdict(str, { 'project': project, 'summary': event_data.get('transcript_summary', '').strip()[:120], 'tool_name': event_data.get('tool_name', ''), 'status': status, 'event': event, }) msg = _tpl.format_map(_tpl_vars) ``` Template key mapping: `task.complete` → `stop`, `task.error` → `error`, `PermissionRequest` → `permission`, idle/question from notification subtypes. ### Config structure `config.json` has 44 keys. The `trainer` section (nested object) establishes the pattern for feature namespaces. No `tts` section exists today. ### State management `.state.json` uses atomic writes (temp file + `os.replace()` on Unix, `Write-StateAtomic` on Windows). Stores `last_played`, `session_packs`, `prompt_timestamps`, `trainer`, etc. Read with retry logic (3 attempts with backoff). ## Target State After this implementation, the hook pipeline gains a TTS phase between sound playback and notification dispatch. The flow becomes: ``` Event JSON → Python/PS routing → Category → Sound selection → Speech text resolution (NEW) ↓ _run_sound_and_notify() ├── play_sound() [existing] ├── speak() [NEW] ├── send_notification() [existing] └── send_mobile_notif() [existing] ↓ Trainer reminder (if applicable) ├── wait for .sound.pid [existing] ├── wait for .tts.pid [NEW — avoids overlap] ├── play trainer sound [existing] ├── speak trainer text [NEW] └── send trainer notif [existing] ``` ### Architecture ``` ┌──────────────────────┐ │ Hook Pipeline │ │ (peon.sh / peon.ps1) │ └──────┬───────────────┘ │ ┌────────────┼────────────────┐ │ │ │ ┌───────▼──┐ ┌─────▼─────┐ ┌──────▼──────┐ │play_sound│ │ speak() │ │send_notif() │ │ .sound.pid│ │ .tts.pid │ │ │ └──────────┘ └─────┬─────┘ └─────────────┘ │ ┌───────────┼───────────────┐ │ │ │ ┌─────▼────┐ ┌───▼─────┐ ┌──────▼──────┐ │tts-native│ │tts-11lab│ │ tts-piper │ │ .sh │ │ .sh │ │ .sh │ │ .ps1 │ │ .ps1 │ │ .ps1 │ └──────────┘ └─────────┘ └─────────────┘ ``` The `speak()` function is the integration layer's single responsibility: resolve the backend, construct the invocation, and fire it as a background process. Each backend script is a black box that reads text from stdin and produces audio. ### Mode sequencing in `_run_sound_and_notify` ``` sound-then-speak (default): play_sound() → speak() → notifications speak-only: [skip sound] → speak() → notifications speak-then-sound: speak() → play_sound() → notifications ``` In `sound-then-speak` and `speak-then-sound`, both sound and speech fire as independent background processes — no waiting. The perceptual ordering comes from invocation order and the ~200ms startup delta. `speak-only` skips `play_sound()` entirely. ## Design ### Key Design Decisions **1. Speech text resolution happens in the Python/PowerShell routing block, not in `speak()`.** The Python block already has access to the manifest (`pick` variable with the chosen sound entry), notification templates (`_tpl_vars`), and event context (`project`, `status`, `summary`). Resolving speech text here means `speak()` receives already-interpolated plain text — no template engine, no manifest access, no event context needed in the shell function or backend scripts. Alternative considered: having `speak()` accept template strings and variables, resolving text at invocation time. Rejected because it duplicates the template resolution already done for notifications and splits the "what text to produce" logic across two locations. **2. `_run_sound_and_notify()` handles mode sequencing; `speak()` is mode-unaware.** `_run_sound_and_notify()` is the only place that knows about both `play_sound()` and `speak()`, so it owns the ordering decision. A `case` on `TTS_MODE` determines whether sound fires first, speech fires first, or sound is skipped entirely. `speak()` itself is a thin wrapper — resolve backend, invoke script, track PID — with no knowledge of modes. This keeps each function to a single responsibility: the caller sequences, the speaker speaks. Alternative considered: having `speak()` handle mode logic internally, checking `TTS_MODE` and coordinating with `play_sound()`. Rejected because `speak()` would need to know about sound playback state and SOUND_FILE availability — concerns that belong to the caller. ```bash _run_sound_and_notify() { # ... suppression checks (applied to both sound and TTS) ... if [ "$_skip_sound" = "false" ]; then case "${TTS_MODE:-sound-then-speak}" in sound-then-speak) [ -n "$SOUND_FILE" ] && [ -f "$SOUND_FILE" ] && play_sound "$SOUND_FILE" "$VOLUME" [ -n "$TTS_TEXT" ] && speak "$TTS_TEXT" ;; speak-only) [ -n "$TTS_TEXT" ] && speak "$TTS_TEXT" ;; speak-then-sound) [ -n "$TTS_TEXT" ] && speak "$TTS_TEXT" [ -n "$SOUND_FILE" ] && [ -f "$SOUND_FILE" ] && play_sound "$SOUND_FILE" "$VOLUME" ;; esac fi # ... notifications (unchanged) ... } ``` Each branch guards both `SOUND_FILE` (may be empty if no sound for this category) and `TTS_TEXT` (may be empty if TTS disabled or text resolved empty). When `SOUND_FILE` is empty, only TTS fires regardless of mode. When `TTS_TEXT` is empty, only sound fires — existing behavior preserved exactly. **3. Backend resolution is a static `case` block, not filesystem scanning.** The ADR explicitly chose this: "Explicit is better than implicit for a system with <10 backends." The hook pipeline maps config values to script paths: ```bash # Unix — always returns a script filename (e.g., "tts-native.sh"), never an absolute path. # The caller (speak()) resolves to absolute via find_bundled_script. _resolve_tts_backend() { local backend="${1:-auto}" case "$backend" in native) echo "tts-native.sh" ;; elevenlabs) echo "tts-elevenlabs.sh" ;; piper) echo "tts-piper.sh" ;; auto) # Probe in priority order: prefer premium when installed. # At Phase 1 launch, only native exists — probes are ~1ms each. for b in elevenlabs piper native; do local script_name script_name="$(_resolve_tts_backend "$b")" || continue find_bundled_script "$script_name" >/dev/null 2>&1 || continue echo "$script_name" && return 0 done return 1 # no backend available ;; *) return 1 ;; esac } ``` ```powershell # Windows — always returns a script filename (e.g., "tts-native.ps1"), never a full path. # The caller (Invoke-TtsSpeak) resolves to absolute via Join-Path $InstallDir "scripts\$name". function Resolve-TtsBackend { param([string]$Backend = "auto") switch ($Backend) { "native" { return "tts-native.ps1" } "elevenlabs" { return "tts-elevenlabs.ps1" } "piper" { return "tts-piper.ps1" } "auto" { # Probe in priority order: prefer premium when installed. # At Phase 1 launch, only native exists — probes are ~1ms each. foreach ($b in @("elevenlabs", "piper", "native")) { $scriptName = Resolve-TtsBackend -Backend $b $full = Join-Path $InstallDir "scripts\$scriptName" if (Test-Path $full) { return $scriptName } } return $null } default { return $null } } } ``` The `auto` probe order prefers premium backends when installed (ElevenLabs > Piper > native). At Phase 1 launch, only `native` exists, so `auto` trivially resolves to `native`. This ordering means users who later install ElevenLabs get an automatic upgrade without config changes. **4. TTS PID tracking uses `.tts.pid` separate from `.sound.pid`.** The ADR specified this for mode independence. The `speak()` function manages `.tts.pid` with the same kill-previous pattern as `kill_previous_sound()`: ```bash kill_previous_tts() { local pidfile="$PEON_DIR/.tts.pid" if [ -f "$pidfile" ]; then local old_pid old_pid=$(cat "$pidfile" 2>/dev/null) if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then kill "$old_pid" 2>/dev/null fi rm -f "$pidfile" fi } save_tts_pid() { echo "$1" > "$PEON_DIR/.tts.pid" } ``` The trainer subshell waits for *both* `.sound.pid` and `.tts.pid` to complete before playing trainer content. In `sound-then-speak` mode, main TTS starts after main sound — so `.sound.pid` can finish while `.tts.pid` is mid-utterance. Waiting for both PIDs prevents the trainer sound from overlapping with the main event's TTS phrase. The wait logic uses the same poll pattern as the existing `.sound.pid` wait (10s timeout, 100ms poll interval). **5. `PEON_TEST=1` makes TTS synchronous for test capture.** The existing test pattern: `PEON_TEST=1` causes `play_sound()` to run synchronously (no `nohup`, no `&`). TTS follows the same convention — `speak()` runs the backend in the foreground when `PEON_TEST=1`, allowing BATS tests to capture the invocation and verify arguments. In production, the backend runs via `nohup ... &` (Unix) or `Start-Process -WindowStyle Hidden` (Windows). ### Interface Design #### `speak()` shell function (Unix) ```bash speak() { local text="$1" [ -z "$text" ] && return 0 kill_previous_tts # _resolve_tts_backend returns a script filename (e.g., "tts-native.sh"). # find_bundled_script resolves it to an absolute path. local script_name script_name="$(_resolve_tts_backend "${TTS_BACKEND:-auto}")" || return 0 local abs_script abs_script="$(find_bundled_script "$script_name")" 2>/dev/null || return 0 [ -x "$abs_script" ] || return 0 local voice="${TTS_VOICE:-default}" local rate="${TTS_RATE:-1.0}" local vol="${TTS_VOLUME:-0.5}" if [ "${PEON_TEST:-0}" = "1" ]; then printf '%s\n' "$text" | "$abs_script" "$voice" "$rate" "$vol" >/dev/null 2>&1 else # printf '%s\n' is used instead of echo to avoid flag interpretation # (e.g., text starting with "-n" or "-e"). Text is passed as $0 to sh -c, # avoiding shell interpolation of metacharacters in the text content. nohup sh -c 'printf "%s\n" "$0" | "$1" "$2" "$3" "$4"' \ "$text" "$abs_script" "$voice" "$rate" "$vol" >/dev/null 2>&1 & save_tts_pid $! fi } ``` #### `Invoke-TtsSpeak` PowerShell function (Windows) ```powershell function Invoke-TtsSpeak { param( [string]$Text, [string]$Backend = "auto", [string]$Voice = "default", [double]$Rate = 1.0, [double]$Volume = 0.5 ) if (-not $Text) { return } # Kill previous TTS $pidFile = Join-Path $InstallDir ".tts.pid" if (Test-Path $pidFile) { $oldPid = Get-Content $pidFile -ErrorAction SilentlyContinue if ($oldPid) { try { Stop-Process -Id $oldPid -Force -ErrorAction SilentlyContinue } catch {} } Remove-Item $pidFile -Force -ErrorAction SilentlyContinue } $scriptName = Resolve-TtsBackend -Backend $Backend if (-not $scriptName) { return } $scriptPath = Join-Path $InstallDir "scripts\$scriptName" if (-not (Test-Path $scriptPath)) { return } # Text is Base64-encoded to avoid shell metacharacter injection. Dynamic text # from template variables ({summary}, {project}) can contain double quotes, # dollar signs, backticks, and other PowerShell-interpreted characters that # would corrupt or break a directly-interpolated -Command string. This matches # the Unix side's safety guarantee (text passed as $0, never interpolated). $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Text)) $proc = Start-Process -FilePath "powershell.exe" ` -ArgumentList "-NoProfile", "-NonInteractive", "-Command", "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$b64')) | & '$scriptPath' -voice '$Voice' -rate $Rate -vol $Volume" ` -WindowStyle Hidden -PassThru $proc.Id | Set-Content $pidFile } ``` #### Python block output (new variables) The Python block gains these additional `print()` outputs: ```python print('TTS_ENABLED=' + ('true' if tts_enabled else 'false')) print('TTS_TEXT=' + q(tts_text)) print('TTS_BACKEND=' + q(tts_backend)) print('TTS_VOICE=' + q(tts_voice)) print('TTS_RATE=' + q(str(tts_rate))) print('TTS_VOLUME=' + q(str(tts_volume))) print('TTS_MODE=' + q(tts_mode)) print('TRAINER_TTS_TEXT=' + q(trainer_tts_text)) ``` #### Speech text resolution (Python block addition) ```python # --- TTS speech text resolution --- tts_cfg = cfg.get('tts', {}) tts_enabled = tts_cfg.get('enabled', False) and not paused tts_text = '' tts_backend = tts_cfg.get('backend', 'auto') tts_voice = tts_cfg.get('voice', 'default') tts_rate = tts_cfg.get('rate', 1.0) tts_volume = tts_cfg.get('volume', 0.5) tts_mode = tts_cfg.get('mode', 'sound-then-speak') if tts_enabled and category: # Chain: manifest speech_text → notification template → default if pick and pick.get('speech_text'): _speech_tpl = pick['speech_text'] elif _tpl: _speech_tpl = _tpl # already resolved notification template else: _speech_tpl = '{project} \u2014 {status}' try: tts_text = _speech_tpl.format_map(_tpl_vars) except Exception: tts_text = '' # Empty after interpolation → skip tts_text = tts_text.strip() if tts_text == '\u2014' or not tts_text: tts_text = '' ``` The `pick` variable (the chosen manifest sound entry) is already in scope from sound selection (line 3571). The `_tpl_vars` dict is already populated from notification template resolution (line 3730). This placement — after both sound selection and template resolution — means all inputs are available. `TRAINER_TTS_TEXT` reuses the existing trainer progress string verbatim — the same string that currently renders into `TRAINER_MSG` for desktop notifications. No reformatting for speech in this phase (resolved question #1): ```python # After trainer reminder logic (which already computes trainer_msg): trainer_tts_text = trainer_msg if (tts_enabled and trainer_msg) else '' ``` #### Speech text resolution (PowerShell addition) ```powershell # --- TTS speech text resolution --- $ttsCfg = if ($config.tts) { $config.tts } else { @{} } $ttsEnabled = ($ttsCfg.enabled -eq $true) -and (-not $paused) $ttsText = "" $ttsBackend = if ($ttsCfg.backend) { $ttsCfg.backend } else { "auto" } $ttsVoice = if ($ttsCfg.voice) { $ttsCfg.voice } else { "default" } $ttsRate = if ($ttsCfg.rate) { $ttsCfg.rate } else { 1.0 } $ttsVolume = if ($ttsCfg.volume) { $ttsCfg.volume } else { 0.5 } $ttsMode = if ($ttsCfg.mode) { $ttsCfg.mode } else { "sound-then-speak" } if ($ttsEnabled -and $category) { $speechTpl = "" if ($chosen -and $chosen.speech_text) { $speechTpl = $chosen.speech_text } elseif ($resolvedTemplate) { $speechTpl = $resolvedTemplate } else { $speechTpl = "{project} `u{2014} {status}" } # Interpolate template variables (same set as notification templates) $ttsText = $speechTpl foreach ($key in $tplVars.Keys) { $ttsText = $ttsText.Replace("{$key}", $tplVars[$key]) } $ttsText = $ttsText.Trim() if ($ttsText -eq "`u{2014}" -or -not $ttsText) { $ttsText = "" } } ``` #### PowerShell mode sequencing (Windows) The PowerShell sound playback section gains the same mode-aware branching as Unix: ```powershell if (-not $skipSound) { switch ($ttsMode) { "sound-then-speak" { if ($soundFile -and (Test-Path $soundFile)) { Play-Sound $soundFile $volume } if ($ttsText) { Invoke-TtsSpeak -Text $ttsText -Backend $ttsBackend -Voice $ttsVoice -Rate $ttsRate -Volume $ttsVolume } } "speak-only" { if ($ttsText) { Invoke-TtsSpeak -Text $ttsText -Backend $ttsBackend -Voice $ttsVoice -Rate $ttsRate -Volume $ttsVolume } } "speak-then-sound" { if ($ttsText) { Invoke-TtsSpeak -Text $ttsText -Backend $ttsBackend -Voice $ttsVoice -Rate $ttsRate -Volume $ttsVolume } if ($soundFile -and (Test-Path $soundFile)) { Play-Sound $soundFile $volume } } } } ``` #### Config schema addition ```json { "tts": { "enabled": false, "backend": "auto", "voice": "default", "rate": 1.0, "volume": 0.5, "mode": "sound-then-speak" } } ``` Added to `config.json` defaults. Runtime code uses `cfg.get('tts', {})` with per-field defaults, so missing keys in existing configs are safe. #### Debug logging TTS adds a `[tts]` log phase following the existing structured logging format: ``` [tts] enabled=true backend=native voice=Alex rate=1.0 volume=0.5 mode=sound-then-speak [tts] text="api-server — all tests passing" source=template [tts] cmd="scripts/tts-native.sh" pid=48291 ``` Or when skipped: ``` [tts] enabled=true backend=auto resolved=none skip=no_backend [tts] enabled=true text="" skip=empty_text [tts] enabled=false skip=disabled ``` ## Implementation Phases ### Phase 1: Config schema and `peon update` backfill **Goal:** Existing installs gain the `tts` config section on next update, and new installs include it by default. **Deliverables:** - `config.json` updated with `tts` section (6 keys, all with safe defaults) - `peon update` config merge logic extended to backfill `tts` section without overwriting user-modified values (same merge pattern as every prior config addition) - `install.ps1` Windows installer includes `tts` section in generated config **Test strategy:** - **Unit (BATS):** `peon update` on a config without `tts` section adds it with correct defaults. `peon update` on a config with existing `tts` section preserves user values. - **Unit (Pester):** Same two cases for the Windows config generation path. **Infrastructure:** None. **Documentation:** None (config key docs ship with tts-docs feature). **Dependencies:** None. **Definition of done:** - [ ] `config.json` contains `tts` section with 6 keys - [ ] `peon update` backfills `tts` section on configs that lack it - [ ] `peon update` preserves existing `tts` values when section already present - [ ] Windows installer generates config with `tts` section - [ ] All existing BATS and Pester tests pass (no regressions) ### Phase 2: Speech text resolution in Python block **Goal:** The Python routing block resolves TTS speech text from the manifest/template/default chain and outputs `TTS_*` shell variables. **Deliverables:** - Speech text resolution logic added to `peon.sh` Python block (after sound selection and template resolution) - 8 new `print()` outputs: `TTS_ENABLED`, `TTS_TEXT`, `TTS_BACKEND`, `TTS_VOICE`, `TTS_RATE`, `TTS_VOLUME`, `TTS_MODE`, `TRAINER_TTS_TEXT` - TTS config loading with safe defaults (`cfg.get('tts', {})`) **Test strategy:** - **Unit (BATS):** Mock events with TTS enabled verify correct `TTS_TEXT` output for each source in the resolution chain: (a) manifest `speech_text` present → uses it, (b) notification template configured → uses it, (c) neither → uses default `"{project} — {status}"`. (d) Empty text after interpolation → `TTS_TEXT` is empty. (e) TTS disabled → `TTS_ENABLED=false`, no `TTS_TEXT`. **Infrastructure:** None. **Documentation:** None. **Dependencies:** Phase 1 (config schema). **Definition of done:** - [ ] Python block reads `tts` config section with safe defaults - [ ] `TTS_TEXT` resolves from manifest `speech_text` when present - [ ] `TTS_TEXT` falls back to notification template when no `speech_text` - [ ] `TTS_TEXT` falls back to default template `"{project} — {status}"` when no notification template - [ ] Empty resolved text produces empty `TTS_TEXT` - [ ] `TTS_ENABLED=false` when TTS disabled or hook is paused - [ ] `TRAINER_TTS_TEXT` populated with trainer progress string when trainer fires and TTS enabled - [ ] All 8 `TTS_*` variables printed in output block ### Phase 3: `speak()` function, PID tracking, and mode sequencing (Unix) **Goal:** `peon.sh` gains the `speak()` shell function, TTS PID management, backend resolution, and mode-aware sequencing in `_run_sound_and_notify()`. **Deliverables:** - `speak()` function: backend resolution → script invocation → PID tracking - `_resolve_tts_backend()` function: config value → script path mapping with `auto` probing - `kill_previous_tts()` and `save_tts_pid()` functions - `_run_sound_and_notify()` updated with mode-aware sound/TTS ordering - Trainer reminder block updated to wait for both `.sound.pid` and `.tts.pid`, then speak `TRAINER_TTS_TEXT` after trainer sound - All suppression rules (`headphones_only`, `meeting_detect`, `suppress_sound_when_tab_focused`, pause) applied to TTS - `PEON_TEST=1` synchronous mode for test capture - `[tts]` debug log phase entries **Test strategy:** - **Unit (BATS):** Mock backend script (logs invocation args to a file instead of speaking). Tests verify: (a) `speak()` invokes backend with correct args order, (b) text passed on stdin, (c) mode sequencing — `sound-then-speak` plays sound then speaks, `speak-only` skips sound, `speak-then-sound` speaks then plays sound, (d) empty `TTS_TEXT` skips TTS invocation entirely, (e) `TTS_ENABLED=false` skips all TTS, (f) suppression rules suppress TTS same as sound, (g) kill-previous kills old `.tts.pid` before new speak, (h) `auto` backend resolution probes scripts in order, (i) missing backend script → graceful skip. - **Integration (BATS):** Full hook invocation with TTS enabled, mock backend, and mock `afplay` — verify both sound and TTS fire in correct order for each mode. **Infrastructure:** None. **Documentation:** None. **Dependencies:** Phase 2 (speech text resolution). **Definition of done:** - [ ] `speak()` invokes resolved backend script with text on stdin - [ ] Backend receives voice, rate, volume as positional args - [ ] `.tts.pid` written after background TTS process starts - [ ] `kill_previous_tts()` kills old TTS before new invocation - [ ] `_run_sound_and_notify()` respects `TTS_MODE` for ordering - [ ] `speak-only` mode skips `play_sound()` entirely - [ ] All suppression rules apply to TTS - [ ] `PEON_TEST=1` runs TTS synchronously - [ ] `[tts]` debug log entries emitted when debug enabled - [ ] Trainer subshell waits for both `.sound.pid` and `.tts.pid` before playing trainer content - [ ] Trainer speaks `TRAINER_TTS_TEXT` after trainer sound when TTS enabled - [ ] Hook return latency unchanged (TTS is async) - [ ] Missing backend → silent skip, no error ### Phase 4: PowerShell port (Windows) **Goal:** The Windows hook engine (`install.ps1` embedded `peon.ps1`) provides identical TTS behavior to the Unix implementation. **Deliverables:** - `Invoke-TtsSpeak` function: backend resolution → `Start-Process` invocation → PID tracking - `Resolve-TtsBackend` function: config value → script path mapping - TTS PID management (`.tts.pid` read/write/kill) - Mode-aware sequencing in the sound playback section - Speech text resolution in the PowerShell routing block (mirrors Python block output) - Trainer TTS integration - Suppression rules applied to TTS **Test strategy:** - **Unit (Pester):** (a) `Resolve-TtsBackend` returns correct paths for each named backend, (b) `Resolve-TtsBackend -Backend auto` probes in priority order, (c) speech text resolution chain produces correct output for manifest/template/default sources, (d) TTS disabled → no `Start-Process` call, (e) mode sequencing logic branches correctly, (f) `.tts.pid` file management (write on speak, read/kill on next speak). **Infrastructure:** None. **Documentation:** None. **Dependencies:** Phase 3 (Unix implementation validates the design; Windows mirrors it). **Definition of done:** - [ ] `Invoke-TtsSpeak` invokes resolved backend via `Start-Process -WindowStyle Hidden` - [ ] Backend receives text on stdin via PowerShell pipeline - [ ] `.tts.pid` managed (write/read/kill) - [ ] Mode sequencing matches Unix behavior - [ ] Speech text resolution chain matches Python block logic - [ ] All suppression rules apply - [ ] Trainer speaks progress when TTS enabled - [ ] All existing Pester tests pass (no regressions) ## Migration & Rollback **Migration:** `peon update` backfills the `tts` section into existing configs. Runtime code defaults every `tts` field individually via `.get()` with fallbacks, so partially-populated configs are safe. `tts.enabled` defaults to `false`, meaning TTS has no effect until explicitly activated — zero behavior change for existing users. **Rollback:** Clean `git revert`. The `tts` section in config is inert when `enabled: false` (the default). Reverting the code leaves the config section harmless. No state migration — `.tts.pid` is only created when TTS runs and is cleaned up by `kill_previous_tts`. ## Risks | Risk | Impact | Likelihood | Mitigation | |------|--------|------------|------------| | Speech text resolution adds latency to Python block | Hook return delayed for all users, not just TTS users | Low | Resolution is pure string operations (dict lookup + `format_map`). No I/O, no subprocess. Measured at <1ms in similar template resolution for notifications. | | Mode sequencing interacts badly with trainer wait logic | Trainer sound waits for wrong PID, causing silence or overlap | Medium | Trainer subshell waits for *both* `.sound.pid` and `.tts.pid` before playing trainer content. This prevents overlap in `sound-then-speak` mode where main TTS may still be speaking when `.sound.pid` finishes. Clear PID separation prevents cross-talk. Test all mode × trainer combinations. | | `nohup sh -c` text quoting breaks on edge cases | Speech text with quotes, newlines, or special chars corrupts backend input | Medium | Text passed via positional `$0` in `sh -c`, not interpolated into the command string. `printf '%s\n'` used instead of `echo` to avoid flag interpretation (text starting with `-n`, `-e`). BATS tests include adversarial text (quotes, backticks, newlines, Unicode, dash-prefixed strings). | | PowerShell `Start-Process` pipeline stdin not trivial | Windows backend doesn't receive text on stdin | Medium | Text is Base64-encoded before embedding in the `-Command` string, avoiding injection of shell metacharacters (double quotes, `$()`, backticks) from dynamic template content. The backend receives decoded text on stdin via PowerShell pipeline. | | `auto` backend resolution adds file probes to every hook | Minor latency from checking script existence | Low | `auto` probes 3 paths max (`-x` / `Test-Path` checks). ~1ms total. Cache result in state if needed (unlikely). | ## Roadmap Connection This design implements the **tts-integration** feature under **v2/m5** ("The peon speaks to you"). It's the P1 foundation that `tts-native`, `tts-cli`, and `tts-notifications` all depend on. The roadmap correctly sequences the dependency chain: `tts-integration` → `tts-native` → `tts-cli` → `tts-notifications` / `tts-docs` / `tts-elevenlabs` / `tts-piper` No roadmap changes needed — the existing feature breakdown and dependencies match this design. ## Resolved Questions 1. **Trainer TTS text format:** Use the existing trainer progress string as-is (e.g., "75 of 300 pushups. 50 of 300 squats. 21 percent."). Optimize for speech later if user feedback warrants it. 2. **TTS volume in speak-only mode:** Always use `tts.volume`, independent from the top-level `volume`. Users choosing `speak-only` set `tts.volume` to their preferred level. 3. **Tab-focused suppression:** Suppress both sound and TTS uniformly when `suppress_sound_when_tab_focused` is true. Granular per-modality suppression (`suppress_tts_when_tab_focused`) is tracked as a separate roadmap feature under v2/m5. --- ## Revision History | Date | Author | Notes | |------|--------|-------| | 2026-03-28 | cameron | Initial design | | 2026-03-28 | cameron | Design review fixes: Windows text transport uses Base64 to match ADR stdin safety guarantee; backend resolution returns consistent filenames (not mixed relative/absolute paths); find_bundled_script called with correct script filenames including extension; printf replaces echo to avoid flag interpretation; trainer waits for both .sound.pid and .tts.pid before playing; KDD #2 prose aligned with code (caller handles mode sequencing); TRAINER_TTS_TEXT derivation specified; PowerShell mode sequencing code added; SOUND_FILE guards added to mode branches | --- ### Designs/Win Notification Templates # Windows Notification Template Resolution Engine **Date**: 2026-03-24 **Card**: kr62ia **Status**: Implementation ## Overview Port the notification template resolution logic from `peon.sh` (Python block, lines 3698-3723) to PowerShell in `peon.ps1` (embedded in `install.ps1`). This achieves feature parity for Windows users. ## Unix Reference Implementation (peon.sh:3698-3723) The Python block: 1. Maps categories to template keys: `task.complete` -> `stop`, `task.error` -> `error` 2. Applies event-specific overrides: `PermissionRequest` -> `permission`, `idle_prompt` -> `idle`, `elicitation_dialog` -> `question` 3. Looks up template string from `config.notification_templates[key]` 4. Substitutes variables using `format_map()` with a `defaultdict(str)` (unknown vars -> empty string) 5. Truncates `transcript_summary` to 120 chars ## PowerShell Implementation ### Insertion Point After the sound-picking block closes (`} # end if (-not $skipSound)` at ~line 1342) and before the desktop notification dispatch section (~line 1344). This ensures `$notifyMsg` is overwritten with the resolved template before it reaches `win-notify.ps1`. ### Template Key Mapping ```powershell $tplKeyMap = @{ 'task.complete' = 'stop'; 'task.error' = 'error' } $tplKey = if ($category -and $tplKeyMap.ContainsKey($category)) { $tplKeyMap[$category] } else { $null } # Event-specific overrides if ($hookEvent -eq 'PermissionRequest') { $tplKey = 'permission' } if ($ntype -eq 'idle_prompt') { $tplKey = 'idle' } if ($ntype -eq 'elicitation_dialog') { $tplKey = 'question' } ``` ### Variable Substitution Uses `[regex]::Replace` with a ScriptBlock evaluator (available since PS 2.0): ```powershell $tplVars = @{ project = $project summary = ($summaryRaw).Substring(0, [Math]::Min($summaryRaw.Length, 120)) tool_name = ($event.tool_name -as [string]) status = $notifyStatus event = $hookEvent } $notifyMsg = [regex]::Replace($tpl, '\{(\w+)\}', { param($m) $key = $m.Groups[1].Value if ($tplVars.ContainsKey($key)) { $tplVars[$key] } else { "" } }) ``` ### Fallback Behavior - Missing or empty `notification_templates` config: `$notifyMsg` retains its original value (project name) - Missing template key: no substitution, original `$notifyMsg` preserved - Unknown `{variable}`: replaced with empty string ## Test Strategy 8 Pester scenarios in `tests/win-notification-templates.Tests.ps1`: 1. Stop with `{summary}` template 2. Stop without `transcript_summary` (resolves to empty) 3. PermissionRequest with `{tool_name}` 4. No template configured (fallback to project name) 5. Unknown variable renders as empty 6. All five template keys map from correct events 7. Summary truncation at 120 chars 8. Special characters in project/tool names --- ### Plans/2026 02 16 Trainer Mode Design # Trainer Mode Design **Date:** 2026-02-16 **Status:** Approved ## Overview The peon IS your personal trainer. Pavel-style daily exercise mode where your Warcraft peon nags you to do 300 pushups and 300 squats throughout the day. Same orc who tells you "work work" now tells you to drop and give him twenty. ElevenLabs orc voice. Piggybacks on IDE hook events — no daemon, no cron. The meme: "I replaced my personal trainer with a Warcraft peon." ## Config New `trainer` section in `config.json`: ```json { "trainer": { "enabled": false, "exercises": { "pushups": 300, "squats": 300 }, "reminder_interval_minutes": 20, "reminder_min_gap_minutes": 5 } } ``` - `exercises` — exercise name to daily goal. Default Pavel 300/300, configurable to 100 or 200. - `reminder_interval_minutes` — how often to nag (clock time, checked on each hook event). - `reminder_min_gap_minutes` — minimum gap between reminders to prevent spam during rapid task completions. ## State Trainer state in `.state.json`: ```json { "trainer": { "date": "2026-02-16", "reps": { "pushups": 75, "squats": 50 }, "last_reminder_ts": 1739721600 } } ``` - Auto-resets when `date` doesn't match today. - `last_reminder_ts` — unix timestamp for hybrid timing logic. ## Sounds Orc peon voice lines generated via ElevenLabs (voice ID: `llD6rmWjUTFZjqbsK8zS`). The peon is your drill sergeant — broken English, orcish grunts, deadpan humor. Trainer sounds live in a dedicated directory, separate from packs: ``` ~/.claude/hooks/peon-ping/trainer/ sounds/ remind/ # Peon nagging you to do reps log/ # Peon acknowledging your work complete/ # Peon celebrating daily goal slacking/ # Peon disappointed in you manifest.json ``` ### Voice Line Examples **trainer.remind** (fired every ~20 min of coding): - "You sit too long! Peon say do pushups NOW!" - "Human weak! Need more squats! Peon demand it!" - "Work work... on MUSCLES! Drop and give peon twenty!" - "Peon no let you be lazy! Squat time!" - "Me notice you just sitting there. Floor. Pushups. Now." - "Something need doing? YES. PUSHUPS." - "Peon tired of watching human just type type type. MOVE BODY!" - "You think code review hard? Try three hundred squats!" **trainer.log** (when you log reps): - "Hmm, not bad for puny human." - "Peon approve. Keep going!" - "Work work! Muscles getting bigger maybe!" - "Good good! Peon proud... a little." - "Okie dokie. More later!" **trainer.complete** (daily goal hit): - "THREE HUNDRED! Human strong like orc now! ...almost." - "Peon... Peon impressed. You done good today." - "GOAL COMPLETE! Peon give you rest. For now." - "Zug zug! Human finish all reps! Maybe you not so weak after all." **trainer.slacking** (behind pace, past noon): - "Peon very disappointed. You barely do anything today." - "Half day gone and you still weak! MORE REPS!" - "Human, you falling behind. Peon no like lazy." - "Me not that kind of orc... but me WILL nag you more." `manifest.json` maps categories to files: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` ## CLI ```bash peon trainer on # enable trainer mode peon trainer off # disable trainer mode peon trainer status # show today's progress peon trainer log 25 pushups # log 25 pushups peon trainer log 30 squats # log 30 squats peon trainer goal 200 # set both exercises to 200 peon trainer goal pushups 100 # set just pushups to 100 ``` `peon trainer status` output example: ``` Peon Trainer -- 2026-02-16 pushups: ████████░░░░░░░░ 125/300 (42%) squats: ██░░░░░░░░░░░░░░ 50/300 (17%) Next reminder in ~12 min ``` ## Reminder Logic On every hook event, after normal sound logic: 1. Check `trainer.enabled` — if false, skip. 2. Check `trainer.date` vs today — if different, reset reps to 0. 3. Check if daily goal already hit for all exercises — if so, skip reminders. 4. Calculate time since `last_reminder_ts`. 5. If `>= reminder_interval_minutes` AND `>= reminder_min_gap_minutes`: - Pick random sound from `trainer.remind` (or `trainer.slacking` if behind pace). - Output trainer sound variables. - Update `last_reminder_ts`. - Desktop notification with progress summary. **Pace check:** If current time is past noon and reps below 25% of goal, use `trainer.slacking`. **Sound priority:** Trainer reminder plays after the normal hook sound with ~1s delay. Skip trainer reminder on `session.start` to avoid stacking. **On rep log:** Play `trainer.log` sound. If log pushes total to goal, play `trainer.complete` instead. ## Files Touched - `peon.sh` — trainer logic in Python block + CLI subcommand + delayed trainer sound playback - `config.json` — add `trainer` section defaults - `tests/` — new BATS tests for trainer logic - `README.md` — trainer section in docs ## Not In Scope - No daemon or background process. - No generalized exercise framework — Pavel pushups + squats with configurable goal number. - No web UI or dashboard. - No trainer-specific mobile notifications (reuses existing mobile infra). --- ### Plans/2026 02 16 Trainer Mode Plan # Trainer Mode Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a Pavel-style daily exercise trainer to peon-ping that nags you to do pushups and squats during coding sessions. **Architecture:** Trainer logic lives inside the existing peon.sh Python block. On each hook event, after normal sound routing, the Python block checks trainer config/state and optionally outputs a second set of sound variables for a trainer reminder. CLI subcommands (`peon trainer ...`) are added to the existing `case` statement. Trainer sounds use a dedicated `trainer/` directory with its own manifest.json. **Tech Stack:** Bash, embedded Python 3, BATS for testing **Design doc:** `docs/plans/2026-02-16-trainer-mode-design.md` **Branch:** Create `feat/trainer-mode` from current HEAD before starting. --- ### Task 1: Create branch and trainer sound directory scaffold **Files:** - Create: `trainer/sounds/remind/.gitkeep` - Create: `trainer/sounds/log/.gitkeep` - Create: `trainer/sounds/complete/.gitkeep` - Create: `trainer/sounds/slacking/.gitkeep` - Create: `trainer/manifest.json` **Step 1: Create branch** ```bash cd /Users/garysheng/Documents/github-repos/peonping-repos/peon-ping git checkout -b feat/trainer-mode ``` **Step 2: Create trainer directory structure** ```bash mkdir -p trainer/sounds/{remind,log,complete,slacking} touch trainer/sounds/remind/.gitkeep touch trainer/sounds/log/.gitkeep touch trainer/sounds/complete/.gitkeep touch trainer/sounds/slacking/.gitkeep ``` **Step 3: Create manifest.json** Create `trainer/manifest.json`: ```json { "trainer.remind": [ { "file": "sounds/remind/placeholder.wav", "label": "Time for reps!" } ], "trainer.log": [ { "file": "sounds/log/placeholder.wav", "label": "Logged." } ], "trainer.complete": [ { "file": "sounds/complete/placeholder.wav", "label": "Goal reached!" } ], "trainer.slacking": [ { "file": "sounds/slacking/placeholder.wav", "label": "You're falling behind." } ] } ``` Note: placeholder entries will be replaced with real ElevenLabs audio later. The code should gracefully handle missing sound files. **Step 4: Update config.json with trainer defaults** Add trainer section to `config.json` (the default template): ```json { "active_pack": "peon", "volume": 0.5, "enabled": true, "desktop_notifications": true, "categories": { "session.start": true, "task.acknowledge": true, "task.complete": true, "task.error": true, "input.required": true, "resource.limit": true, "user.spam": true }, "annoyed_threshold": 3, "annoyed_window_seconds": 10, "silent_window_seconds": 0, "pack_rotation": [], "pack_rotation_mode": "random", "session_ttl_days": 7, "linux_audio_player": "", "trainer": { "enabled": false, "exercises": { "pushups": 300, "squats": 300 }, "reminder_interval_minutes": 20, "reminder_min_gap_minutes": 5 } } ``` **Step 5: Commit** ```bash git add trainer/ config.json git commit -m "feat(trainer): add directory scaffold and config defaults" ``` --- ### Task 2: Add trainer CLI subcommands (on/off/status/log/goal) **Files:** - Modify: `peon.sh` (lines 599-1288, the `case` statement) **Step 1: Write BATS tests for trainer CLI** Create `tests/trainer.bats`: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` **Step 2: Run tests to verify they fail** ```bash bats tests/trainer.bats ``` Expected: All tests FAIL (trainer subcommand not implemented yet). **Step 3: Add trainer subcommand to peon.sh case statement** Insert before the `--*)` catch-all (line 1282) in the `case` statement. Add a `trainer)` branch: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` **Step 4: Run tests to verify they pass** ```bash bats tests/trainer.bats ``` Expected: All tests PASS. **Step 5: Commit** ```bash git add peon.sh tests/trainer.bats git commit -m "feat(trainer): add CLI subcommands (on/off/status/log/goal)" ``` --- ### Task 3: Add trainer reminder logic to Python hook block **Files:** - Modify: `peon.sh` (lines 1310-1672, the embedded Python block) **Step 1: Write BATS tests for trainer reminders on hook events** Append to `tests/trainer.bats`: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` **Step 2: Run tests to verify they fail** ```bash bats tests/trainer.bats ``` Expected: New hook-event tests FAIL. **Step 3: Add trainer reminder logic to the Python block** In the embedded Python block (around line 1650, after the main sound selection but before the final variable output), add trainer check logic. The Python block should: 1. Read `trainer` from config — if not enabled, set `TRAINER_SOUND=""` and skip. 2. Load trainer state from `.state.json` — auto-reset if date doesn't match today. 3. Check if event is `SessionStart` — if so, skip trainer. 4. Check if all exercises at or above goal — if so, skip. 5. Check elapsed time vs interval/min_gap — if not due, skip. 6. Load `trainer/manifest.json`, pick random sound from `trainer.remind` (or `trainer.slacking` if behind pace). 7. Update `last_reminder_ts` in state, save state. 8. Output `TRAINER_SOUND`, `TRAINER_VOL`, `TRAINER_MSG` variables. The exact insertion point is near the end of the Python block, after `SOUND_FILE` is determined but before the final `print()` statements that output shell variables. Add these output lines: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` Then in the output section, add: ```python print(f"TRAINER_SOUND='{trainer_sound}'") print(f"TRAINER_MSG='{trainer_msg}'") ``` **Step 4: Add trainer sound playback to shell section** After the main `_run_sound_and_notify` call (around line 1781), add trainer sound playback: ```bash # --- Trainer reminder sound (delayed, after main sound) --- if [ -n "${TRAINER_SOUND:-}" ] && [ -f "$TRAINER_SOUND" ]; then ( sleep 1 play_sound "$TRAINER_SOUND" "$VOLUME" if [ "${NOTIFY:-}" = "1" ]; then send_notification "Trainer" "${TRAINER_MSG:-Time for reps!}" "${NOTIFY_COLOR:-blue}" fi ) & if [ "${PEON_TEST:-0}" != "1" ]; then disown 2>/dev/null fi fi ``` For test mode (PEON_TEST=1), the delayed playback needs to be synchronous. Adjust to: ```bash if [ -n "${TRAINER_SOUND:-}" ] && [ -f "$TRAINER_SOUND" ]; then if [ "${PEON_TEST:-0}" = "1" ]; then play_sound "$TRAINER_SOUND" "$VOLUME" else ( sleep 1 play_sound "$TRAINER_SOUND" "$VOLUME" if [ "${NOTIFY:-}" = "1" ]; then send_notification "Trainer" "${TRAINER_MSG:-Time for reps!}" "${NOTIFY_COLOR:-blue}" fi ) & disown 2>/dev/null fi fi ``` **Step 5: Run tests to verify they pass** ```bash bats tests/trainer.bats ``` Expected: All tests PASS. **Step 6: Run full test suite to ensure no regressions** ```bash bats tests/peon.bats ``` Expected: All existing tests still PASS. **Step 7: Commit** ```bash git add peon.sh tests/trainer.bats git commit -m "feat(trainer): add reminder logic to hook event pipeline" ``` --- ### Task 4: Add trainer help text and README docs **Files:** - Modify: `peon.sh` (help text around line 1245) - Modify: `README.md` **Step 1: Add trainer to main help output** In the `help)` case (around line 1245), add a trainer section to the help text: ``` trainer on|off Enable/disable daily exercise trainer trainer status Show today's progress trainer log N exercise Log reps (e.g. peon trainer log 25 pushups) trainer goal N Set goal for all exercises trainer goal EX N Set goal for one exercise trainer help Show trainer help ``` **Step 2: Add trainer section to README.md** Add a section after the existing features documentation: ```markdown ## Trainer Mode Built-in Pavel-style daily exercise trainer. Reminds you to do pushups and squats while you code — uses custom voice lines that play alongside your normal peon-ping sounds. ### Quick Start ```bash peon trainer on # enable trainer peon trainer goal 200 # set both exercises to 200 (default: 300) # ... code for a while, get reminded to do reps ... peon trainer log 25 pushups # log what you did peon trainer log 30 squats peon trainer status # check progress ``` ### How It Works Trainer reminders piggyback on your coding session — every ~20 minutes of active coding, you'll hear a voice line reminding you to do reps. No background daemon needed. Log your reps with `peon trainer log`, and progress resets automatically at midnight. ### Custom Voice Lines Drop your own audio files into `~/.claude/hooks/peon-ping/trainer/sounds/`: ``` trainer/sounds/remind/ # reminder voice lines trainer/sounds/log/ # acknowledgment when logging reps trainer/sounds/complete/ # celebration when daily goal is hit trainer/sounds/slacking/ # fired when you're behind pace ``` Update `trainer/manifest.json` to register your sound files. ``` **Step 3: Commit** ```bash git add peon.sh README.md git commit -m "docs: add trainer mode to help text and README" ``` --- ### Task 5: Final integration test and cleanup **Step 1: Run full test suite** ```bash bats tests/ ``` Expected: All tests pass across all test files. **Step 2: Manual smoke test** ```bash # Enable trainer peon trainer on # Check status peon trainer status # Log some reps peon trainer log 25 pushups peon trainer log 30 squats # Check progress updated peon trainer status # Set custom goal peon trainer goal 100 # Disable peon trainer off ``` **Step 3: Verify config.json template is clean** Read `config.json` and confirm the trainer section is present with correct defaults. **Step 4: Commit any fixups if needed** If any issues found during smoke testing, fix and commit. --- ## Summary | Task | Description | Key Files | |------|-------------|-----------| | 1 | Branch + scaffold | `trainer/`, `config.json` | | 2 | CLI subcommands | `peon.sh`, `tests/trainer.bats` | | 3 | Hook reminder logic | `peon.sh` (Python block + shell playback) | | 4 | Help text + README | `peon.sh`, `README.md` | | 5 | Integration test | All files | --- ### Plans/2026 02 19 Path Rules Design # Design: path_rules + Override Hierarchy Cleanup **Date:** 2026-02-19 **Scope:** peon-ping CLI (`peon.sh`, `config.json`, `peon update` migration) --- ## Problem There is no way to assign a specific sound pack to a project/repo automatically. The workarounds are: - Drop a `config.json` in `${PWD}/.claude/hooks/peon-ping/` (manual, per-repo file) - Use `/peon-ping-use ` each session (ephemeral, not persistent) Also, two existing config keys have misleading names: - `active_pack` sounds like the currently-playing pack, but it's just the global fallback - `agentskill` (rotation mode) names the mechanism (a skill) not the behavior (per-session override) --- ## Design ### Override Hierarchy From most to least authoritative. Each layer is only consulted if all layers above it produce no result. | Layer | Mechanism | Scope | Persistence | |---|---|---|---| | 1. `session_override` | `/peon-ping-use ` | Current session | Expires with session | | 2. Local config | `${PWD}/.claude/hooks/peon-ping/config.json` | Exact directory | Until file is removed | | 3. `path_rules` | Glob-matched against `cwd` | Matched paths | Until config changes | | 4. `pack_rotation` | `random` or `round-robin` | All sessions globally | Until config changes | | 5. `default_pack` | Global fallback | All sessions globally | Until config changes | **Philosophy:** more specific and more immediate beats more general. A temporary in-session choice (`session_override`) beats a standing rule (`path_rules`), which beats a global default. `path_rules` is a floor for matched repos, not a ceiling — you can always escape it for a session. --- ### Config Schema Changes #### New: `path_rules` Array of objects. First match wins. Evaluated against `cwd` using Python `fnmatch` (glob-style). ```json "path_rules": [ { "pattern": "*/peonping-repos/*", "pack": "peon" }, { "pattern": "*/work/clients/*", "pack": "glados" } ] ``` - Patterns use glob syntax (`*`, `?`, `[seq]`) - First matching rule wins; remaining rules are not evaluated - If matched pack is not installed, falls through to next layer - `rotation` per rule is not supported (YAGNI) #### Renamed: `active_pack` → `default_pack` `active_pack` was misleading — it implied the currently-playing pack, not the fallback. `default_pack` reflects its role as the baseline when nothing more specific matches. #### Renamed: `agentskill` mode → `session_override` mode The `pack_rotation_mode` value `"agentskill"` named the mechanism (a Claude Code skill). `"session_override"` names the behavior (pack is overridden per session via explicit assignment). --- ### Migration (via `peon update`) Both renames are handled automatically during `peon update`: 1. If `active_pack` exists and `default_pack` does not → rename key in-place 2. If `pack_rotation_mode` is `"agentskill"` → rewrite to `"session_override"` 3. Write updated config back to disk During the transition window, the runtime reads both old and new key names (new preferred, old as fallback). This ensures configs not yet migrated continue to work. --- ### Matching Logic Inserted in `peon.sh` Python block after config load and `cwd` extraction, before the rotation/default block. Only runs if `session_override` mode has not already assigned a pack. ```python import fnmatch for rule in cfg.get('path_rules', []): if cwd and fnmatch.fnmatch(cwd, rule.get('pattern', '')): candidate = rule.get('pack', '') if candidate and os.path.isdir(os.path.join(peon_dir, 'packs', candidate)): active_pack = candidate break # first match wins ``` --- ### CLI / UX - `peon status` should show active path rule when one is matched (e.g. `path rule: */peonping-repos/* → peon`) - `peon config set path_rules ...` is out of scope — JSON arrays are awkward to set via CLI; users edit `config.json` directly - README and `peon help` should document `path_rules` alongside `pack_rotation` --- ## Out of Scope - Per-path rotation (`"rotation": [...]` in a rule) - `path_rules` inside local project config (redundant — local config already scopes to that exact directory) - A dedicated `peon path-rules` subcommand --- ## Files Affected | File | Change | |---|---| | `peon.sh` | Add path_rules matching logic; rename `agentskill` → `session_override`; read `default_pack` with `active_pack` fallback; `peon update` migration; `peon status` output | | `config.json` | Rename `active_pack` → `default_pack`; add `"path_rules": []` | | `README.md` | Document `path_rules`, `default_pack`, `session_override` mode | | `README_zh.md` | Mirror README changes | | `docs/public/llms.txt` | Update config key references | | `tests/peon.bats` | Tests for path_rules matching, migration, fallthrough | --- ### Plans/2026 02 20 Ide Click To Focus Design # IDE Click-to-Focus Design **Date:** 2026-02-20 **Status:** Approved ## Problem When peon-ping runs inside an IDE's embedded terminal (Cursor, VS Code, Windsurf, Zed), clicking the notification overlay does nothing. `_mac_terminal_bundle_id()` returns empty because `TERM_PROGRAM` is `vscode` (not a standalone terminal), so the click handler never gets registered. The `_mac_ide_pid()` function already detects IDE ancestor PIDs and passes them as argv[6] to `mac-overlay.js`, but that value is currently unused ("reserved for future"). ## Solution When `bundle_id` is empty, fall back to deriving the bundle ID from the IDE ancestor PID using `lsappinfo` (macOS built-in). This populates `bundle_id` for both the overlay and terminal-notifier notification paths with no additional changes to those paths. ## Changes ### 1. `peon.sh` — Add `_mac_bundle_id_from_pid()` ```bash _mac_bundle_id_from_pid() { local pid="$1" [ -z "$pid" ] || [ "$pid" = "0" ] && return lsappinfo info -only bundleid -app pid="$pid" 2>/dev/null \ | grep -o '"[^"]*"' | tr -d '"' } ``` ### 2. `peon.sh` — Fallback logic in `send_notification()` After computing `bundle_id` and `ide_pid`, if `bundle_id` is empty and `ide_pid > 0`, set `bundle_id` from `_mac_bundle_id_from_pid(ide_pid)`. ### 3. `mac-overlay.js` — PID-based NSRunningApplication fallback When `bundleId` is empty but `idePid > 0`, use `NSRunningApplication.runningApplicationWithProcessIdentifier_()` to activate the IDE app. Belt-and-suspenders fallback for cases where `lsappinfo` might fail. ### 4. Tests - BATS: mock `lsappinfo` returning a bundle ID for a given PID - BATS: `_mac_bundle_id_from_pid` with pid=0 returns empty - Overlay: verify `ide_pid` activates app when `bundle_id` is empty ## Files Touched | File | Change | |---|---| | `peon.sh` | Add `_mac_bundle_id_from_pid()`, fallback in `send_notification()` | | `scripts/mac-overlay.js` | PID-based activation fallback | | `tests/mac-overlay.bats` | IDE click-to-focus tests | | `tests/setup.bash` | Mock `lsappinfo` | ## Scope Exclusions - No attempt to switch to the terminal panel within the IDE (just brings window to front) - No new dependencies (`lsappinfo` is macOS built-in) - No changes to Windows/Linux paths --- ### Plans/2026 02 20 Multi Window Focus # Multi-Window Click-to-Focus (Tabled) ## Problem When multiple Cursor windows are open, click-to-focus brings Cursor to front but raises the wrong window (last-focused, not the one running the agent). ## What works - Single-window click-to-focus works reliably (bundle ID via System Events query) - Bundle ID detection for Cursor/Code/Windsurf via `TERM_PROGRAM=vscode` + osascript System Events - Project name is correctly extracted from cwd and passed through to overlay ## Approaches tried (all failed for multi-window) ### 1. System Events AXRaise after activateWithOptions - `NSRunningApplication.activateWithOptions` is async, brings wrong window first - AXRaise fires after but the wrong window is already visible ### 2. System Events AXRaise before set frontmost - Reorder: AXRaise first, then `set frontmost to true` - Still didn't reliably raise the correct window ### 3. System Events AXRaise + frontmost in one AppleScript - Single atomic AppleScript: `set frontmost to true` + iterate windows + AXRaise matching window - Window title matching worked (confirmed via debug logging: `result=matched`) - Still didn't visually raise the correct window ### 4. `open -a "Cursor" /path/to/project` - Should focus the existing window for that folder - Didn't work ### 5. `cursor /path/to/project` CLI - Cursor's CLI tool to open/focus a folder - Didn't work either ## Technical notes - Cursor window titles contain the project folder name (e.g., "config.json -- peonping-repos") - System Events can list/query Cursor windows (accessibility permissions granted) - AXRaise reports success but doesn't visually change window order - macOS may be preventing programmatic window reordering for Electron apps - Cursor is an Electron app (bundle: com.todesktop.230313mzl4w4u92) ## Current state Click-to-focus works for single-window setups. For multi-window, it brings Cursor to front but may show the wrong window. The window-matching code is in place but AXRaise doesn't reliably reorder Electron app windows on macOS. --- ### Plans/2026 02 24 Notification Templates Design # Notification Message Templates **Date**: 2026-02-24 **Branch**: `feat/configurable-notifications` **Status**: Approved ## Problem Notification message content is hardcoded (just the project name for most events). Users who want richer context (e.g., transcript summary on task completion, tool name on permission requests) have no way to configure it. ## Design: Format String Templates ### Config Schema New key `notification_templates` in `config.json`: ```json { "notification_templates": { "stop": "{project}: {summary}", "permission": "{project}: {tool_name}", "error": "{project}: error", "idle": "{project}", "question": "{project}" } } ``` - **Omitted keys** fall back to `"{project}"` (current default behavior). - **Empty or missing** `notification_templates` object = 100% backward-compatible. - Unknown `{variables}` render as empty string (no crash). ### Available Variables | Variable | Available in | Source | |----------|-------------|--------| | `{project}` | all events | project name resolution chain | | `{summary}` | stop | `transcript_summary` from hook JSON (truncated to 120 chars) | | `{tool_name}` | permission, error | `tool_name` from hook JSON | | `{status}` | all events | computed status string (done/error/ready/etc.) | | `{event}` | all events | event type name (Stop/PermissionRequest/etc.) | ### Category-to-Template Key Mapping | Category | Template key | |----------|-------------| | `task.complete` | `stop` | | `input.required` (PermissionRequest) | `permission` | | `input.required` (elicitation_dialog) | `question` | | `task.error` | `error` | | Notification/idle_prompt | `idle` | ### Implementation Location Python event parser in `peon.sh`, after per-event `msg` construction (~line 2860), before the shell variable output block. Uses `str.format_map()` with `collections.defaultdict(str)` for safe missing-key handling. ### CLI Interface ```bash peon notifications template stop "{project}: {summary}" peon notifications template permission "{project}: {tool_name}" peon notifications template --reset ``` Writes to `notification_templates` in `config.json`. ### What Doesn't Change - Position, dismiss, label configs (already shipped) - Sound/category routing - Default behavior with no template config --- ### Plans/2026 02 24 Notification Templates Plan # Notification Message Templates — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Let users configure notification message content with format string templates per event type, defaulting to current behavior. **Architecture:** Single config key `notification_templates` holds per-event format strings. A template resolution block in the Python event parser applies `str.format_map()` with available variables after each event sets its default `msg`. CLI subcommand `peon notifications template` provides get/set/reset. **Tech Stack:** Bash + embedded Python (peon.sh), BATS tests --- ### Task 1: Template Resolution in Python Event Parser **Files:** - Modify: `peon.sh:2620-2625` (revert hardcoded summary concat) - Modify: `peon.sh:2863` (insert template resolution before shell variable output block) **Step 1: Revert the hardcoded transcript_summary change** Replace the current Stop message block (lines 2620-2624): ```python _summary = event_data.get('transcript_summary', '').strip() if _summary: msg = project + ': ' + _summary[:120] else: msg = project ``` Back to the original default: ```python msg = project ``` This keeps `msg = project` as the default for all events. Templates will handle enrichment. **Step 2: Add template resolution block** Insert this Python block at `peon.sh` just BEFORE the line `# --- Output shell variables ---` (currently line 2864), after the tab color block ends: ```python # --- Notification message template resolution --- from collections import defaultdict as _defaultdict _templates = cfg.get('notification_templates', {}) _tpl_key_map = { 'task.complete': 'stop', 'task.error': 'error', } _tpl_key = _tpl_key_map.get(category, '') if event == 'Notification': if ntype == 'idle_prompt': _tpl_key = 'idle' elif ntype == 'elicitation_dialog': _tpl_key = 'question' elif event == 'PermissionRequest': _tpl_key = 'permission' _tpl = _templates.get(_tpl_key, '') if _tpl: _tpl_vars = _defaultdict(str, { 'project': project, 'summary': event_data.get('transcript_summary', '').strip()[:120], 'tool_name': event_data.get('tool_name', ''), 'status': status, 'event': event, }) try: msg = _tpl.format_map(_tpl_vars) except Exception: pass ``` **Step 3: Run existing tests to verify no regression** Run: `cd ~/iWorld/projects/peon-ping && bats tests/mac-overlay.bats` Expected: All existing tests pass (the default `msg = project` behavior is unchanged when no templates configured). **Step 4: Commit** ```bash git add peon.sh git commit -m "feat: notification message template resolution engine Reads notification_templates from config.json and applies format_map with available variables ({project}, {summary}, {tool_name}, {status}, {event}). Defaults to current behavior when no templates configured." ``` --- ### Task 2: CLI Subcommand — `peon notifications template` **Files:** - Modify: `peon.sh:1068` (add `template)` case before the `*)` fallback) - Modify: `peon.sh:1070` (update usage string) **Step 1: Add the `template` case** Insert before line 1069 (`*)`): ``` /* Detailed source-code truncated for AI context efficiency. */ ``` **Step 2: Update usage string** Change line 1070 from: ``` echo "Usage: peon notifications " >&2; exit 1 ;; ``` To: ``` echo "Usage: peon notifications " >&2; exit 1 ;; ``` **Step 3: Update help text** Find the help text block (~line 1981-1982) and add after the `label` line: ``` notifications template [key] [fmt] Get/set message templates (keys: stop, permission, error, idle, question) ``` **Step 4: Add template display to `peon status`** In the Python status output block (~lines 765-770), after the label/project_name_map display, add: ```python _tpls = c.get('notification_templates', {}) if _tpls: print('peon-ping: notification templates:') for _tk, _tv in _tpls.items(): print(f' {_tk} = "{_tv}"') ``` **Step 5: Run tests** Run: `cd ~/iWorld/projects/peon-ping && bats tests/mac-overlay.bats` Expected: All existing tests still pass. **Step 6: Commit** ```bash git add peon.sh git commit -m "feat: peon notifications template CLI for get/set/reset" ``` --- ### Task 3: BATS Tests for Notification Templates **Files:** - Modify: `tests/mac-overlay.bats` (append new test section after the label priority chain tests, ~line 663) **Step 1: Write the tests** Append to `tests/mac-overlay.bats`: ``` /* Detailed source-code truncated for AI context efficiency. */ ``` **Step 2: Run the new tests** Run: `cd ~/iWorld/projects/peon-ping && bats tests/mac-overlay.bats` Expected: All tests pass including the new template tests. **Step 3: Run full test suite** Run: `cd ~/iWorld/projects/peon-ping && bats tests/` Expected: All 55+ tests pass. **Step 4: Commit** ```bash git add tests/mac-overlay.bats git commit -m "test: BATS tests for notification message templates" ``` --- ### Task 4: Integration Test with Live Hooks **Step 1: Configure template in the dev config** ```bash ~/iWorld/projects/peon-ping/peon.sh notifications template stop '{project}: {summary}' ~/iWorld/projects/peon-ping/peon.sh notifications template permission '{project}: {tool_name} needs approval' ``` **Step 2: Fire test notifications** ```bash echo '{"hook_event_name":"Stop","session_id":"tpl-test","cwd":"/path/to/my-project","transcript_summary":"Added notification templates"}' | ~/.claude/hooks/peon-ping/peon.sh 2>/dev/null & ``` Expected: Overlay shows "My Project: Added notification templates" at top-right, persistent. **Step 3: Verify stacking still works** Fire a second notification. Both should stack vertically, no overlap. **Step 4: Verify default behavior** Reset templates and fire again — notification should show just "My Project". ```bash ~/iWorld/projects/peon-ping/peon.sh notifications template --reset ``` --- ### Plans/2026 02 25 Selective Sound Control # Selective Sound Control: Documentation & CLI Improvements Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Improve discoverability of the existing `desktop_notifications: false` feature that lets users keep voice reminders while disabling desktop notification popups. **Architecture:** Pure documentation and CLI enhancement. The core feature already works (sounds and notifications are independent). We're adding help text, verbose status output, README sections, and skill documentation updates. **Tech Stack:** Bash (peon.sh CLI), Markdown (README, skills), no build tools --- ## Task 1: Add verbose status output to CLI **Files:** - Modify: `peon.sh` (status command section around line 800-850) **Step 1: Locate the status command section** Search for the `status)` case in peon.sh: ```bash grep -n "status)" peon.sh | head -3 ``` Expected: Find the status command handler around line 800-850 **Step 2: Read current status implementation** Read the status command section to understand current output format: ```bash # Look for lines like: # echo "peon-ping: enabled" # echo "peon-ping: volume $vol" ``` **Step 3: Add --verbose flag support** Modify the status command to support `--verbose` flag: ```bash status) # Existing status output eval "$(_py_status)" # Add verbose flag support if [ "${1:-}" = "--verbose" ]; then # Read desktop_notifications and mobile_notify from config _verbose_out="$(python3 -c " import json try: cfg = json.load(open('$CONFIG_PY')) dn = cfg.get('desktop_notifications', True) mn = cfg.get('mobile_notify', {}) mobile_on = bool(mn and mn.get('service') and mn.get('enabled', True)) dn_status = 'on' if dn else 'off (sounds still play)' mobile_status = 'on' if mobile_on else 'off' print('peon-ping: desktop notifications ' + dn_status) print('peon-ping: mobile notifications ' + mobile_status) except Exception: pass ")" echo "$_verbose_out" fi exit 0 ;; ``` **Step 4: Test the verbose output** Run the status command with and without verbose: ```bash peon status # Expected: Current output (enabled, volume, pack) peon status --verbose # Expected: Above + desktop notifications status + mobile notifications status ``` **Step 5: Commit** ```bash git add peon.sh git commit -m "feat: add --verbose flag to peon status command Shows desktop and mobile notification states with clarifying text that sounds continue when desktop notifications are disabled." ``` --- ## Task 2: Improve help text for notifications command **Files:** - Modify: `peon.sh` (help text section around line 1900-1910) **Step 1: Locate notifications help text** Search for the help text: ```bash grep -n "notifications on\|off" peon.sh | grep -v "^#" ``` Expected: Find help text around line 1905 **Step 2: Update help text** Modify the help text to clarify that sounds continue: ```bash # Before: # notifications on|off # Toggle desktop notifications # After: notifications on|off # Toggle desktop notification popups (sounds continue playing) ``` **Step 3: Verify help output** ```bash peon help | grep notifications # Expected: See updated text with "(sounds continue playing)" ``` **Step 4: Commit** ```bash git add peon.sh git commit -m "docs: clarify notifications command help text Makes it clear that toggling notifications only affects popups, not audio playback." ``` --- ## Task 3: Add popups alias command **Files:** - Modify: `peon.sh` (command routing section where notifications command is handled) **Step 1: Locate notifications command handler** ```bash grep -n "notifications)" peon.sh ``` Expected: Find the case statement around line 900-940 **Step 2: Add popups alias** Add a new case that routes to the same handler: ```bash # Find this section: notifications) case "${1:-status}" in on) # ... existing code ... off) # ... existing code ... esac ;; # Add right after: popups) # Alias for 'notifications' command - same behavior case "${1:-status}" in on) # Call same Python code as notifications on python3 -c " import json try: cfg = json.load(open('$CONFIG_PY')) except Exception: cfg = {} cfg['desktop_notifications'] = True json.dump(cfg, open('$CONFIG_PY', 'w'), indent=2) print('peon-ping: desktop notifications on') " sync_adapter_configs; exit 0 ;; off) python3 -c " import json try: cfg = json.load(open('$CONFIG_PY')) except Exception: cfg = {} cfg['desktop_notifications'] = False json.dump(cfg, open('$CONFIG_PY', 'w'), indent=2) print('peon-ping: desktop notifications off') " sync_adapter_configs; exit 0 ;; *) echo "Usage: peon popups on|off" >&2 exit 1 ;; esac ;; ``` **Step 3: Add to help text** Add the popups alias to the help output: ```bash # In the help section, add: popups on|off # Alias for 'notifications' - toggle desktop notification popups ``` **Step 4: Test the alias** ```bash peon popups off # Expected: "peon-ping: desktop notifications off" peon status --verbose # Expected: Shows "desktop notifications off (sounds still play)" peon popups on # Expected: "peon-ping: desktop notifications on" ``` **Step 5: Commit** ```bash git add peon.sh git commit -m "feat: add 'peon popups' alias for notifications command Provides clearer alternative to 'peon notifications' since popups is more specific than the ambiguous 'notifications' term." ``` --- ## Task 4: Update README.md with Common Use Cases section **Files:** - Modify: `README.md` (insert after Configuration section around line 270) **Step 1: Find insertion point** ```bash grep -n "^## Configuration" README.md ``` Expected: Find the Configuration section heading **Step 2: Add Common Use Cases section** After the Configuration section and before the next ## heading, insert: ```markdown ## Common Use Cases ### Sounds without popups Want voice feedback but no visual distractions? ```bash peon notifications off ``` This keeps all sound categories playing while suppressing desktop notification banners. Mobile notifications (if configured) continue working. You can also use the alias: ```bash peon popups off ``` ### Silent mode with notifications only Want visual alerts but no audio? ```bash peon pause # or set "enabled": false in config ``` With `desktop_notifications: true`, you'll get popups but no sounds. ### Complete silence Disable everything: ```bash peon pause peon notifications off peon mobile off ``` ``` **Step 3: Verify markdown formatting** ```bash # Preview the section (if you have a markdown viewer) # Or just verify syntax manually grep -A20 "^## Common Use Cases" README.md ``` **Step 4: Commit** ```bash git add README.md git commit -m "docs: add Common Use Cases section to README Highlights the 'sounds without popups' use case and clarifies the three independent toggle controls (enabled, desktop_notifications, mobile_notify)." ``` --- ## Task 5: Add Independent Controls table to README **Files:** - Modify: `README.md` (Configuration section around line 240) **Step 1: Locate the Configuration section details** Find where config keys are documented: ```bash grep -n "desktop_notifications" README.md | head -3 ``` **Step 2: Add table before the config key descriptions** Insert this table at the start of the Configuration section, right after the JSON example: ```markdown ### Independent Controls peon-ping has three independent controls that can be mixed and matched: | Config Key | Controls | Affects Sounds | Affects Desktop Popups | Affects Mobile Push | |------------|----------|----------------|------------------------|---------------------| | `enabled` | Master audio switch | ✅ Yes | ❌ No | ❌ No | | `desktop_notifications` | Desktop popup banners | ❌ No | ✅ Yes | ❌ No | | `mobile_notify.enabled` | Phone push notifications | ❌ No | ❌ No | ✅ Yes | This means you can: - Keep sounds but disable desktop popups: `peon notifications off` - Keep desktop popups but disable sounds: `peon pause` - Enable mobile push without desktop popups: set `desktop_notifications: false` and `mobile_notify.enabled: true` ``` **Step 3: Update desktop_notifications description** Find the existing `desktop_notifications` description and enhance it: ```markdown - **desktop_notifications**: `true`/`false` — toggle desktop notification popups independently from sounds (default: `true`). When disabled, sounds continue playing but visual popups are suppressed. Mobile notifications are unaffected. ``` **Step 4: Verify table renders correctly** ```bash # Check the markdown syntax grep -A10 "^### Independent Controls" README.md ``` **Step 5: Commit** ```bash git add README.md git commit -m "docs: add Independent Controls table to README Clarifies that enabled, desktop_notifications, and mobile_notify are three separate toggles that can be combined for different notification strategies." ``` --- ## Task 6: Update Chinese README (README_zh.md) **Files:** - Modify: `README_zh.md` **Step 1: Find corresponding sections in Chinese README** ```bash grep -n "## Configuration\|## 配置" README_zh.md ``` **Step 2: Add Chinese translation of Common Use Cases** Insert after the Configuration section: ```markdown ## 常见用例 ### 保留声音但禁用弹窗 想要语音反馈但不想要视觉干扰? ```bash peon notifications off ``` 这会保持所有声音类别的播放,同时禁用桌面通知横幅。手机通知(如果已配置)继续工作。 您也可以使用别名: ```bash peon popups off ``` ### 静音模式但保留通知 想要视觉提醒但不要音频? ```bash peon pause # 或在配置中设置 "enabled": false ``` 当 `desktop_notifications: true` 时,您将收到弹窗但没有声音。 ### 完全静音 禁用所有功能: ```bash peon pause peon notifications off peon mobile off ``` ``` **Step 3: Add Chinese translation of Independent Controls table** ```markdown ### 独立控制 peon-ping 有三个独立的控制开关,可以混合使用: | 配置键 | 控制项 | 影响声音 | 影响桌面弹窗 | 影响手机推送 | |--------|--------|----------|--------------|--------------| | `enabled` | 主音频开关 | ✅ 是 | ❌ 否 | ❌ 否 | | `desktop_notifications` | 桌面弹窗横幅 | ❌ 否 | ✅ 是 | ❌ 否 | | `mobile_notify.enabled` | 手机推送通知 | ❌ 否 | ❌ 否 | ✅ 是 | 这意味着您可以: - 保留声音但禁用桌面弹窗:`peon notifications off` - 保留桌面弹窗但禁用声音:`peon pause` - 启用手机推送但不显示桌面弹窗:设置 `desktop_notifications: false` 和 `mobile_notify.enabled: true` ``` **Step 4: Update desktop_notifications description in Chinese** Find and enhance the Chinese description of `desktop_notifications`. **Step 5: Commit** ```bash git add README_zh.md git commit -m "docs: add Chinese translations for Common Use Cases and Independent Controls Maintains documentation parity between English and Chinese versions." ``` --- ## Task 7: Update /peon-ping-toggle skill documentation **Files:** - Modify: `skills/peon-ping-toggle/SKILL.md` **Step 1: Read current skill content** ```bash cat skills/peon-ping-toggle/SKILL.md ``` **Step 2: Add clarification about what gets toggled** Update the description or add a note section: ```markdown ## What This Toggles This command toggles the **master audio switch** (`enabled` config). When disabled: - ❌ Sounds stop playing - ❌ Desktop notifications also stop (they require sounds to be enabled) - ❌ Mobile notifications also stop **For notification-only control**, use `/peon-ping-config` to set `desktop_notifications: false`. This keeps sounds playing while suppressing desktop popups. ## Examples "Mute peon-ping completely" → Sets `enabled: false` "Just disable the popups but keep sounds" → Sets `desktop_notifications: false` (use `/peon-ping-config` instead) ``` **Step 3: Commit** ```bash git add skills/peon-ping-toggle/SKILL.md git commit -m "docs: clarify peon-ping-toggle behavior Explains that toggle affects master switch, and directs users to peon-ping-config for notification-only control." ``` --- ## Task 8: Update /peon-ping-config skill documentation **Files:** - Modify: `skills/peon-ping-config/SKILL.md` **Step 1: Read current skill content** ```bash cat skills/peon-ping-config/SKILL.md ``` **Step 2: Add example for notification control** Add to the examples section (or create one if it doesn't exist): ```markdown ## Common Configuration Examples ### Disable desktop notification popups but keep sounds **User request:** "Disable desktop notifications" **Action:** Set `desktop_notifications: false` in config **Result:** - ✅ Sounds continue playing (voice reminders) - ❌ Desktop notification popups suppressed - ✅ Mobile notifications unaffected (separate toggle) **Alternative CLI command:** ```bash peon notifications off # or peon popups off ``` ### Adjust volume **User request:** "Set volume to 30%" **Action:** Set `volume: 0.3` in config ### Enable round-robin pack rotation **User request:** "Enable round-robin pack rotation with peon and glados" **Action:** Set: ```json { "pack_rotation": ["peon", "glados"], "pack_rotation_mode": "round-robin" } ``` ``` **Step 3: Commit** ```bash git add skills/peon-ping-config/SKILL.md git commit -m "docs: add notification control examples to peon-ping-config skill Shows how to disable desktop popups while keeping sounds, and clarifies the independent toggle system." ``` --- ## Task 9: Update docs/public/llms.txt **Files:** - Modify: `docs/public/llms.txt` **Step 1: Read current llms.txt structure** ```bash head -50 docs/public/llms.txt ``` **Step 2: Add Common Use Cases section** Find an appropriate location (likely near configuration or features) and add: ``` ## Common Use Cases Sounds without popups: - User wants voice feedback but no visual distractions - Solution: peon notifications off - Result: Sounds play, desktop popups suppressed, mobile notifications continue - Config: desktop_notifications: false Silent mode with notifications only: - User wants visual alerts but no audio - Solution: peon pause (or enabled: false in config) - Result: Desktop popups show, no sounds play Independent Controls: - enabled: master audio switch (affects sounds only) - desktop_notifications: desktop popup banners (affects popups only) - mobile_notify.enabled: phone push notifications (affects mobile only) ``` **Step 3: Commit** ```bash git add docs/public/llms.txt git commit -m "docs: add notification control patterns to llms.txt Helps AI assistants recommend correct solution for sounds-without-popups use case." ``` --- ## Task 10: Manual verification testing **Files:** - None (testing only) **Step 1: Test desktop_notifications=false behavior** ```bash # Setup peon notifications off # Verify config grep desktop_notifications ~/.claude/hooks/peon-ping/config.json # Expected: "desktop_notifications": false # Trigger a hook event (Stop event) echo '{"event":"Stop","sessionId":"test-$(date +%s)"}' | ~/.claude/hooks/peon-ping/peon.sh # Manual verification: # ✅ Did you hear a sound? # ❌ Did you see a desktop notification popup? ``` **Step 2: Test CLI commands** ```bash # Test status peon status # Expected: Shows enabled, volume, pack peon status --verbose # Expected: Above + "desktop notifications off (sounds still play)" # Test notifications command peon notifications on peon status --verbose # Expected: "desktop notifications on" peon notifications off peon status --verbose # Expected: "desktop notifications off (sounds still play)" # Test popups alias peon popups on peon status --verbose # Expected: "desktop notifications on" peon popups off peon status --verbose # Expected: "desktop notifications off (sounds still play)" ``` **Step 3: Test help output** ```bash peon help | grep -A1 "notifications" # Expected: See "(sounds continue playing)" in help text peon help | grep "popups" # Expected: See popups alias listed ``` **Step 4: Verify documentation** ```bash # Check README has new sections grep "## Common Use Cases" README.md grep "### Independent Controls" README.md # Check Chinese README grep "## 常见用例" README_zh.md grep "### 独立控制" README_zh.md # Check skills grep "notification-only control" skills/peon-ping-toggle/SKILL.md grep "desktop_notifications: false" skills/peon-ping-config/SKILL.md ``` **Step 5: Document test results** Create a test report comment or note: ``` Manual testing completed: ✅ desktop_notifications=false keeps sounds, suppresses popups ✅ peon status --verbose shows notification state ✅ peon notifications on/off works correctly ✅ peon popups on/off alias works correctly ✅ Help text shows clarifying text ✅ README sections added (EN and ZH) ✅ Skills updated with examples ✅ llms.txt updated ``` --- ## Task 11: Final commit and summary **Files:** - None (wrap-up) **Step 1: Review all commits** ```bash git log --oneline -11 # Expected: See all 10 commits from this plan ``` **Step 2: Verify working tree is clean** ```bash git status # Expected: "nothing to commit, working tree clean" ``` **Step 3: Create summary of changes** Document what was changed: ``` ## Summary Enhanced discoverability of desktop_notifications feature: CLI Enhancements: - Added peon status --verbose flag - Improved notifications command help text - Added peon popups alias Documentation: - Added Common Use Cases section to README (EN + ZH) - Added Independent Controls table to README (EN + ZH) - Updated skill documentation (toggle + config) - Updated llms.txt with use case patterns Testing: - Manual verification confirms feature works correctly - Sounds play independently from desktop notifications - CLI provides clear feedback No code changes to core functionality - feature already worked correctly. ``` **Step 4: Optional: Update CHANGELOG.md** If this warrants a version bump, add to CHANGELOG.md: ```markdown ## [Unreleased] ### Added - `peon status --verbose` flag showing desktop and mobile notification states - `peon popups` alias for `peon notifications` command - Common Use Cases section in README (sounds without popups, etc.) - Independent Controls table clarifying the three toggle system ### Changed - Improved help text for notifications command to clarify sounds continue - Enhanced skill documentation with notification control examples ``` **Step 5: Done** Implementation complete! All documentation and CLI improvements are in place. ---