---
phase: 260407-h07-antifragile-fix-for-afplayplayer-wav-wri
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
  - plugins/claude-tts-companion/Tests/CompanionCoreTests/AfplayPlayerChaosTests.swift
autonomous: true
requirements:
  - QUICK-260407-h07
must_haves:
  truths:
    - "AfplayPlayer survives deletion of ~/.local/share/tts-debug-wav/ mid-session without losing audio"
    - "When the primary WAV directory fails, AfplayPlayer transparently falls back to NSTemporaryDirectory and then to mkstemp in /tmp"
    - "When primary recovers, AfplayPlayer returns to the primary path and logs a single recovery event"
    - "Collapsed failure telemetry produces at most one structured log per failure class per 60s (no 60Hz spam)"
    - "GET /health exposes afplay subsystem state (fallback level, consecutive failure count, total fallback writes, last failure class/at) without breaking existing SwiftBar jq parsers"
    - "SettingsStore config directory creation errors are logged instead of silently swallowed"
    - "swift build -c release succeeds with zero warnings in the touched files"
    - "swift test --filter AfplayPlayerChaosTests passes after the fix; the same test fails against unmodified AfplayPlayer.swift"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift"
      provides: "ensureWritableWavDirectory() fallback chain, WavWriteFailureState actor-isolated telemetry, getAfplayHealthSnapshot() accessor, explicit do/catch around init createDirectory"
      contains: "ensureWritableWavDirectory"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift"
      provides: "HealthResponse.afplay substruct (AfplayHealth) populated from AfplayPlayer.getAfplayHealthSnapshot()"
      contains: "AfplayHealth"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift"
      provides: "Explicit do/catch around config dir createDirectory with logger.error"
    - path: "plugins/claude-tts-companion/Tests/CompanionCoreTests/AfplayPlayerChaosTests.swift"
      provides: "Chaos tests: missing dir self-heals; repeated failures log once; health snapshot reflects fallback level; recovery logged once"
      contains: "@Suite"
  key_links:
    - from: "AfplayPlayer.play (~L137) and AfplayPlayer.startSegment (~L366)"
      to: "ensureWritableWavDirectory()"
      via: "pre-writeWav call site replacement (wavPath now derived from the returned URL, not the captured debugWavDir)"
      pattern: "ensureWritableWavDirectory"
    - from: "HTTPControlServer.healthResponse()"
      to: "AfplayPlayer.getAfplayHealthSnapshot()"
      via: "await MainActor.run { playbackManager.afplayPlayer.getAfplayHealthSnapshot() }"
      pattern: "getAfplayHealthSnapshot"
    - from: "AfplayPlayerChaosTests"
      to: "AfplayPlayer.ensureWritableWavDirectory() + getAfplayHealthSnapshot()"
      via: "@testable import CompanionCore; direct method calls; no afplay process spawn"
      pattern: "@testable import CompanionCore"
---

<objective>
Deliver an antifragile fix for the AfplayPlayer WAV-write failure diagnosed in
`.planning/debug/tts-no-audio-260406.md`. When `~/.local/share/tts-debug-wav/`
disappears mid-session, playback currently fails silently for the rest of the
process lifetime because the directory path is captured at lazy init. This
plan implements Option A+C+D+E from 260407-h07-RESEARCH.md:

- **A.** Self-healing fallback chain (primary → NSTemporaryDirectory → mkstemp)
  called on every WAV write, idempotent and sub-millisecond.
- **C.** Structured collapsed telemetry: one log line per failure class per 60s,
  not one per failing 60Hz tick. Tracks consecutive count, fallback level,
  total fallback writes, and recovery.
- **D.** `/health` endpoint exposes the new telemetry so SwiftBar and curl can
  observe fallback state in real time. Additive JSON keys — verified safe
  against SwiftBar's `jq`-with-`//`-defaults parsing.
- **E.** Codebase-wide `try? createDirectory` cleanup: the two hits
  (AfplayPlayer.swift:46 and SettingsStore.swift:122) become explicit
  `do/catch` with `logger.error`.
- **Chaos test.** A new test file validates the fallback chain in isolation,
  without spawning afplay or playing audio. MUST fail on current code and pass
  after the fix.

**Explicitly out of scope:** the audio playback path itself. No changes to
`posix_spawn`, `waitpid`, `killall`, QoS inheritance, segment chaining, or the
deferred-offset pattern. Option B (AVAudioPlayer replacement) is REJECTED per
research — it would re-introduce the jitter bug that `e2e80e1e` fixed.

Purpose: restore TTS audio when the debug dir disappears, make fragility
observable, and prevent the same silent-failure class from recurring in
SettingsStore.

Output: one self-contained plan with three atomic tasks (telemetry + fallback
core, health endpoint + settings cleanup, chaos test). `swift build -c release`
and `swift test --filter AfplayPlayerChaosTests` both pass.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/quick/260407-h07-antifragile-fix-for-afplayplayer-wav-wri/260407-h07-RESEARCH.md
@.planning/debug/tts-no-audio-260406.md
@plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
@plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
@plugins/claude-tts-companion/Tests/CompanionCoreTests/StreamingPipelineTests.swift

<interfaces>
<!-- Key existing shapes the executor needs. Do NOT explore the codebase for these — use them as specified. -->

