---
phase: 11-notification-formatting
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
autonomous: true
requirements: [FMT-01, FMT-02, FMT-04, FMT-05, FMT-06]
must_haves:
  truths:
    - "Session header renders project name, path (~/ substituted), session ID (8-char), git branch, duration, turn count as pipe-separated HTML"
    - "Markdown bold, italic, code, pre (with language class), and links convert to Telegram HTML entities"
    - "Messages >4096 chars split at fence-aware boundaries with fence close/reopen across chunks"
    - "File references (.md, .py, .go, .sh, .pl, .am, .at, .be, .cc) wrapped in <code> to prevent Telegram auto-linking"
    - "Last prompt text has Claude meta-tags and skill expansions stripped before display"
    - "Prompts >800 chars are shown as condensed version when promptSummary is available"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift"
      provides: "Full notification formatting engine with legacy feature parity"
      exports:
        [
          "renderSessionNotification",
          "markdownToTelegramHtml",
          "chunkTelegramHtml",
          "wrapFileReferencesInHtml",
          "stripMetaTags",
          "stripSkillExpansion",
          "projectName",
          "formatDuration",
        ]
  key_links:
    - from: "TelegramFormatter.renderSessionNotification"
      to: "TelegramFormatter.escapeHtml"
      via: "All user-supplied text escaped before HTML embedding"
      pattern: "escapeHtml\\("
    - from: "TelegramFormatter.markdownToTelegramHtml"
      to: "TelegramFormatter.wrapFileReferencesInHtml"
      via: "File ref wrapping runs AFTER markdown->HTML conversion"
      pattern: "wrapFileReferencesInHtml"
    - from: "TelegramFormatter.chunkTelegramHtml"
      to: "TelegramFormatter.parseFenceSpans"
      via: "Chunking uses fence spans for safe break detection and fence close/reopen"
      pattern: "parseFenceSpans\\("
---

<objective>
Upgrade TelegramFormatter.swift to full legacy feature parity with the TypeScript formatting pipeline.

Purpose: Port the exact formatting logic from formatter.ts, format.ts, and fences.ts so session notifications match the legacy TypeScript output. This is the core formatting engine that Plan 02 wires into the bot.

Output: TelegramFormatter.swift with renderSessionNotification(), markdownToTelegramHtml() (with file ref wrapping), enhanced chunkTelegramHtml() (with fence close/reopen), and meta-tag/skill-expansion stripping.
</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
@plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/SummaryEngine.swift

<interfaces>
<!-- Key types the executor needs from existing code -->

From SummaryEngine.swift:

