---
phase: 09-content-management
plan: 06
type: execute
wave: 3
depends_on: ["09-01", "09-02"]
files_modified:
  - summercms/src/hot/FileWatcher.scala
  - summercms/src/hot/HotReloadService.scala
  - summercms/src/hot/TemplateInvalidator.scala
  - summercms/src/config/AppConfig.scala
  - summercms/src/Main.scala
autonomous: true

must_haves:
  truths:
    - "Developer edits template file, browser refresh shows changes without server restart"
    - "Page/layout/partial modifications reflect immediately after save"
    - "Hot reload only active when DEV_MODE environment variable is true"
    - "File system changes detected efficiently without CPU overhead"
    - "Only changed template is reloaded, not the entire cache"
  artifacts:
    - path: "summercms/src/hot/FileWatcher.scala"
      provides: "File change detection"
      contains: "class FileWatcher"
    - path: "summercms/src/hot/HotReloadService.scala"
      provides: "Reload orchestration service"
      contains: "trait HotReloadService"
    - path: "summercms/src/hot/TemplateInvalidator.scala"
      provides: "Pebble cache invalidation"
      contains: "class TemplateInvalidator"
  key_links:
    - from: "FileWatcher"
      to: "HotReloadService"
      via: "file change events trigger reload"
      pattern: "HotReloadService.*onFileChanged"
    - from: "HotReloadService"
      to: "PebbleEngine.cache"
      via: "invalidates template cache"
      pattern: "pebbleEngine.*invalidate"
---

<objective>
Implement hot reload for development mode

Purpose: Enable fast iteration during development by automatically reloading templates when files change. Per DIFF-02 requirement and CONTEXT.md decision for Claude's discretion on implementation details.

Output: FileWatcher using JDK WatchService, HotReloadService orchestrating reloads, TemplateInvalidator for Pebble cache, configuration for dev mode detection.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/09-content-management/09-RESEARCH.md
@.planning/phases/09-content-management/09-01-SUMMARY.md
@.planning/phases/09-content-management/09-02-SUMMARY.md
@summercms/src/config/AppConfig.scala
@summercms/src/Main.scala
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create FileWatcher using JDK WatchService</name>
  <files>
    summercms/src/hot/FileWatcher.scala
    summercms/src/config/AppConfig.scala
  </files>
  <action>
**Update AppConfig.scala:**
Add development mode configuration:
```scala
case class DevConfig(
  enabled: Boolean = false,          // Enable dev features
  hotReload: Boolean = false,        // Enable hot reload
  watchPaths: List[String] = List(), // Additional paths to watch
  watchDebounceMs: Int = 100         // Debounce rapid changes
)

case class AppConfig(
  server: ServerConfig,
  database: DatabaseConfig,
  dev: DevConfig = DevConfig()
)
```

Update application.conf:
```hocon
dev {
  enabled = false
  enabled = ${?DEV_MODE}
  hot-reload = false
  hot-reload = ${?HOT_RELOAD}
  watch-paths = []
  watch-debounce-ms = 100
}
```