From AfplayPlayer.swift (current state, line numbers are approximate — executor must re-verify):

```swift
@MainActor
public final class AfplayPlayer {
    private let logger = Logger(label: "afplay-player")
    // Line ~44-48: lazy debugWavDir — must be replaced with explicit do/catch init
    private let debugWavDir: String = { ... try? FileManager.default.createDirectory(...) ... }()
    // Line ~111: func play(label:onComplete:) -> Bool
    //   Line ~135: let wavPath = debugWavDir + "/tts-\(timestamp)_\(slug).wav"
    //   Line ~137: try writeWav(samples: pendingSamples, sampleRate: 48000, to: wavPath)
    // Line ~326: private func startSegment(samples:label:)
    //   Line ~364: let wavPath = debugWavDir + "/tts-\(timestamp)_\(slug).wav"
    //   Line ~366: try writeWav(samples: samples, sampleRate: 48000, to: wavPath)
    // Line ~520: private func writeWav(samples:sampleRate:to:) throws
}
```

From HTTPControlServer.swift:

```swift
// Line ~47-54: HealthResponse is a private struct — must be extended
private struct HealthResponse: Codable {
    let status: String
    let uptime_seconds: Int
    let rss_mb: Double
    let subsystems: SubsystemStatus
    let audio_routing_clean: Bool?
    let audio_routing_warnings: [String]?
    // NEW: let afplay: AfplayHealth
}

// Line ~302: private func healthResponse() -> HTTPResponse — must populate new field
// Executor accesses player via: playbackManager.afplayPlayer  (property on PlaybackManager)
```

From SettingsStore.swift (line 122):

```swift
// Current (to be replaced):
try? FileManager.default.createDirectory(
    atPath: configDir,
    withIntermediateDirectories: true,
    attributes: nil
)
// The class already has: private let logger = Logger(label: "settings-store")
```

From StreamingPipelineTests.swift (test framework pattern to copy):

```swift
@testable import CompanionCore
import Foundation
import Testing

@Suite(.serialized)
@MainActor
struct StreamingPipelineTests {
    @Test func someBehavior() { #expect(...) }
}
```

SwiftBar /health consumer (verified in this plan):

- Parses with `jq -re '.status // "ok"'`, `.uptime_seconds // 0`, `.rss_mb // 0`,
  `.subsystems.bot // "unknown"`, `.subsystems.tts // "unknown"`,
  `.subsystems.subtitle // "unknown"`, `.audio_routing_clean // true`.
- Does NOT enumerate top-level keys. Does NOT use `keys` or `to_entries`.
- **Verdict:** Adding a new top-level `afplay` object is backward compatible.
  No `?include=afplay` gating required.
  </interfaces>

</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Fallback chain + collapsed telemetry in AfplayPlayer</name>
  <files>plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift</files>
  <behavior>
    Contracts the tests (Task 3) will assert against:
    - `ensureWritableWavDirectory() -> URL` returns the primary dir URL when
      `~/.local/share/tts-debug-wav` is writable; returns `NSTemporaryDirectory() + "claude-tts-wav/"`
      when primary createDirectory fails; falls through to a mkstemp-allocated
      file in `/tmp` as last resort (represented as a URL whose parent is `/tmp`).
    - Idempotent: calling it repeatedly with a healthy primary returns the
      primary path and performs at most one createDirectory syscall per call
      (createDirectory is a single stat; not optimized further).
    - On primary failure, increments `WavWriteFailureState.consecutiveFailureCount`
      and sets `fallbackLevel = 1` (or 2 for mkstemp). Classifies errors into
      `"missing_dir" | "eperm" | "enospc" | "erofs" | "eexist" | "other"` based
      on NSError code / POSIX errno.
    - Rate-limited logging: at most ONE structured warning per failure class
      per 60 seconds. The 2nd through Nth failures in that window increment the
      counter silently. Exactly one "primary_recovered" info log fires when
      the primary becomes writable again, and resets state.
    - `getAfplayHealthSnapshot() -> AfplayHealthSnapshot` returns an actor-isolated
      snapshot struct (Sendable) with: `primaryDirWritable: Bool`, `fallbackLevel: Int`,
      `consecutiveFailureCount: Int`, `totalFallbackWrites: Int`,
      `lastFailureClass: String?`, `lastFailureAt: Date?`. Safe to call from
      tests and from HTTPControlServer via `MainActor.run`.
    - Init-time `try? FileManager.default.createDirectory` is replaced with
      `do { try ... } catch { logger.error("Failed to create debugWavDir at init: \(error)") }`.
      Since ensureWritableWavDirectory handles runtime creation, init failure
      is non-fatal but now observable.
  </behavior>
  <action>
Edit ONLY `plugins/claude-tts-companion/Sources/CompanionCore/AfplayPlayer.swift`. No other files.

**Step 1 — Read the current file and note EXACT line numbers** for the two `writeWav` call sites (currently approximately lines 135-137 and 364-366 per research) and the init-time `try?` at line ~46. Line numbers in this plan are from the research snapshot and may have drifted; verify before editing.

**Step 2 — Add types and state** (insert near the top of the class, after the existing `isPipelinedMode` property):

