peon-ping

Warcraft III Peon voice notifications (+ more!) for Claude Code, Codex, IDEs, and any AI agent. Stop babysitting your terminal. Employ a Peon today.

RAW Doc

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:

text
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:

text
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.

- ADR-001: Async Audio and Safe State on Windows โ€” 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 โ€” product requirements driving this decision
- CESP v1.0 Specification โ€” the event category schema that logging must cover
- Claude Code hooks documentation โ€” hook timeout behavior, JSON payload schema
- POSIX write(2) atomicity โ€” guarantees for concurrent O_APPEND writes under PIPE_BUF
- GitHub #402, #397 โ€” open bugs that motivated this work

---

Revision History


DateStatusNotes
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"):

text

Unix (bash scripts) โ€” text on stdin, options as arguments


echo "<text>" | scripts/tts-native.sh "<voice>" "<rate>" "<volume>"

Windows (PowerShell scripts) โ€” text on stdin, options as named params


"<text>" | scripts/tts-native.ps1 -voice "<voice>" -rate <rate> -vol <volume>

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):

text

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:

text
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.

- 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 โ€” 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 โ€” native macOS TTS capabilities
- System.Speech.Synthesis (SAPI5) โ€” Windows native TTS API
- espeak-ng โ€” open-source speech synthesizer for Linux
- Piper โ€” fast local neural TTS for Linux

---

Revision History


DateStatusNotes
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 (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. <cwd>/.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):

EventFires
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 spawns 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

text
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 eventhook_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 <HOME>/.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 | 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

text
/ 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

text
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:

text
[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:

text
[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:

text
[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


RiskImpactLikelihoodMitigation
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


DateAuthorNotes
2026-03-25
cameron | Initial design |

---

Designs/Tts Integration

Design Doc: TTS Integration Layer and Backend Contract

ADR: ADR-001 | 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:

text
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 evals 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:

text
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

text
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 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

text
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:

text
[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:

text
[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


RiskImpactLikelihoodMitigation
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


DateAuthorNotes
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:

text
~/.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:

text
/ 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:

text
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:

text
/ 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:

text
/ 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:

text
/ 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:

text
/ 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:

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
text

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
text
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


TaskDescriptionKey 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.

LayerMechanismScopePersistence
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


FileChange
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


FileChange
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


VariableAvailable inSource
{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


CategoryTemplate 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 (*)):

text
/ Detailed source-code truncated for AI context efficiency. /

Step 2: Update usage string

Change line 1070 from:

text
echo "Usage: peon notifications <on|off|overlay|standard|position|dismiss|label|test>" >&2; exit 1 ;;

To:
text
echo "Usage: peon notifications <on|off|overlay|standard|position|dismiss|label|template|test>" >&2; exit 1 ;;

Step 3: Update help text

Find the help text block (~line 1981-1982) and add after the label line:

text
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:

text
/ 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
text
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
text

Silent mode with notifications only

Want visual alerts but no audio?

bash
peon pause # or set "enabled": false in config
text
With desktop_notifications: true, you'll get popups but no sounds.

Complete silence

Disable everything:

bash
peon pause
peon notifications off
peon mobile off
text

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 KeyControlsAffects SoundsAffects Desktop PopupsAffects 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
text
่ฟ™ไผšไฟๆŒๆ‰€ๆœ‰ๅฃฐ้Ÿณ็ฑปๅˆซ็š„ๆ’ญๆ”พ๏ผŒๅŒๆ—ถ็ฆ็”จๆกŒ้ข้€š็Ÿฅๆจชๅน…ใ€‚ๆ‰‹ๆœบ้€š็Ÿฅ๏ผˆๅฆ‚ๆžœๅทฒ้…็ฝฎ๏ผ‰็ปง็ปญๅทฅไฝœใ€‚

ๆ‚จไนŸๅฏไปฅไฝฟ็”จๅˆซๅ๏ผš

bash
peon popups off
text

้™้Ÿณๆจกๅผไฝ†ไฟ็•™้€š็Ÿฅ

ๆƒณ่ฆ่ง†่ง‰ๆ้†’ไฝ†ไธ่ฆ้Ÿณ้ข‘๏ผŸ

bash
peon pause # ๆˆ–ๅœจ้…็ฝฎไธญ่ฎพ็ฝฎ "enabled": false
text
ๅฝ“ desktop_notifications: true ๆ—ถ๏ผŒๆ‚จๅฐ†ๆ”ถๅˆฐๅผน็ช—ไฝ†ๆฒกๆœ‰ๅฃฐ้Ÿณใ€‚

ๅฎŒๅ…จ้™้Ÿณ

็ฆ็”จๆ‰€ๆœ‰ๅŠŸ่ƒฝ๏ผš

bash
peon pause
peon notifications off
peon mobile off
text

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
text

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"
}
text

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:

text

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:

text
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:

text

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.

---