**FileWatcher.scala:**
File change detection using java.nio.file.WatchService:
```scala
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import scala.jdk.CollectionConverters.*

case class FileChangeEvent(
  path: Path,
  kind: FileChangeKind,
  timestamp: Instant
)

enum FileChangeKind:
  case Created, Modified, Deleted

trait FileWatcher:
  def start: IO[FileWatchError, Unit]
  def stop: IO[FileWatchError, Unit]
  def events: ZStream[Any, FileWatchError, FileChangeEvent]

object FileWatcher:
  def live(
    watchPaths: List[Path],
    debounceMs: Int = 100
  ): ZLayer[Any, Nothing, FileWatcher] =
    ZLayer.fromZIO {
      for
        watchService <- ZIO.attemptBlocking(FileSystems.getDefault.newWatchService())
        eventQueue   <- Queue.unbounded[FileChangeEvent]
        running      <- Ref.make(false)
      yield new FileWatcher:
        def start: IO[FileWatchError, Unit] =
          for
            _ <- running.set(true)
            // Register all directories recursively
            _ <- ZIO.foreach(watchPaths)(registerRecursive(_, watchService))
            // Start watch loop in background
            _ <- watchLoop(watchService, eventQueue)
              .ensuring(ZIO.attemptBlocking(watchService.close()).ignore)
              .fork
          yield ()

        def stop: IO[FileWatchError, Unit] =
          running.set(false)

        def events: ZStream[Any, FileWatchError, FileChangeEvent] =
          ZStream.fromQueue(eventQueue)
            .groupedWithin(100, Duration.fromMillis(debounceMs))
            .map(_.distinctBy(_.path))  // Dedupe rapid changes
            .flatMap(ZStream.fromIterable)

        private def watchLoop(
          watcher: WatchService,
          queue: Queue[FileChangeEvent]
        ): IO[FileWatchError, Unit] =
          running.get.flatMap { isRunning =>
            if !isRunning then ZIO.unit
            else
              for
                key <- ZIO.attemptBlocking(watcher.poll(500, java.util.concurrent.TimeUnit.MILLISECONDS))
                  .mapError(FileWatchError.WatchFailed(_))
                _ <- ZIO.whenCase(key) {
                  case k if k != null =>
                    val events = k.pollEvents().asScala.toList
                    val dir = k.watchable().asInstanceOf[Path]
                    ZIO.foreach(events) { event =>
                      val kind = event.kind()
                      if kind != StandardWatchEventKinds.OVERFLOW then
                        val relativePath = event.context().asInstanceOf[Path]
                        val fullPath = dir.resolve(relativePath)
                        val changeKind = kind match
                          case StandardWatchEventKinds.ENTRY_CREATE => FileChangeKind.Created
                          case StandardWatchEventKinds.ENTRY_MODIFY => FileChangeKind.Modified
                          case StandardWatchEventKinds.ENTRY_DELETE => FileChangeKind.Deleted
                          case _ => FileChangeKind.Modified
                        queue.offer(FileChangeEvent(fullPath, changeKind, Instant.now))
                      else ZIO.unit
                    } *> ZIO.attemptBlocking(k.reset()).ignore
                }
                _ <- watchLoop(watcher, queue)
              yield ()
          }

        private def registerRecursive(path: Path, watcher: WatchService): IO[FileWatchError, Unit] =
          ZIO.attemptBlocking {
            if Files.isDirectory(path) then
              Files.walkFileTree(path, new SimpleFileVisitor[Path] {
                override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = {
                  dir.register(
                    watcher,
                    StandardWatchEventKinds.ENTRY_CREATE,
                    StandardWatchEventKinds.ENTRY_MODIFY,
                    StandardWatchEventKinds.ENTRY_DELETE
                  )
                  FileVisitResult.CONTINUE
                }
              })
          }.mapError(FileWatchError.RegistrationFailed(_))
    }

enum FileWatchError:
  case WatchFailed(cause: Throwable)
  case RegistrationFailed(cause: Throwable)
```

Key features:
- Recursive directory registration
- Event debouncing to handle rapid saves
- Deduplication of same-path events
- Non-blocking poll with timeout
- Graceful shutdown via running flag
  </action>
  <verify>
`./mill summercms.compile` succeeds
FileWatcher can be instantiated (manual test)
  </verify>
  <done>
FileWatcher uses JDK WatchService for OS-native file change detection. Supports recursive watching, debouncing, and deduplication.
  </done>
</task>

<task type="auto">
  <name>Task 2: Implement TemplateInvalidator and HotReloadService</name>
  <files>
    summercms/src/hot/TemplateInvalidator.scala
    summercms/src/hot/HotReloadService.scala
  </files>
  <action>
**TemplateInvalidator.scala:**
Targeted Pebble cache invalidation:
```scala
import io.pebbletemplates.pebble.PebbleEngine
import io.pebbletemplates.pebble.cache.PebbleCache

trait TemplateInvalidator:
  def invalidate(path: Path): UIO[Unit]
  def invalidateAll: UIO[Unit]

object TemplateInvalidator:
  def live(pebbleEngine: PebbleEngine, themePath: Path): ULayer[TemplateInvalidator] =
    ZLayer.succeed {
      new TemplateInvalidator:
        def invalidate(path: Path): UIO[Unit] =
          ZIO.attemptBlocking {
            // Convert path to template name
            val templateName = themePath.relativize(path).toString
            // Pebble's template cache is keyed by template name
            val cache = pebbleEngine.getTemplateCache
            cache.invalidate(templateName)

            // Also invalidate tag cache (for custom tags)
            pebbleEngine.getTagCache.invalidateAll()
          }.ignore

        def invalidateAll: UIO[Unit] =
          ZIO.attemptBlocking {
            pebbleEngine.getTemplateCache.invalidateAll()
            pebbleEngine.getTagCache.invalidateAll()
          }.ignore
    }
```