```swift
// MARK: - WAV Path Fallback Chain (h07)

/// Classification of WAV-write-path failures, for structured telemetry.
private enum WavFailureClass: String {
    case missingDir = "missing_dir"   // ENOENT on parent
    case eperm = "eperm"              // EPERM / EACCES
    case enospc = "enospc"            // ENOSPC / EDQUOT
    case erofs = "erofs"              // EROFS
    case eexist = "eexist"            // EEXIST where dir was expected (race)
    case other = "other"
}

/// Collapsed failure telemetry — at most one log per class per 60s.
private struct WavWriteFailureState {
    var consecutiveFailureCount: Int = 0
    var firstFailureAt: Date? = nil
    var lastFailureClass: String? = nil
    var fallbackLevel: Int = 0          // 0=primary, 1=tmpdir, 2=mkstemp
    var nextRecoveryAttemptAt: Date? = nil
    var totalFallbackWrites: Int = 0
    var lastLoggedAt: [String: Date] = [:]   // per-class throttle
}

/// Sendable snapshot for /health exposure.
public struct AfplayHealthSnapshot: Sendable, Codable {
    public let primary_dir_writable: Bool
    public let fallback_level: Int
    public let consecutive_failure_count: Int
    public let total_fallback_writes: Int
    public let last_failure_class: String?
    public let last_failure_at: String?   // ISO8601, nil if never
}

private var wavFailureState = WavWriteFailureState()
```

**Step 3 — Replace the init-time `try?`** (currently ~line 44-48). Change from:

```swift
private let debugWavDir: String = {
    let dir = NSHomeDirectory() + "/.local/share/tts-debug-wav"
    try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
    return dir
}()
```

to an explicit two-step pattern (the stored property remains a computed path; creation moves to `init` with explicit error logging):

```swift
/// Primary retention directory for debug WAVs. Path is captured eagerly;
/// actual writability is re-verified before every WAV write via
/// `ensureWritableWavDirectory()` to self-heal mid-session deletion.
private let debugWavDir: String = NSHomeDirectory() + "/.local/share/tts-debug-wav"

public init() {
    do {
        try FileManager.default.createDirectory(
            atPath: debugWavDir, withIntermediateDirectories: true)
    } catch {
        // Non-fatal: ensureWritableWavDirectory() will retry on first write.
        logger.error("Failed to create debugWavDir at init: \(error) — will retry on first write via fallback chain")
    }
}
```

Note: the class currently has no explicit `init`. Adding one is safe (all stored properties have defaults / `let` initializers). Confirm no caller passes init args by grep before committing.

**Step 4 — Add the fallback chain methods** (place in a new `// MARK: - WAV Path Resolution` section near `writeWav`):