```swift
struct ConversationTurn {
    let prompt: String
    let response: String
    let timestamp: Date?
    let toolSummary: String?
    let toolResults: String?
}

struct SummaryResult {
    let narrative: String
    let promptSummary: String?
    let ttsGreeting: String?
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add renderSessionNotification, meta-tag stripping, file ref wrapping, and enhanced chunking to TelegramFormatter</name>
  <read_first>
    - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift (current implementation to upgrade)
    - ~/.claude/automation/claude-telegram-sync/src/claude-sync/formatter.ts (renderMessage, stripMetaTags, stripSkillExpansion, projectName, formatDuration)
    - ~/.claude/automation/claude-telegram-sync/src/telegram/format.ts (markdownToTelegramHtml with wrapFileReferencesInHtml, chunkTelegramHtml with fence close/reopen, FILE_EXTENSIONS_WITH_TLD, wrapSegmentFileRefs)
    - ~/.claude/automation/claude-telegram-sync/src/telegram/fences.ts (parseFenceSpans — already ported but verify findFenceSpanAt boundary check)
  </read_first>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift</files>
  <action>
Rewrite TelegramFormatter.swift to add the following, porting EXACT logic from the TypeScript sources. Keep all existing working code (escapeHtml, FenceSpan, parseFenceSpans, pickSafeBreakIndex, markdownToTelegramHtml, stripHtmlTags, chunkTelegramHtml basic structure) but upgrade/add:

**1. Fix findFenceSpanAt boundary condition (FMT-05)**
The legacy TS uses `index > span.start && index < span.end` (strict greater-than on start), but the current Swift uses `index >= span.start`. Fix to match legacy:

```swift
static func findFenceSpanAt(_ spans: [FenceSpan], index: Int) -> FenceSpan? {
    spans.first { index > $0.start && index < $0.end }
}
```

**2. Add projectName() — port from formatter.ts (FMT-01)**

```swift
static func projectName(_ cwd: String) -> String {
    let trimmed = cwd.replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
    let parts = trimmed.split(separator: "/").map(String.init)
    let folder = parts.last ?? "unknown"
    // Strip git-town worktree suffixes: "repo.worktree-2026-02-14-branch" -> "repo"
    return folder.replacingOccurrences(of: "\\.worktree-.*$", with: "", options: .regularExpression)
}
```

**3. Add formatDuration() — port from formatter.ts (FMT-01)**

```swift
static func formatDuration(from start: Date, to end: Date) -> String {
    let diff = Int(end.timeIntervalSince(start))
    let seconds = max(0, diff)
    let minutes = seconds / 60
    let hours = minutes / 60
    if hours > 0 {
        return "\(hours)h \(minutes % 60)m"
    } else if minutes > 0 {
        return "\(minutes)m \(seconds % 60)s"
    } else {
        return "\(seconds)s"
    }
}
```

**4. Add stripMetaTags() — port from formatter.ts (FMT-02)**
Strip Claude Code meta tags from user prompt text. Port these exact regex patterns:

```swift
static func stripMetaTags(_ text: String) -> String {
    var result = text
    // Remove Claude Code slash command wrappers
    result = result.replacingOccurrences(of: "<command-message>[^<]*</command-message>", with: "", options: .regularExpression)
    result = result.replacingOccurrences(of: "<command-name>[^<]*</command-name>", with: "", options: .regularExpression)
    result = result.replacingOccurrences(of: "<command-args>[^<]*</command-args>", with: "", options: .regularExpression)
    result = result.replacingOccurrences(of: "<local-command-caveat>[^<]*</local-command-caveat>", with: "", options: .regularExpression)
    result = result.replacingOccurrences(of: "<local-command-stdout>[^<]*</local-command-stdout>", with: "", options: .regularExpression)
    // Remove system reminders (multiline)
    result = result.replacingOccurrences(of: "<system-reminder>[\\s\\S]*?</system-reminder>", with: "", options: .regularExpression)
    // Collapse whitespace
    result = result.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
    return result.trimmingCharacters(in: .whitespaces)
}
```

**5. Add stripSkillExpansion() — port from formatter.ts (FMT-02)**
Strip skill-injected expansion content appended after the user's real prompt:

```swift
static func stripSkillExpansion(_ text: String) -> String {
    guard let range = text.range(of: "\n\n") else { return text }
    let afterBreak = String(text[range.upperBound...]).trimmingCharacters(in: .whitespaces)
    // Check if what follows looks like a skill expansion (bold uppercase, headers, block formatting)
    let pattern = "^(\\*\\*[A-Z]|#{1,4}\\s|---\\s*\\n|>\\s|-\\s\\[|TRIGGERS\\b)"
    if afterBreak.range(of: pattern, options: .regularExpression) != nil {
        return String(text[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
    }
    return text
}
```

**6. Add FILE_EXTENSIONS_WITH_TLD set and wrapFileReferencesInHtml() — port from format.ts (FMT-06)**

```swift
private static let fileExtensionsWithTLD: Set<String> = [
    "md", "go", "py", "pl", "sh", "am", "at", "be", "cc"
]
```

Port the full `wrapFileReferencesInHtml()` function with:

- `isAutoLinkedFileRef()` helper that strips `https?://` prefix and checks if label matches, has dot, and extension is in fileExtensionsWithTLD
- Auto-linked anchor pattern: `<a\s+href="https?://([^"]+)"[^>]*>\1</a>` — de-linkify these
- File reference pattern: `(^|[^a-zA-Z0-9_\-/])([a-zA-Z0-9_.\-./]+\.(?:md|go|py|pl|sh|am|at|be|cc))(?=$|[^a-zA-Z0-9_\-/])`
- Orphaned TLD pattern: `([^a-zA-Z0-9]|^)([A-Za-z]\.(?:md|go|py|...))(?=[^a-zA-Z0-9/]|$)`
- HTML tag walker that tracks code/pre/anchor nesting depth, only wrapping file refs in segments outside these tags
- `wrapStandaloneFileRef()` that skips `//` prefix and `https?://` prefix
- Call `wrapFileReferencesInHtml` at the END of `markdownToTelegramHtml` (update existing function to add this call as final step, matching TS `return wrapFileReferencesInHtml(html)`)

**7. Upgrade chunkTelegramHtml() with fence close/reopen — port from format.ts (FMT-05)**
The current Swift implementation splits at paragraph boundaries but does NOT close/reopen fences when forced to split inside a code block. Port the `chunkMarkdownText()` logic from format.ts:

- When splitting inside a fence (detected via `findFenceSpanAt`), append `\n{indent}{marker}` to close the fence in the current chunk
- Prepend `{openLine}\n` to the next chunk to reopen it
- Find break points inside fences preferring newlines, with minimum progress past the fence opening
- Handle the edge case where close-line length leaves no room (fall back to hard break without fence split)

**8. Add renderSessionNotification() — port from formatter.ts renderMessage() (FMT-01, FMT-02)**
This is the main public API. Accepts a struct with all notification metadata:

```swift
struct SessionNotificationData {
    let sessionId: String
    let cwd: String
    let gitBranch: String?
    let startTime: Date?
    let lastActivity: Date?
    let turnCount: Int
    let lastUserPrompt: String?
    let aiNarrative: String?
    let promptSummary: String?
}
```

Port the exact renderMessage() logic:

```swift
static func renderSessionNotification(_ data: SessionNotificationData) -> String {
    let sessionIdShort = String(data.sessionId.prefix(8))
    let cwdDisplay = data.cwd.replacingOccurrences(of: "/Users/[^/]+", with: "~", options: .regularExpression)

    // Clean the last prompt
    let rawPrompt: String
    if let prompt = data.lastUserPrompt {
        rawPrompt = stripSkillExpansion(stripMetaTags(prompt))
    } else {
        rawPrompt = ""
    }

    let PROMPT_DISPLAY_MAX = 800
    let wasCondensed = data.promptSummary != nil && rawPrompt.count > PROMPT_DISPLAY_MAX
    let lastPrompt: String
    if wasCondensed, let summary = data.promptSummary {
        lastPrompt = summary
    } else if rawPrompt.count > PROMPT_DISPLAY_MAX {
        lastPrompt = String(rawPrompt.prefix(PROMPT_DISPLAY_MAX)) + "..."
    } else {
        lastPrompt = rawPrompt
    }

    // Duration from last prompt time (or start) to last activity
    let duration: String
    if let start = data.startTime, let end = data.lastActivity {
        duration = formatDuration(from: start, to: end)
    } else {
        duration = ""
    }

    // Build metadata line: project | path | session | branch | duration | turns
    var metaParts: [String] = []
    metaParts.append("<b>\(escapeHtml(projectName(data.cwd)))</b>")
    metaParts.append("<code>\(escapeHtml(cwdDisplay))</code>")
    metaParts.append("<code>\(escapeHtml(sessionIdShort))</code>")
    if let branch = data.gitBranch, !branch.isEmpty {
        metaParts.append("<code>\(escapeHtml(branch))</code>")
    }
    if !duration.isEmpty {
        metaParts.append(escapeHtml(duration))
    }
    if data.turnCount > 1 {
        metaParts.append("\(data.turnCount) turns")
    }
    let metaLine = metaParts.joined(separator: " | ")

    // Summary block
    let summaryBlock: String
    if let narrative = data.aiNarrative, !narrative.isEmpty {
        summaryBlock = "\n<b>Summary</b>:\n\(escapeHtml(narrative))\n"
    } else {
        summaryBlock = ""
    }

    // Assemble
    var message = metaLine
    if !lastPrompt.isEmpty {
        message += "\n\n<b>Last Prompt</b>"
        if wasCondensed { message += " <i>(condensed)</i>" }
        message += ":\n<i>\(escapeHtml(lastPrompt))</i>"
    }
    message += summaryBlock

    return message.trimmingCharacters(in: .whitespacesAndNewlines)
}
```

IMPORTANT: Keep all existing public API (escapeHtml, parseFenceSpans, findFenceSpanAt, isSafeFenceBreak, markdownToTelegramHtml, chunkTelegramHtml, stripHtmlTags) — only upgrade/extend them. Do not break the existing PromptExecutor.swift usage of escapeHtml and chunkTelegramHtml.
</action>
<verify>
<automated>cd /Users/terryli/eon/cc-skills/plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria> - TelegramFormatter.swift compiles with zero errors - renderSessionNotification() produces pipe-separated header with project name, path, session ID, branch, duration, turns - stripMetaTags removes all 6 Claude Code meta-tag patterns - stripSkillExpansion detects bold-uppercase/header/block patterns after \n\n - markdownToTelegramHtml calls wrapFileReferencesInHtml as final step - wrapFileReferencesInHtml wraps .md/.py/.go/.sh/.pl/.am/.at/.be/.cc in code tags, skips inside existing code/pre/a tags - chunkTelegramHtml closes and reopens fences when forced to split inside a code block - findFenceSpanAt uses strict > on start (not >=) matching legacy TS - SessionNotificationData struct defined with all 9 fields
</acceptance_criteria>
<done>
swift build succeeds. TelegramFormatter has renderSessionNotification(), stripMetaTags(), stripSkillExpansion(), projectName(), formatDuration(), wrapFileReferencesInHtml(), and fence close/reopen chunking — all matching legacy TypeScript logic.
</done>
</task>

</tasks>

<verification>
```bash
cd plugins/claude-tts-companion && swift build 2>&1 | tail -3
```
Build must succeed with zero errors. Grep for key function signatures to confirm they exist:
```bash
grep -c 'renderSessionNotification\|stripMetaTags\|stripSkillExpansion\|wrapFileReferencesInHtml\|projectName\|formatDuration\|SessionNotificationData' plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
```
Should return 7+ matches.
</verification>

<success_criteria>
TelegramFormatter.swift is a complete formatting engine with legacy feature parity. swift build succeeds. All 6 key functions exist and match the TypeScript source logic.
</success_criteria>

<output>
After completion, create `.planning/phases/11-notification-formatting/11-01-SUMMARY.md`
</output>
