---
phase: 16-integration--reliability
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/NotificationProcessor.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
autonomous: true
requirements: [REL-01, REL-02, REL-03, REL-04, REL-05]

must_haves:
  truths:
    - "Duplicate notifications are skipped when transcript file has not grown since last notification for that session"
    - "Notification processing is rate-limited to at most one every 5 seconds"
    - "Circuit breaker trips after 3 consecutive MiniMax failures, enters 5-minute cooldown, and uses fallback narrative"
    - "Stop hook notification JSON is read with all required fields (sessionId, cwd, transcriptPath, itermSessionId)"
    - "Tool breakdown shows top 6 tools by count, excluding subagent orchestration tools"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/NotificationProcessor.swift"
      provides: "Dedup and rate limiting for notification pipeline"
      min_lines: 80
      contains: "NotificationProcessor"
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift"
      provides: "Wiring NotificationProcessor into notification callback"
      contains: "NotificationProcessor"
  key_links:
    - from: "NotificationProcessor.swift"
      to: "FileWatcher.swift"
      via: "NotificationWatcher callback receives file path, NotificationProcessor gates processing"
      pattern: "NotificationProcessor"
    - from: "main.swift"
      to: "NotificationProcessor.swift"
      via: "main.swift creates NotificationProcessor and passes it as callback"
      pattern: "notificationProcessor"
---

<objective>
Add production reliability to the notification pipeline: deduplication (session ID + transcript size tracking with 15-min TTL), rate limiting (5s minimum between processing), and wire everything together.

Purpose: Prevent duplicate Telegram notifications when users re-open and close sessions without new work, and avoid notification storms when multiple sessions end rapidly.

Output: NotificationProcessor.swift (new) + updated main.swift wiring
</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

@plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/FileWatcher.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/CircuitBreaker.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/AutoContinue.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift

<interfaces>
<!-- Key types and contracts the executor needs -->

From FileWatcher.swift:

```swift
final class NotificationWatcher: @unchecked Sendable {
    init(directory: String = Config.notificationDir, callback: @escaping (String) -> Void)
    func start()
    func stop()
}
```

From CircuitBreaker.swift:

```swift
final class CircuitBreaker: @unchecked Sendable {
    let maxFailures: Int       // default 3
    let cooldownSeconds: TimeInterval  // default 300 (5 min)
    var isOpen: Bool
    func recordFailure()
    func recordSuccess()
    var failureCount: Int
}
```

From AutoContinue.swift (tool breakdown already implemented):

```swift
// SUBAGENT_TOOLS exclusion set:
private static let SUBAGENT_TOOLS: Set<String> = [
    "Agent", "Task", "TaskCreate", "TaskGet", "TaskList",
    "TaskOutput", "TaskUpdate", "TaskStop"
]

// buildToolBreakdown already computes top 6 tools, excluding subagent tools
func buildToolBreakdown(turns: [ConversationTurn]) -> (totalCalls: Int, breakdown: String)
```

From Config.swift:

```swift
static let notificationDir: String  // ~/.claude/notifications
```

From main.swift (current notification callback pattern, lines 96-187):

- NotificationWatcher callback reads JSON, extracts sessionId/transcriptPath/cwd/itermSessionId
- Calls autoContinue.evaluate() then bot.sendSessionNotification()
- No dedup or rate limiting currently exists
  </interfaces>

</context>

<tasks>

<task type="auto">
  <name>Task 1: Create NotificationProcessor with dedup and rate limiting</name>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/NotificationProcessor.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift (lines 96-187 for current notification callback)
    - plugins/claude-tts-companion/Sources/claude-tts-companion/FileWatcher.swift (NotificationWatcher API)
    - plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift (notificationDir)
    - ~/.claude/automation/claude-telegram-sync/src/claude-sync/notification-watcher.ts (lines 39-46 for dedup logic, lines 36-37 for rate limit constants, lines 99-121 for processing gate)
  </read_first>
  <action>
Create `NotificationProcessor.swift` porting the dedup and rate limiting logic from the legacy TypeScript notification-watcher.ts.

**NotificationProcessor class** (final class, @unchecked Sendable, NSLock for thread safety):

1. **Dedup tracking** (REL-01):
   - Private struct `DedupEntry { let processedAt: Date; let transcriptSize: UInt64 }`
   - Private dictionary `processedSessions: [String: DedupEntry]` keyed by sessionId
   - Constant `SESSION_DEDUP_TTL: TimeInterval = 900` (15 minutes)
   - Method `shouldSkipDedup(sessionId: String, transcriptPath: String) -> Bool`:
     - Look up sessionId in processedSessions
     - If found and within TTL: get current transcript file size via FileManager.attributesOfItem
     - If current size <= cached size: return true (skip, transcript unchanged)
     - If current size > cached size: log "re-notifying, transcript grew X -> Y bytes", return false
     - If not found or TTL expired: return false
   - Method `recordProcessed(sessionId: String, transcriptPath: String)`:
     - Get file size, store DedupEntry with Date() and size
   - Method `pruneExpiredEntries()`:
     - Remove entries older than TTL \* 2 (30 min) to prevent unbounded growth