```swift
/// Resolve a writable directory for WAV output. Tries primary, then tmp, then
/// last-resort mkstemp. Called before every WAV write — the createDirectory
/// syscall is ~1-10us on an existing dir (idempotent stat).
///
/// Returns the directory URL plus the fallback level used (0/1/2). For level 2
/// (mkstemp), the "directory" is `/tmp` and the caller must use the exact file
/// path returned by `lastResortMkstempPath` rather than composing its own.
private func ensureWritableWavDirectory() -> (url: URL, level: Int, mkstempPath: String?) {
    // Tier 0: primary
    let primaryURL = URL(fileURLWithPath: debugWavDir, isDirectory: true)
    do {
        try FileManager.default.createDirectory(at: primaryURL, withIntermediateDirectories: true)
        // Success — check if we're recovering from a failing state
        if wavFailureState.fallbackLevel > 0 || wavFailureState.consecutiveFailureCount > 0 {
            logger.info("[TELEMETRY] afplay-player WAV write primary_recovered prior_failures=\(wavFailureState.consecutiveFailureCount) prior_level=\(wavFailureState.fallbackLevel)")
            wavFailureState.consecutiveFailureCount = 0
            wavFailureState.fallbackLevel = 0
            wavFailureState.firstFailureAt = nil
            wavFailureState.lastFailureClass = nil
            wavFailureState.nextRecoveryAttemptAt = nil
            wavFailureState.lastLoggedAt.removeAll()
        }
        return (primaryURL, 0, nil)
    } catch {
        recordFailure(classify(error), error: error, attemptedPath: debugWavDir)
    }

    // Tier 1: NSTemporaryDirectory()/claude-tts-wav
    let tmpPath = (NSTemporaryDirectory() as NSString).appendingPathComponent("claude-tts-wav")
    let tmpURL = URL(fileURLWithPath: tmpPath, isDirectory: true)
    do {
        try FileManager.default.createDirectory(at: tmpURL, withIntermediateDirectories: true)
        if wavFailureState.fallbackLevel < 1 {
            wavFailureState.fallbackLevel = 1
            logger.warning("[TELEMETRY] afplay-player WAV write fallback_engaged level=1 path=\(tmpPath) reason=\(wavFailureState.lastFailureClass ?? "unknown")")
        }
        wavFailureState.totalFallbackWrites += 1
        return (tmpURL, 1, nil)
    } catch {
        recordFailure(classify(error), error: error, attemptedPath: tmpPath)
    }

    // Tier 2: mkstemp in /tmp
    var template = "/tmp/claude-tts-wav-XXXXXX.wav".utf8CString.map { Int8($0) }
    let fd = template.withUnsafeMutableBufferPointer { buf -> Int32 in
        return mkstemps(buf.baseAddress, 4)  // 4 = length of ".wav" suffix
    }
    if fd >= 0 {
        close(fd)
        let path = template.withUnsafeBufferPointer { buf -> String in
            String(cString: buf.baseAddress!)
        }
        if wavFailureState.fallbackLevel < 2 {
            wavFailureState.fallbackLevel = 2
            logger.warning("[TELEMETRY] afplay-player WAV write ultimate_fallback_engaged level=2 path=\(path)")
        }
        wavFailureState.totalFallbackWrites += 1
        return (URL(fileURLWithPath: "/tmp", isDirectory: true), 2, path)
    }
    // Truly exhausted — log and return primary (caller will fail gracefully)
    logger.error("[TELEMETRY] afplay-player WAV write all_tiers_exhausted errno=\(errno)")
    return (primaryURL, 0, nil)
}

private func classify(_ error: Error) -> WavFailureClass {
    let ns = error as NSError
    // NSError wrapping POSIX errors uses NSPOSIXErrorDomain with errno codes.
    if ns.domain == NSPOSIXErrorDomain {
        switch Int32(ns.code) {
        case ENOENT: return .missingDir
        case EPERM, EACCES: return .eperm
        case ENOSPC, EDQUOT: return .enospc
        case EROFS: return .erofs
        case EEXIST: return .eexist
        default: return .other
        }
    }
    // Cocoa errors from FileManager
    if ns.domain == NSCocoaErrorDomain {
        switch ns.code {
        case NSFileNoSuchFileError, NSFileReadNoSuchFileError: return .missingDir
        case NSFileWriteNoPermissionError, NSFileReadNoPermissionError: return .eperm
        case NSFileWriteOutOfSpaceError: return .enospc
        case NSFileWriteVolumeReadOnlyError: return .erofs
        case NSFileWriteFileExistsError: return .eexist
        default: return .other
        }
    }
    return .other
}

private func recordFailure(_ cls: WavFailureClass, error: Error, attemptedPath: String) {
    wavFailureState.consecutiveFailureCount += 1
    if wavFailureState.firstFailureAt == nil {
        wavFailureState.firstFailureAt = Date()
    }
    wavFailureState.lastFailureClass = cls.rawValue

    // Throttle: at most one log per class per 60s
    let now = Date()
    let lastLogged = wavFailureState.lastLoggedAt[cls.rawValue]
    if lastLogged == nil || now.timeIntervalSince(lastLogged!) >= 60.0 {
        let iso = ISO8601DateFormatter().string(from: wavFailureState.firstFailureAt ?? now)
        let tmpPath = (NSTemporaryDirectory() as NSString).appendingPathComponent("claude-tts-wav")
        let activeFallback = wavFailureState.fallbackLevel == 0 ? debugWavDir
            : wavFailureState.fallbackLevel == 1 ? tmpPath : "/tmp/mkstemp"
        logger.warning("afplay-player WAV write failure CLASS=\(cls.rawValue) COUNT=\(wavFailureState.consecutiveFailureCount) SINCE=\(iso) fallback_active=\(activeFallback) successful_writes_via_fallback=\(wavFailureState.totalFallbackWrites) attempted_path=\(attemptedPath) error=\(error)")
        wavFailureState.lastLoggedAt[cls.rawValue] = now
    }
}

/// Sendable snapshot for /health.
public func getAfplayHealthSnapshot() -> AfplayHealthSnapshot {
    let iso: String? = wavFailureState.firstFailureAt.map { ISO8601DateFormatter().string(from: $0) }
    let primaryWritable = (wavFailureState.fallbackLevel == 0 && wavFailureState.consecutiveFailureCount == 0)
    return AfplayHealthSnapshot(
        primary_dir_writable: primaryWritable,
        fallback_level: wavFailureState.fallbackLevel,
        consecutive_failure_count: wavFailureState.consecutiveFailureCount,
        total_fallback_writes: wavFailureState.totalFallbackWrites,
        last_failure_class: wavFailureState.lastFailureClass,
        last_failure_at: iso
    )
}
```

**Step 5 — Replace both writeWav call sites** to use the fallback chain. At each site (currently ~L135 in `play()` and ~L364 in `startSegment()`):

Replace:

```swift
let wavPath = debugWavDir + "/tts-\(timestamp)_\(slug).wav"
do {
    try writeWav(samples: pendingSamples, sampleRate: 48000, to: wavPath)
} catch { ... }
```

with:

```swift
let resolved = ensureWritableWavDirectory()
let wavPath: String
if let mkstempPath = resolved.mkstempPath {
    wavPath = mkstempPath  // level 2 — exact file path from mkstemp
} else {
    wavPath = resolved.url.appendingPathComponent("tts-\(timestamp)_\(slug).wav").path
}
do {
    try writeWav(samples: pendingSamples, sampleRate: 48000, to: wavPath)
} catch {
    recordFailure(classify(error), error: error, attemptedPath: wavPath)
    logger.error("Failed to write WAV for afplay: \(error)")
    onComplete?()
    return false
}
```