**HotReloadService.scala:**
Orchestrates hot reload on file changes:
```scala
trait HotReloadService:
  def start: IO[HotReloadError, Unit]
  def stop: IO[HotReloadError, Unit]
  def onFileChanged(event: FileChangeEvent): UIO[Unit]

case class HotReloadConfig(
  enabled: Boolean,
  watchPaths: List[Path],
  debounceMs: Int
)

object HotReloadService:
  def live(config: HotReloadConfig): ZLayer[
    FileWatcher & TemplateInvalidator & CmsRouter,
    Nothing,
    HotReloadService
  ] =
    ZLayer.fromZIO {
      for
        watcher     <- ZIO.service[FileWatcher]
        invalidator <- ZIO.service[TemplateInvalidator]
        cmsRouter   <- ZIO.service[CmsRouter]
        running     <- Ref.make(false)
      yield new HotReloadService:
        def start: IO[HotReloadError, Unit] =
          if !config.enabled then
            ZIO.logInfo("Hot reload disabled") *> ZIO.unit
          else
            for
              _ <- ZIO.logInfo(s"Starting hot reload for paths: ${config.watchPaths}")
              _ <- running.set(true)
              _ <- watcher.start.mapError(HotReloadError.WatcherFailed(_))
              // Process events in background
              _ <- watcher.events
                .tap(event => onFileChanged(event))
                .runDrain
                .fork
            yield ()

        def stop: IO[HotReloadError, Unit] =
          for
            _ <- running.set(false)
            _ <- watcher.stop.mapError(HotReloadError.WatcherFailed(_))
            _ <- ZIO.logInfo("Hot reload stopped")
          yield ()

        def onFileChanged(event: FileChangeEvent): UIO[Unit] =
          val path = event.path
          val extension = path.getFileName.toString.split('.').lastOption.getOrElse("")

          extension match
            case "htm" | "html" =>
              // Template file changed
              for
                _ <- ZIO.logDebug(s"Template changed: $path")
                _ <- invalidator.invalidate(path)
                // Rebuild router if page URL might have changed
                _ <- ZIO.when(path.toString.contains("/pages/")) {
                  cmsRouter.rebuild.ignore
                }
              yield ()

            case "css" | "js" =>
              // Asset changed - just log, browser handles via Vite
              ZIO.logDebug(s"Asset changed: $path")

            case "yaml" | "yml" =>
              // Config changed - may need full invalidation
              for
                _ <- ZIO.logDebug(s"Config changed: $path")
                _ <- invalidator.invalidateAll
              yield ()

            case _ =>
              ZIO.logDebug(s"Ignored file change: $path")

enum HotReloadError:
  case WatcherFailed(cause: FileWatchError)
  case InvalidationFailed(cause: Throwable)
```