2. **Rate limiting** (REL-02):
   - Private var `lastProcessTime: Date = .distantPast`
   - Private var `isProcessing: Bool = false`
   - Constant `MIN_INTERVAL: TimeInterval = 5.0` (5 seconds)
   - Private var `pendingFilePath: String?` to track files that arrive during rate limit window
   - Method `processIfReady(filePath: String, handler: @escaping (String) -> Void)`:
     - If isProcessing: log "blocked by mutex", store as pendingFilePath, return
     - If Date().timeIntervalSince(lastProcessTime) < MIN_INTERVAL: schedule retry via DispatchQueue.global().asyncAfter, return
     - Set isProcessing = true, lastProcessTime = Date()
     - Call handler(filePath) in a closure that:
       - On completion: set isProcessing = false, call pruneExpiredEntries(), check pendingFilePath and re-invoke if present

3. **Config constants** -- add to Config.swift:
   - `static let notificationDedupTTL: TimeInterval = 900` (15 min)
   - `static let notificationMinInterval: TimeInterval = 5.0` (5 sec)

Use Logging framework with label "notification-processor". All log messages should match the legacy TypeScript format for grep-ability (include sessionId prefix, byte counts for dedup).
</action>
<verify>
<automated>cd /Users/terryli/eon/cc-skills/plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - NotificationProcessor.swift exists with dedup (session ID + transcript size) and rate limiting (5s gate) - DedupEntry tracks processedAt and transcriptSize - Dedup TTL is 15 minutes, rate limit is 5 seconds - Expired entries are pruned to prevent unbounded Map growth - Config.swift has notificationDedupTTL and notificationMinInterval constants
</acceptance_criteria>
<done>NotificationProcessor compiles, has dedup with 15-min TTL and 5s rate limiting</done>
</task>

<task type="auto">
  <name>Task 2: Wire NotificationProcessor into main.swift and verify build</name>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift (full file, especially lines 96-187 notification callback)
    - plugins/claude-tts-companion/Sources/claude-tts-companion/NotificationProcessor.swift (just created in Task 1)
  </read_first>
  <action>
Refactor the NotificationWatcher callback in main.swift to use NotificationProcessor for dedup and rate limiting.

1. **Create NotificationProcessor instance** after the existing object creation block (after `let autoContinue = ...`):

   ```swift
   let notificationProcessor = NotificationProcessor()
   ```

2. **Wrap the existing notification callback** inside notificationProcessor.processIfReady:
   - The outer NotificationWatcher callback calls `notificationProcessor.processIfReady(filePath: filePath) { path in ... }`
   - Inside the handler closure, move the existing notification processing logic (JSON read, sessionId extraction, etc.)
   - After extracting sessionId and transcriptPath, add dedup check:

     ```swift
     if notificationProcessor.shouldSkipDedup(sessionId: sessionId, transcriptPath: tp) {
         logger.info("Dedup: skipping re-notification for session \(sessionId.prefix(8))")
         return
     }
     ```

   - At the end of successful processing (after both auto-continue and session notification are sent), call:

     ```swift
     notificationProcessor.recordProcessed(sessionId: sessionId, transcriptPath: tp)
     ```

3. **Add keepAlive reference** to prevent ARC deallocation:

   ```swift
   nonisolated(unsafe) var keepNotificationProcessor: NotificationProcessor? = notificationProcessor
   ```

4. **Verify existing reliability features are intact** (no changes needed, just confirm):
   - REL-03: CircuitBreaker is already wired into MiniMaxClient (3 failures, 5min cooldown). SummaryEngine returns "Session completed." fallback on any error including circuit breaker open.
   - REL-04: Stop hook (telegram-notify-stop.ts) already writes notification JSON with sessionId, cwd, slug, timestamp, transcriptPath, itermSessionId. The Swift side reads all these fields (lines 112-116 of current main.swift).
   - REL-05: AutoContinue.buildToolBreakdown() already computes top 6 tools excluding SUBAGENT_TOOLS, and formatDecisionMessage renders it as "Bash61 Edit54 Read55" format.

5. **Build verification**: Run `swift build` to confirm everything compiles.
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/cc-skills/plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - main.swift creates NotificationProcessor and uses it in the NotificationWatcher callback
    - Dedup check happens before notification processing (shouldSkipDedup)
    - Processed sessions are recorded after successful notification (recordProcessed)
    - Rate limiting gates the entire callback via processIfReady
    - keepAlive reference prevents ARC deallocation
    - swift build succeeds with zero errors
  </acceptance_criteria>
  <done>NotificationProcessor wired into main.swift, dedup + rate limiting active, swift build passes</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` succeeds
2. NotificationProcessor.swift exists with dedup (DedupEntry, 15-min TTL, transcript size comparison)
3. NotificationProcessor.swift exists with rate limiting (5s MIN_INTERVAL, mutex gate)
4. main.swift uses NotificationProcessor in the NotificationWatcher callback
5. CircuitBreaker is wired (already verified in MiniMaxClient.swift)
6. Tool breakdown is computed (already verified in AutoContinue.swift)
7. Stop hook notification fields are read (already verified in main.swift)
</verification>

<success_criteria>

- REL-01: Dedup skips re-notifications when transcript unchanged within 15-min TTL
- REL-02: Rate limiting enforces 5s minimum between notification processing
- REL-03: Circuit breaker (3 failures, 5-min cooldown) already works via MiniMaxClient + SummaryEngine fallback
- REL-04: Stop hook JSON fields (sessionId, cwd, transcriptPath, itermSessionId) already read correctly
- REL-05: Tool breakdown (top 6, excluding subagent tools) already computed in AutoContinue.buildToolBreakdown()
- swift build succeeds with zero errors
  </success_criteria>

<output>
After completion, create `.planning/phases/16-integration--reliability/16-01-SUMMARY.md`
</output>