Apply the analogous change at the `startSegment` call site (use `samples` instead of `pendingSamples`, and the pipelined-path failure branch uses `advanceQueue()` + `return` instead of `onComplete?()` + `return false`).

**Do NOT touch** any posix_spawn, waitpid, QoS, kill, or segment-chaining code. The diff must be surgical.

**Step 6 — Build** with `cd plugins/claude-tts-companion && swift build -c release 2>&1 | tail -40`. Fix any Swift 6 strict-concurrency warnings. Common pitfalls:

- `AfplayHealthSnapshot` must be `public` if `HTTPControlServer` (different module? — check; likely same target `CompanionCore`) uses it. If same module, `internal` works.
- `mkstemps` requires `Darwin` (already imported).
- `ENOENT` etc require `Darwin` / `Foundation` — check imports.

**Step 7 — Commit** with message `fix(claude-tts-companion): self-healing WAV path fallback + collapsed telemetry`.
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift build -c release 2>&1 | tail -20 &amp;&amp; grep -n "ensureWritableWavDirectory\|getAfplayHealthSnapshot\|WavWriteFailureState" Sources/CompanionCore/AfplayPlayer.swift</automated>
</verify>
<done>
swift build -c release succeeds. AfplayPlayer.swift contains: `ensureWritableWavDirectory`, `WavWriteFailureState`, `AfplayHealthSnapshot`, `getAfplayHealthSnapshot`, `classify(_:)`, `recordFailure(_:error:attemptedPath:)`. Init-time `try?` is gone; replaced with explicit `do/catch` in `init()` with `logger.error`. Both `writeWav` call sites use `ensureWritableWavDirectory()` to compute `wavPath`. No changes to posix_spawn, waitpid, QoS, kill, or segment-chaining code.
</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Expose /health.afplay + SettingsStore do/catch cleanup</name>
  <files>plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift, plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift</files>
  <behavior>
    - GET /health responds with a top-level `afplay` object containing:
      `primary_dir_writable`, `fallback_level`, `consecutive_failure_count`,
      `total_fallback_writes`, `last_failure_class` (nullable), `last_failure_at`
      (nullable ISO8601). All other existing fields unchanged (`status`,
      `uptime_seconds`, `rss_mb`, `subsystems`, `audio_routing_clean`,
      `audio_routing_warnings`).
    - The SwiftBar plugin at `~/Library/Application Support/SwiftBar/Plugins/claude-hq.10s.sh`
      continues to parse /health without error. Verified in this plan: the
      plugin uses field-specific `jq -re '.foo // default'` accessors, which
      tolerate additive keys. No `?include=afplay` gating needed.
    - SettingsStore init logs `"Failed to create config dir: {error}"` instead
      of silently swallowing the error when `createDirectory` fails. Settings
      load path still uses default on read failure (no behavior change beyond
      observability).
  </behavior>
  <action>
**Part A — HTTPControlServer.swift**

1. Extend the private `HealthResponse` struct (~L47) to add an `afplay: AfplayHealthSnapshot` field. Since `AfplayHealthSnapshot` is already `Codable`, no further encoder work is needed.

```swift
private struct HealthResponse: Codable {
    let status: String
    let uptime_seconds: Int
    let rss_mb: Double
    let subsystems: SubsystemStatus
    let audio_routing_clean: Bool?
    let audio_routing_warnings: [String]?
    let afplay: AfplayHealthSnapshot   // NEW
}
```

1. Update `healthResponse()` (~L302) to populate it. `AfplayPlayer` is `@MainActor`; `HTTPControlServer` is `@unchecked Sendable`. The handler closure at `GET /health` already runs in a FlyingFox request context. Wrap the snapshot call in `MainActor.run`:

The current call site looks like:

```swift
await server.appendRoute("GET /health") { [self] _ in
    return healthResponse()
}
```

Change `healthResponse()` to an async function that captures the afplay snapshot on the main actor:

```swift
private func healthResponse() async -> HTTPResponse {
    let uptimeSeconds = Int(Date().timeIntervalSince(startTime))
    let rssMB = currentRSSMB()
    let afplaySnapshot = await MainActor.run { playbackManager.afplayPlayer.getAfplayHealthSnapshot() }

    let health = HealthResponse(
        status: audioRoutingClean ? "ok" : "degraded",
        uptime_seconds: uptimeSeconds,
        rss_mb: rssMB,
        subsystems: SubsystemStatus(
            bot: telegramBot?.watching == true ? "watching" : (telegramBot != nil ? "stopped" : "unknown"),
            tts: "ready",
            subtitle: "ready"
        ),
        audio_routing_clean: audioRoutingClean,
        audio_routing_warnings: audioRoutingWarnings.isEmpty ? nil : audioRoutingWarnings,
        afplay: afplaySnapshot
    )
    return jsonResponse(health)
}
```

And update the route handler:

```swift
await server.appendRoute("GET /health") { [self] _ in
    return await healthResponse()
}
```

**Verify** `playbackManager.afplayPlayer` is the correct access path. If `PlaybackManager` exposes the player under a different name, match it. If `afplayPlayer` is `internal` (not `public`) that is fine — same module `CompanionCore`.

**Part B — SettingsStore.swift**

Replace lines 121-126:

```swift
// Ensure config directory exists
try? FileManager.default.createDirectory(
    atPath: configDir,
    withIntermediateDirectories: true,
    attributes: nil
)
```

with:

```swift
// Ensure config directory exists (explicit error logging — h07)
do {
    try FileManager.default.createDirectory(
        atPath: configDir,
        withIntermediateDirectories: true,
        attributes: nil
    )
} catch {
    logger.error("Failed to create config dir \(configDir): \(error) — settings writes will fail until dir is recreated")
}
```

No other changes to SettingsStore. Settings load/save behavior is unchanged.

**Part C — Build verification + SwiftBar smoke**

```bash
cd plugins/claude-tts-companion && swift build -c release 2>&1 | tail -20
```

Fix any warnings. Swift 6 strict concurrency may require marking the `healthResponse()` async call chain carefully — if `AfplayPlayer.getAfplayHealthSnapshot()` is `@MainActor`, the `await MainActor.run` wrapper handles the hop.

Do NOT deploy the binary or restart the service in this task. Deployment is a manual post-merge step.

**Commit** with `feat(claude-tts-companion): expose afplay WAV telemetry on /health + log SettingsStore dir failures`.
</action>
<verify>
<automated>cd plugins/claude-tts-companion &amp;&amp; swift build -c release 2>&1 | tail -20 &amp;&amp; grep -n "afplay: AfplayHealthSnapshot\|getAfplayHealthSnapshot" Sources/CompanionCore/HTTPControlServer.swift &amp;&amp; grep -n "Failed to create config dir" Sources/CompanionCore/SettingsStore.swift</automated>
</verify>
<done>
swift build -c release succeeds. HealthResponse struct contains `afplay: AfplayHealthSnapshot`. healthResponse() is async and calls `await MainActor.run { playbackManager.afplayPlayer.getAfplayHealthSnapshot() }`. The `GET /health` route is `await healthResponse()`. SettingsStore init has explicit `do/catch` with `logger.error("Failed to create config dir ...")`. No other files touched.
</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: AfplayPlayerChaosTests — verifies self-heal + single-log collapse + health snapshot</name>
  <files>plugins/claude-tts-companion/Tests/CompanionCoreTests/AfplayPlayerChaosTests.swift</files>
  <behavior>
    Four tests in one `@Suite(.serialized) @MainActor` struct. None invoke afplay or play audio; all operate on the directory-resolution subsystem in isolation.

    1. `primaryRecreatesWhenDeleted`: delete `~/.local/share/tts-debug-wav/`, call `ensureWritableWavDirectory()`, expect the returned URL path to match the primary and the dir to exist on disk again. Health snapshot shows `fallback_level=0`, `primary_dir_writable=true`.

    2. `fallsBackWhenPrimaryReadOnly`: create the primary dir, `chmod 0o500` its *parent* (`~/.local/share/`) to simulate a non-writable parent that blocks createDirectory, call `ensureWritableWavDirectory()` (deleted first so createDirectory actually fails), expect level 1 (NSTemporaryDirectory path) or level 2 (mkstemp). Health snapshot shows `fallback_level >= 1`, `consecutive_failure_count >= 1`, `last_failure_class` non-nil. `defer` chmod back to 0o755. **Test skips gracefully if the parent cannot be chmod'd (e.g., on CI where HOME layout differs).**

    3. `failureLoggedAtMostOncePerClassPer60s`: cause primary to fail 10 times in a tight loop (while parent is read-only). Verify `consecutive_failure_count == 10` but that `wavFailureState.lastLoggedAt` has exactly one entry per class. (Use a test-only accessor — see below.)

    4. `recoveryLoggedOnce`: start in a failing state (level 1), restore primary writability, call `ensureWritableWavDirectory()` twice. Expect `fallback_level == 0` after the first call and that calling it again does not log a second recovery event. (Verified indirectly: after recovery, `consecutive_failure_count == 0` and `fallback_level == 0` — a second call keeps them at 0.)

    Test-only accessor: add a `#if DEBUG` or internal-visible property on AfplayPlayer named `__testingFailureStateSnapshot()` returning the whole `WavWriteFailureState` (or at minimum: `loggedClassCount: Int`). Alternatively, assert on `getAfplayHealthSnapshot()` only — simpler and sufficient for tests 1, 2, and 4. Test 3 can assert via health snapshot: if logging is NOT throttled, `consecutiveFailureCount` grows but `totalFallbackWrites` also grows; the actual "logged at most once" assertion requires either a log-capture mechanism OR an exposed `loggedClassCount`.

    **Decision:** add a minimal `internal` accessor on AfplayPlayer:
    ```swift
    #if DEBUG
    internal var __testing_loggedClassCount: Int { wavFailureState.lastLoggedAt.count }
    #endif
    ```
    This is the smallest possible test seam. Alternative (log capture) is too heavyweight for a chaos test.

  </behavior>
  <action>
**Step 1 — Add the test seam to AfplayPlayer.swift.** Edit the file from Task 1 to add (inside the class, near the telemetry state):

```swift
#if DEBUG
/// Test-only accessor for log throttle state. Do not use in production code.
internal var __testing_loggedClassCount: Int { wavFailureState.lastLoggedAt.count }
internal var __testing_consecutiveFailureCount: Int { wavFailureState.consecutiveFailureCount }
#endif
```