Key decisions (per CONTEXT.md Claude's discretion):
- Template changes: targeted cache invalidation
- Page changes: also rebuild router (URL might have changed)
- YAML changes: full cache invalidation
- Asset changes: logged only (Vite handles HMR)
- Debounce: 100ms default to handle editor save-on-type
  </action>
  <verify>
`./mill summercms.compile` succeeds
HotReloadService methods have correct signatures
  </verify>
  <done>
TemplateInvalidator provides targeted Pebble cache invalidation. HotReloadService orchestrates reload by processing file events and invalidating appropriate caches.
  </done>
</task>

<task type="auto">
  <name>Task 3: Integrate hot reload with application startup</name>
  <files>
    summercms/src/Main.scala
    summercms/resources/application.conf
  </files>
  <action>
**Update Main.scala:**
Conditionally start hot reload in development mode:
```scala
object Main extends ZIOAppDefault:
  override def run: ZIO[Any, Throwable, Unit] =
    for
      _ <- printBanner
      config <- ZIO.service[AppConfig]
      _ <- ZIO.logInfo(s"Starting SummerCMS on port ${config.server.port}")

      // Initialize Pebble engine
      pebbleEngine <- buildPebbleEngine(config)

      // Start hot reload if enabled
      _ <- ZIO.when(config.dev.enabled && config.dev.hotReload) {
        for
          _ <- ZIO.logInfo("Development mode: Hot reload enabled")
          themePath <- ZIO.succeed(Path.of(config.theme.path))
          watchPaths = themePath :: config.dev.watchPaths.map(Path.of)

          hotReloadConfig = HotReloadConfig(
            enabled = true,
            watchPaths = watchPaths,
            debounceMs = config.dev.watchDebounceMs
          )

          // Build hot reload layers
          fileWatcherLayer = FileWatcher.live(watchPaths, config.dev.watchDebounceMs)
          invalidatorLayer = TemplateInvalidator.live(pebbleEngine, themePath)
          hotReloadLayer = HotReloadService.live(hotReloadConfig)

          // Start hot reload service
          _ <- ZIO.serviceWithZIO[HotReloadService](_.start)
            .provide(
              fileWatcherLayer,
              invalidatorLayer,
              hotReloadLayer,
              CmsRouter.live(themePath)
            )
        yield ()
      }

      // Start HTTP server
      _ <- startServer(config)
    yield ()
    .provide(
      AppConfig.layer,
      // ... other layers
    )

  private def buildPebbleEngine(config: AppConfig): UIO[PebbleEngine] =
    ZIO.succeed {
      new PebbleEngine.Builder()
        .loader(new FileLoader())
        .cacheActive(!config.dev.hotReload)  // Disable cache in hot reload mode
        .strictVariables(config.dev.enabled)  // Strict mode in dev
        .extension(new CmsPebbleExtension(...))
        .build()
    }
```

**Update application.conf:**
Add theme path configuration:
```hocon
theme {
  path = "themes/default"
  path = ${?THEME_PATH}
}

dev {
  enabled = false
  enabled = ${?DEV_MODE}
  hot-reload = false
  hot-reload = ${?HOT_RELOAD}
  watch-paths = []
  watch-debounce-ms = 100
}
```

**Development startup script (optional):**
Create scripts/dev.sh:
```bash
#!/bin/bash
export DEV_MODE=true
export HOT_RELOAD=true
./mill -w summercms.run
```

Note: Mill's --watch mode (-w) restarts on Scala code changes. Combined with our hot reload, this provides:
- Scala code changes: Mill restarts JVM
- Template changes: Hot reload invalidates cache (no restart)
- Asset changes: Vite HMR in browser

Production mode: All caching enabled, hot reload disabled, strict mode off.
  </action>
  <verify>
`./mill summercms.compile` succeeds
DEV_MODE=true ./mill summercms.run starts with hot reload logging
Modify template file -> see "Template changed" log message
  </verify>
  <done>
Hot reload integrated with application startup. Development mode enables file watching and cache invalidation. Production mode uses full caching.
  </done>
</task>

</tasks>

<verification>
After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Test development mode:
   - DEV_MODE=true HOT_RELOAD=true ./mill summercms.run
   - See "Hot reload enabled" in logs
3. Test file watching:
   - Modify themes/default/pages/home.htm
   - See "Template changed" log
   - Refresh browser -> see updated content immediately
4. Test cache invalidation:
   - Modify layout -> layout changes reflected
   - Modify partial -> partial changes reflected
   - Add new page -> router rebuilt, new URL accessible
5. Test production mode:
   - DEV_MODE=false ./mill summercms.run
   - No hot reload logs
   - Templates cached (verify via response time)
</verification>

<success_criteria>
- Hot reload only active when DEV_MODE=true and HOT_RELOAD=true
- Template file saved -> changes visible on next browser refresh (no restart)
- Targeted invalidation: only changed template reloaded, not all templates
- Page changes also trigger router rebuild for URL changes
- Production mode uses full caching for performance
- Event debouncing prevents rapid reload spam during fast typing
</success_criteria>

<output>
After completion, create `.planning/phases/09-content-management/09-06-SUMMARY.md`
</output>
