---
phase: 18-companioncore-library-test-infrastructure
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Package.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
autonomous: true
requirements: [ARCH-01]

must_haves:
  truths:
    - "All business logic lives in CompanionCore library target, not the executable"
    - "main.swift is the only file in the executable target"
    - "swift build succeeds with the new library + executable + test target structure"
  artifacts:
    - path: "plugins/claude-tts-companion/Package.swift"
      provides: "Three-target SwiftPM manifest (CompanionCore library, executable, test)"
      contains: '.target(name: "CompanionCore"'
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift"
      provides: "Coordinator facade that owns all subsystems"
      contains: "public final class CompanionApp"
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift"
      provides: "Ultra-thin entry point (~50 lines)"
      contains: "import CompanionCore"
  key_links:
    - from: "main.swift"
      to: "CompanionApp"
      via: "import CompanionCore + CompanionApp().start()"
      pattern: "CompanionApp\\(\\)"
    - from: "Package.swift"
      to: "Sources/CompanionCore/"
      via: '.target(name: "CompanionCore")'
      pattern: "CompanionCore"
---

<objective>
Extract all business logic from the single executable target into a CompanionCore library target. After this plan, `Sources/claude-tts-companion/` contains only `main.swift` (~50 lines) and all 25 business logic files live in `Sources/CompanionCore/`. A new `CompanionApp` coordinator class replaces the inline wiring in main.swift.

Purpose: SwiftPM cannot `@testable import` an executable target. Library extraction is the prerequisite for all testing (Phase 18 Plan 02, Phase 20).
Output: Compiling project with library + executable + test target structure.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-companioncore-library-test-infrastructure/18-CONTEXT.md
@.planning/phases/18-companioncore-library-test-infrastructure/18-RESEARCH.md
@plugins/claude-tts-companion/Package.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift

<interfaces>
<!-- Key types the executor needs to understand for CompanionApp extraction -->

From Sources/claude-tts-companion/main.swift (current wiring to extract):

- Creates: SettingsStore, SubtitlePanel, TTSEngine, CaptionHistory, HTTPControlServer, MiniMaxClient, SummaryEngine, AutoContinueEvaluator, NotificationProcessor, ThinkingWatcher, TelegramBot, NotificationWatcher
- Helper functions: extractGitBranch(from:), extractFirstTimestamp(_:), extractLastTimestamp(_:)
- Memory lifecycle: plannedRestart(reason:), checkMemoryLifecycleRestart()
- All subsystems stored as nonisolated(unsafe) var keepAlive references

From Sources/claude-tts-companion/Config.swift:

```swift
enum Config {
    static let appName = "claude-tts-companion"
    static let httpPort: UInt16 = 8780
    static var telegramBotToken: String? { ... }
    static var telegramChatId: String? { ... }
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Restructure Package.swift and move files to CompanionCore</name>
  <files>
    plugins/claude-tts-companion/Package.swift,
    plugins/claude-tts-companion/Sources/CompanionCore/ (directory with 25 moved files)
  </files>
  <read_first>
    plugins/claude-tts-companion/Package.swift,
    plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
  </read_first>
  <action>
1. Create `Sources/CompanionCore/` directory.

1. Move all 25 `.swift` files from `Sources/claude-tts-companion/` to `Sources/CompanionCore/`, EXCEPT `main.swift`:

   ```bash
   cd plugins/claude-tts-companion
   mkdir -p Sources/CompanionCore
   for f in Sources/claude-tts-companion/*.swift; do
       [ "$(basename "$f")" = "main.swift" ] && continue
       mv "$f" Sources/CompanionCore/
   done
   mkdir -p Tests/CompanionCoreTests
   ```

2. Update `Package.swift` to have three targets (per D-01, research Pattern 1):
   - `.target(name: "CompanionCore", ...)` -- library target with ALL dependencies (SwiftTelegramBot, Logging, FlyingFox, KokoroSwift, MLXUtilsLibrary, MLX) and linkerSettings for AppKit, Foundation, AVFoundation
   - `.executableTarget(name: "claude-tts-companion", dependencies: ["CompanionCore"])` -- depends ONLY on CompanionCore, linkerSettings for AppKit only
   - `.testTarget(name: "CompanionCoreTests", dependencies: ["CompanionCore"])` -- for Plan 02

   Exact Package.swift content:

   ```swift
   // swift-tools-version: 6.0
   import PackageDescription

   let package = Package(
       name: "claude-tts-companion",
       platforms: [.macOS(.v15)],
       dependencies: [
           .package(url: "https://github.com/nerzh/swift-telegram-sdk", from: "4.5.0"),
           .package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
           .package(url: "https://github.com/swhitty/FlyingFox", from: "0.26.0"),
           .package(url: "https://github.com/terrylica/kokoro-ios.git", exact: "1.0.14"),
           .package(url: "https://github.com/mlalma/MLXUtilsLibrary.git", exact: "0.0.6"),
           .package(url: "https://github.com/ml-explore/mlx-swift", exact: "0.30.2"),
       ],
       targets: [
           .target(
               name: "CompanionCore",
               dependencies: [
                   .product(name: "SwiftTelegramBot", package: "swift-telegram-sdk"),
                   .product(name: "Logging", package: "swift-log"),
                   .product(name: "FlyingFox", package: "FlyingFox"),
                   .product(name: "KokoroSwift", package: "kokoro-ios"),
                   .product(name: "MLXUtilsLibrary", package: "MLXUtilsLibrary"),
                   .product(name: "MLX", package: "mlx-swift"),
               ],
               linkerSettings: [
                   .linkedFramework("AppKit"),
                   .linkedFramework("Foundation"),
                   .linkedFramework("AVFoundation"),
               ]
           ),
           .executableTarget(
               name: "claude-tts-companion",
               dependencies: ["CompanionCore"],
               linkerSettings: [
                   .linkedFramework("AppKit"),
               ]
           ),
           .testTarget(
               name: "CompanionCoreTests",
               dependencies: ["CompanionCore"]
           ),
       ]
   )
   ```

3. Add `public` access modifier to ALL type declarations (class, struct, enum, protocol) in the 25 moved files (per D-04). Use sed or manual editing. Types need `public` on the declaration line only -- methods and properties stay `internal`. Specific types that also need public init/methods for main.swift:
   - `Config` -- needs `public` on `appName`, `httpPort` static properties (used by logger label in main.swift... but after extraction, main.swift won't use Config directly, CompanionApp will)

   Actually per D-06, main.swift only needs the public `CompanionApp` facade. So: add `public` to every `class`/`struct`/`enum`/`protocol` declaration. `CompanionApp` (created in Task 2) gets `public init()`, `public func start()`, `public func shutdown()`. Everything else stays internal -- tests use `@testable import`.

4. Verify `Sources/claude-tts-companion/` contains ONLY `main.swift` after the move.
   </action>

  <verify>
    <automated>cd plugins/claude-tts-companion && ls Sources/claude-tts-companion/ | wc -l && ls Sources/CompanionCore/ | wc -l</automated>
  </verify>
  <acceptance_criteria>
    - `ls Sources/claude-tts-companion/` shows exactly 1 file: `main.swift`
    - `ls Sources/CompanionCore/` shows exactly 25 files (all business logic)
    - Package.swift contains `.target(name: "CompanionCore"`
    - Package.swift contains `.testTarget(name: "CompanionCoreTests"`
    - Package.swift contains `.executableTarget(name: "claude-tts-companion", dependencies: ["CompanionCore"]`
    - Every `.swift` file in `Sources/CompanionCore/` has `public` on its primary type declaration (grep for `^public class\|^public struct\|^public enum\|^public protocol\|^public final class`)
  </acceptance_criteria>
  <done>25 files moved to CompanionCore, Package.swift has 3 targets, all types have public access modifier</done>
</task>

<task type="auto">
  <name>Task 2: Create CompanionApp coordinator and slim main.swift</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/CompanionApp.swift,
    plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift,
    plugins/claude-tts-companion/Sources/CompanionCore/Config.swift,
    plugins/claude-tts-companion/Sources/CompanionCore/TTSEngine.swift
  </read_first>
  <action>
1. Create `Sources/CompanionCore/CompanionApp.swift` (per D-01, D-02, research Pattern 5):

```swift
import AppKit
import Foundation
import Logging

public final class CompanionApp: @unchecked Sendable {
    private let logger = Logger(label: Config.appName)

    // All subsystems as properties
    private let settingsStore: SettingsStore
    private let subtitlePanel: SubtitlePanel
    private let ttsEngine: TTSEngine
    private let captionHistory: CaptionHistory
    private let httpServer: HTTPControlServer
    private let miniMaxClient: MiniMaxClient
    private let summaryEngine: SummaryEngine
    private let autoContinue: AutoContinueEvaluator
    private let notificationProcessor: NotificationProcessor
    private var thinkingWatcher: ThinkingWatcher!
    private var telegramBot: TelegramBot?
    private var notificationWatcher: NotificationWatcher!

    public init() {
        // Create subsystems (same order as current main.swift)
        settingsStore = SettingsStore()
        subtitlePanel = SubtitlePanel(settingsStore: settingsStore)
        ttsEngine = TTSEngine()
        captionHistory = CaptionHistory()
        httpServer = HTTPControlServer(
            settingsStore: settingsStore,
            subtitlePanel: subtitlePanel,
            ttsEngine: ttsEngine,
            captionHistory: captionHistory
        )
        miniMaxClient = MiniMaxClient()
        summaryEngine = SummaryEngine(client: miniMaxClient)
        autoContinue = AutoContinueEvaluator(client: miniMaxClient)
        notificationProcessor = NotificationProcessor()
    }

    public func start() {
        // Position subtitle panel
        subtitlePanel.positionOnScreen()

        // Create thinking watcher
        thinkingWatcher = ThinkingWatcher(client: miniMaxClient) { [weak self] summary in
            guard let self = self else { return }
            self.logger.info("Thinking summary: \(summary)")
            DispatchQueue.main.async {
                self.subtitlePanel.show(text: summary)
                self.captionHistory.record(summary)
            }
        }

        // Start HTTP server
        Task {
            do {
                logger.info("Starting HTTP control API on port \(Config.httpPort)")
                try await httpServer.start()
            } catch {
                logger.warning("HTTP server failed to start: \(error) -- continuing without HTTP API")
            }
        }

        // Start Telegram bot if configured
        if let token = Config.telegramBotToken, let chatIdStr = Config.telegramChatId, let chatId = Int64(chatIdStr) {
            let bot = TelegramBot(
                botToken: token,
                chatId: chatId,
                summaryEngine: summaryEngine,
                ttsEngine: ttsEngine,
                subtitlePanel: subtitlePanel
            )
            telegramBot = bot
            httpServer.setBot(bot)
            Task {
                do {
                    try await bot.start()
                    logger.info("Telegram bot started successfully")
                } catch {
                    logger.warning("Telegram bot failed to start: \(error) -- continuing without bot")
                }
            }
        } else {
            logger.warning("TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID not set -- bot disabled")
        }

        // Create notification watcher with full processing pipeline
        notificationWatcher = NotificationWatcher { [weak self] filePath in
            guard let self = self else { return }
            self.handleNotification(filePath: filePath)
        }
        notificationWatcher.start()

        logger.info("Starting \(Config.appName)")

        // Show TTS demo only when bot is disabled (dev mode)
        if telegramBot == nil {
            showDemoTTS()
        }
    }

    public func shutdown() {
        logger.info("Shutting down")
        subtitlePanel.hide()
        notificationWatcher?.stop()
        thinkingWatcher?.stop()
        if let bot = telegramBot {
            Task { await bot.stop() }
        }
    }
}
```

Then add private extension with all the extracted helper methods:

- `handleNotification(filePath:)` -- the full notification processing closure from current main.swift (lines 98-204)
- `plannedRestart(reason:)` -- extracted from main.swift lines 214-229
- `checkMemoryLifecycleRestart()` -- extracted from main.swift lines 234-238
- `extractGitBranch(from:)` -- extracted from main.swift lines 306-318
- `extractFirstTimestamp(_:)` -- extracted from main.swift lines 321-331
- `extractLastTimestamp(_:)` -- extracted from main.swift lines 334-346
- `showDemoTTS()` -- extracted from main.swift lines 281-301

  All these become private methods on CompanionApp. The notification handler references `self.notificationProcessor`, `self.autoContinue`, `self.telegramBot`, etc.

1. Rewrite `main.swift` to be ultra-thin (~50 lines, per D-01, D-03):

   ```swift
   import AppKit
   import CompanionCore
   import Foundation
   import Logging

   // Unbuffer stdout/stderr for launchd
   setbuf(stdout, nil)
   setbuf(stderr, nil)

   // Configure logging first
   LoggingSystem.bootstrap(StreamLogHandler.standardError)

   // Set up NSApplication as accessory (no dock icon, no app switcher)
   let app = NSApplication.shared
   app.setActivationPolicy(.accessory)

   // Create and start the companion app
   let companion = CompanionApp()
   companion.start()

   // Set up SIGTERM handler using DispatchSource
   let sigSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
   signal(SIGTERM, SIG_IGN)
   sigSource.setEventHandler {
       companion.shutdown()
       // Post dummy event to unblock RunLoop
       let event = NSEvent.otherEvent(
           with: .applicationDefined, location: .zero,
           modifierFlags: [], timestamp: 0, windowNumber: 0,
           context: nil, subtype: 0, data1: 0, data2: 0
       )!
       app.postEvent(event, atStart: true)
       app.stop(nil)
   }
   sigSource.resume()

   // Store references globally to prevent ARC deallocation (per D-03)
   nonisolated(unsafe) var keepAlive: (any DispatchSourceSignal)? = sigSource
   nonisolated(unsafe) var keepCompanion: CompanionApp? = companion

   // Enter run loop (blocks forever until SIGTERM)
   app.run()
   ```

   Note: main.swift no longer needs `nonisolated(unsafe)` keepAlive for every subsystem -- CompanionApp owns them all. Only keepAlive for sigSource and companion itself (per D-03).

2. Run `swift build` to verify the restructured project compiles.
   </action>

  <verify>
    <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - `swift build` exits with code 0
    - `main.swift` contains `import CompanionCore`
    - `main.swift` contains `CompanionApp()` instantiation
    - `main.swift` does NOT contain `SettingsStore()`, `SubtitlePanel(`, `TTSEngine()`, `MiniMaxClient()`, `SummaryEngine(`, `AutoContinueEvaluator(`
    - `main.swift` has at most 2 `nonisolated(unsafe)` declarations (sigSource + companion)
    - `CompanionApp.swift` contains `public final class CompanionApp`
    - `CompanionApp.swift` contains `public func start()`
    - `CompanionApp.swift` contains `public func shutdown()`
    - `CompanionApp.swift` contains `func plannedRestart(`
    - `CompanionApp.swift` contains `func extractGitBranch(`
    - `wc -l < Sources/claude-tts-companion/main.swift` is under 60 lines
  </acceptance_criteria>
  <done>CompanionApp coordinator created with all extracted logic. main.swift is ~50 lines containing only NSApplication setup, SIGTERM handler, and run loop. `swift build` succeeds.</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` -- project compiles with new structure
2. `ls Sources/claude-tts-companion/` -- shows only `main.swift`
3. `ls Sources/CompanionCore/ | wc -l` -- shows 26 files (25 moved + 1 new CompanionApp.swift)
4. `grep -l "^public" Sources/CompanionCore/*.swift | wc -l` -- all files have public types
5. `wc -l Sources/claude-tts-companion/main.swift` -- under 60 lines
</verification>

<success_criteria>

- `swift build` succeeds with zero errors
- main.swift is the ONLY file in the executable target
- All 25 original business logic files + new CompanionApp.swift live in Sources/CompanionCore/
- main.swift imports CompanionCore and uses only CompanionApp facade
- Package.swift defines CompanionCore library target, executable target, and test target
  </success_criteria>

<output>
After completion, create `.planning/phases/18-companioncore-library-test-infrastructure/18-01-SUMMARY.md`
</output>