Swift test builds default to debug, so `#if DEBUG` is active in `swift test`. If that's not the case in this project, drop the `#if DEBUG` and leave them `internal`.

**Step 2 — Create** `plugins/claude-tts-companion/Tests/CompanionCoreTests/AfplayPlayerChaosTests.swift`:

```swift
@testable import CompanionCore
import Foundation
import Testing

/// Chaos tests for the WAV path fallback chain introduced in h07.
///
/// These tests do NOT invoke afplay or play audio. They exercise
/// `ensureWritableWavDirectory()` and `getAfplayHealthSnapshot()` in isolation
/// by manipulating the filesystem state of `~/.local/share/tts-debug-wav/`.
///
/// On the pre-fix AfplayPlayer, these tests fail because:
///   - there is no `ensureWritableWavDirectory()` method
///   - there is no `getAfplayHealthSnapshot()` method
///   - the init-time `try?` swallows failures silently
@Suite(.serialized)
@MainActor
struct AfplayPlayerChaosTests {

    private var primaryDir: String {
        NSHomeDirectory() + "/.local/share/tts-debug-wav"
    }

    /// Helper: ensure primary exists and is writable before each test.
    private func resetPrimary() {
        let fm = FileManager.default
        let parent = (primaryDir as NSString).deletingLastPathComponent
        _ = chmod(parent, 0o755)
        try? fm.createDirectory(atPath: primaryDir, withIntermediateDirectories: true)
        _ = chmod(primaryDir, 0o755)
    }

    @Test func primaryRecreatesWhenDeleted() throws {
        resetPrimary()
        let player = AfplayPlayer()

        // Chaos: delete the primary dir
        try? FileManager.default.removeItem(atPath: primaryDir)
        #expect(!FileManager.default.fileExists(atPath: primaryDir),
                "Primary dir should be deleted before act")

        // Act: resolve — should recreate primary
        let resolved = player.ensureWritableWavDirectoryForTesting()
        #expect(resolved.level == 0, "Primary should recreate successfully")
        #expect(FileManager.default.fileExists(atPath: primaryDir),
                "Primary dir should be recreated")

        let snap = player.getAfplayHealthSnapshot()
        #expect(snap.primary_dir_writable == true)
        #expect(snap.fallback_level == 0)
        #expect(snap.consecutive_failure_count == 0)
    }

    @Test func fallsBackWhenPrimaryUnwritable() throws {
        resetPrimary()
        let fm = FileManager.default
        let parent = (primaryDir as NSString).deletingLastPathComponent

        // Remove primary and make parent read-only
        try? fm.removeItem(atPath: primaryDir)
        let chmodResult = chmod(parent, 0o500)
        guard chmodResult == 0 else {
            // Cannot execute chaos on this filesystem; skip gracefully
            return
        }
        defer { _ = chmod(parent, 0o755); resetPrimary() }

        let player = AfplayPlayer()
        let resolved = player.ensureWritableWavDirectoryForTesting()
        #expect(resolved.level >= 1, "Expected fallback to level 1 or 2, got \(resolved.level)")

        let snap = player.getAfplayHealthSnapshot()
        #expect(snap.fallback_level >= 1)
        #expect(snap.consecutive_failure_count >= 1)
        #expect(snap.last_failure_class != nil)
    }

    @Test func failureLoggedAtMostOncePerClassIn60sWindow() throws {
        resetPrimary()
        let fm = FileManager.default
        let parent = (primaryDir as NSString).deletingLastPathComponent

        try? fm.removeItem(atPath: primaryDir)
        let chmodResult = chmod(parent, 0o500)
        guard chmodResult == 0 else { return }
        defer { _ = chmod(parent, 0o755); resetPrimary() }

        let player = AfplayPlayer()
        // Hammer the resolver to force repeated failures
        for _ in 0..<10 {
            _ = player.ensureWritableWavDirectoryForTesting()
        }

        #expect(player.__testing_consecutiveFailureCount >= 10,
                "Failures should accumulate")
        #expect(player.__testing_loggedClassCount <= 2,
                "At most one log per class (primary + possibly tmp); got \(player.__testing_loggedClassCount)")
    }

    @Test func recoveryResetsStateAndIsIdempotent() throws {
        resetPrimary()
        let fm = FileManager.default
        let parent = (primaryDir as NSString).deletingLastPathComponent

        // Put player into failing state
        try? fm.removeItem(atPath: primaryDir)
        let chmodResult = chmod(parent, 0o500)
        guard chmodResult == 0 else { return }
        let player = AfplayPlayer()
        _ = player.ensureWritableWavDirectoryForTesting()
        let failingSnap = player.getAfplayHealthSnapshot()
        #expect(failingSnap.fallback_level >= 1)

        // Restore writability
        _ = chmod(parent, 0o755)
        defer { resetPrimary() }

        // First recovery call resets state
        let recovered = player.ensureWritableWavDirectoryForTesting()
        #expect(recovered.level == 0)
        let recoveredSnap = player.getAfplayHealthSnapshot()
        #expect(recoveredSnap.fallback_level == 0)
        #expect(recoveredSnap.consecutive_failure_count == 0)

        // Second call is a no-op at the telemetry level
        _ = player.ensureWritableWavDirectoryForTesting()
        let idempotentSnap = player.getAfplayHealthSnapshot()
        #expect(idempotentSnap.fallback_level == 0)
        #expect(idempotentSnap.consecutive_failure_count == 0)
    }
}
```

**Step 3 — Expose a testable shim.** The tests call `ensureWritableWavDirectoryForTesting()` because the real method is `private`. Add this shim to AfplayPlayer.swift (near the other `__testing_*` accessors):

```swift
#if DEBUG
internal func ensureWritableWavDirectoryForTesting() -> (url: URL, level: Int, mkstempPath: String?) {
    return ensureWritableWavDirectory()
}
#endif
```

**Step 4 — Run the tests**:

```bash
cd plugins/claude-tts-companion && swift test --filter AfplayPlayerChaosTests 2>&1 | tail -40
```

All 4 tests should pass. If `chmod` skips on this filesystem, tests 2-4 return early gracefully; test 1 must always pass.

**Step 5 — Commit** with `test(claude-tts-companion): chaos tests for AfplayPlayer WAV path self-healing`.

**Note on pre-fix verification:** The task_brief mandates verifying the test FAILS on unmodified AfplayPlayer. This is satisfied structurally: the test references `ensureWritableWavDirectoryForTesting`, `getAfplayHealthSnapshot`, `AfplayHealthSnapshot`, and `__testing_loggedClassCount` — none of which exist on unmodified AfplayPlayer. On the pre-fix code, this file would fail to COMPILE, which is a strictly stronger form of "test fails". Document this in the commit message.
</action>
<verify>
<automated>cd plugins/claude-tts-companion &amp;&amp; swift test --filter AfplayPlayerChaosTests 2>&1 | tail -30</automated>
</verify>
<done>
`swift test --filter AfplayPlayerChaosTests` reports 4 tests, 0 failures. Tests cover: primary recreation after deletion, fallback engagement when primary is unwritable, log-throttling under repeated failures, and idempotent recovery. Tests run in isolation without invoking afplay. AfplayPlayer.swift exposes minimal test shims (`ensureWritableWavDirectoryForTesting`, `__testing_loggedClassCount`, `__testing_consecutiveFailureCount`) gated under `#if DEBUG`.
</done>
</task>

</tasks>

<verification>
- `cd plugins/claude-tts-companion && swift build -c release` succeeds with no new warnings in AfplayPlayer.swift, HTTPControlServer.swift, or SettingsStore.swift.
- `cd plugins/claude-tts-companion && swift test --filter AfplayPlayerChaosTests` passes (4 tests).
- `cd plugins/claude-tts-companion && swift test` full suite still passes (no regressions in StreamingPipelineTests or others).
- Grep confirms:
  - `grep -n "ensureWritableWavDirectory" Sources/CompanionCore/AfplayPlayer.swift` returns the method definition plus two call sites (in `play()` and `startSegment()`).
  - `grep -rn "try? FileManager.default.createDirectory" Sources/` returns zero hits.
  - `grep -n "afplay: AfplayHealthSnapshot" Sources/CompanionCore/HTTPControlServer.swift` returns one hit.
- Manual smoke (post-merge, out of scope for the plan executor): restart the companion, run a TTS request, `curl http://localhost:8780/health | jq '.afplay'` returns a populated object, SwiftBar menu bar continues to render without "unknown" fields.
</verification>

<success_criteria>

- AfplayPlayer.swift has a fallback chain called on every writeWav: primary → NSTemporaryDirectory → mkstemp. Idempotent createDirectory calls. Init-time `try?` replaced with explicit do/catch.
- Structured telemetry collapses to at most one log per failure-class per 60s. One recovery log fires when primary becomes writable again. Counters, first-failure timestamp, fallback level, and total fallback writes are tracked.
- GET /health returns a new `afplay` object with all six fields (`primary_dir_writable`, `fallback_level`, `consecutive_failure_count`, `total_fallback_writes`, `last_failure_class`, `last_failure_at`). Existing SwiftBar consumer continues to work — additive JSON change verified safe against jq field-specific `//` default parsing.
- SettingsStore.swift:122 converted to explicit do/catch with `logger.error`. No other `try? createDirectory` remains in Sources/.
- AfplayPlayerChaosTests.swift exists with 4 tests covering self-heal, fallback engagement, log throttling, and recovery idempotence. Uses the same `@Suite(.serialized) @MainActor` pattern as StreamingPipelineTests.
- swift build -c release and swift test both pass. No audio-path code (posix_spawn, waitpid, killall, QoS, segment chaining, deferred offset) was modified.
- Three atomic commits with conventional commit messages.
  </success_criteria>

<output>
After completion, create `.planning/quick/260407-h07-antifragile-fix-for-afplayplayer-wav-wri/260407-h07-SUMMARY.md` documenting:
- Final line numbers of the edits (as shipped, not planned)
- Exact jq keys that SwiftBar reads from /health.afplay (if any were added to SwiftBar in a follow-up)
- Test run output from `swift test --filter AfplayPlayerChaosTests`
- Any deviations from this plan (test skips on CI filesystem, renamed accessor, etc.)
- Post-merge manual smoke checklist: restart companion, curl /health, play a TTS, delete debugWavDir mid-session, confirm audio continues
</output>
