---
phase: 02-plugin-system
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - build.mill
  - summercms/src/plugin/PluginId.scala
  - summercms/src/plugin/PluginManifest.scala
  - summercms/src/plugin/PluginError.scala
  - summercms/src/plugin/PluginDiscovery.scala
  - plugins/.gitkeep
autonomous: true

must_haves:
  truths:
    - "Developer can write a plugin.yaml with vendor, name, version, dependencies"
    - "System discovers all plugin.yaml files in plugins/ directory"
    - "Invalid YAML manifests produce clear error messages"
    - "Dependency version constraints parse correctly (^1.2.0, >=1.0.0)"
  artifacts:
    - path: "summercms/src/plugin/PluginManifest.scala"
      provides: "Plugin manifest case class with circe derivation"
      contains: "case class PluginManifest"
    - path: "summercms/src/plugin/PluginDiscovery.scala"
      provides: "Service to discover plugins from filesystem"
      exports: ["PluginDiscovery", "DiscoveredPlugin"]
    - path: "summercms/src/plugin/PluginId.scala"
      provides: "Plugin identifier (vendor.name)"
      contains: "case class PluginId"
    - path: "summercms/src/plugin/PluginError.scala"
      provides: "Plugin error ADT"
      contains: "enum PluginError"
  key_links:
    - from: "summercms/src/plugin/PluginDiscovery.scala"
      to: "summercms/src/plugin/PluginManifest.scala"
      via: "parseManifest"
      pattern: "parser\\.parse.*as\\[PluginManifest\\]"
    - from: "build.mill"
      to: "circe-yaml"
      via: "mvnDeps"
      pattern: "circe-yaml"
---

<objective>
Create the plugin discovery and manifest parsing foundation for SummerCMS.

Purpose: All plugins need manifests parsed from YAML. Discovery finds plugins automatically. This is the foundation that lifecycle management (02-02) builds upon.

Output: PluginManifest case class, PluginDiscovery service, PluginId/PluginError types, dependencies added to build.mill
</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/STATE.md
@.planning/phases/02-plugin-system/02-RESEARCH.md
@.planning/phases/02-plugin-system/02-CONTEXT.md
@build.mill
@summercms/src/Main.scala
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add YAML and version parsing dependencies</name>
  <files>build.mill</files>
  <action>
Add the following dependencies to build.mill mvnDeps:

```scala
// YAML parsing for plugin manifests
mvn"io.circe::circe-yaml:0.16.1",
mvn"io.circe::circe-generic:0.14.10",
mvn"io.circe::circe-parser:0.14.10",

// Semver version constraints
mvn"com.github.sh4869::semver-parser-scala:0.0.4",
```

Add after the existing Flyway dependencies.

Note: circe-parser is needed alongside circe-yaml for JSON operations.
  </action>
  <verify>Run `./mill summercms.compile` - should succeed with new dependencies resolved</verify>
  <done>build.mill contains circe-yaml, circe-generic, circe-parser, and semver-parser-scala dependencies</done>
</task>

<task type="auto">
  <name>Task 2: Create plugin type foundations</name>
  <files>
    summercms/src/plugin/PluginId.scala
    summercms/src/plugin/PluginError.scala
  </files>
  <action>
Create `summercms/src/plugin/` directory with these files:

**PluginId.scala:**
```scala
package plugin

/** Plugin identifier combining vendor and name */
case class PluginId(vendor: String, name: String):
  /** Full identifier string (vendor.name) */
  def fullId: String = s"$vendor.$name"

  override def toString: String = fullId

object PluginId:
  /** Parse "vendor.name" string into PluginId */
  def parse(s: String): Either[String, PluginId] =
    s.split('.').toList match
      case vendor :: name :: Nil if vendor.nonEmpty && name.nonEmpty =>
        Right(PluginId(vendor, name))
      case _ =>
        Left(s"Invalid plugin ID '$s': expected 'vendor.name' format")

  /** Unsafe parse - throws on invalid format */
  def unsafeParse(s: String): PluginId =
    parse(s).getOrElse(throw new IllegalArgumentException(s"Invalid plugin ID: $s"))
```

**PluginError.scala:**
```scala
package plugin

import java.nio.file.Path

/** Plugin system error ADT */
enum PluginError:
  case ManifestNotFound(path: Path)
  case ManifestParseError(path: Path, message: String)
  case InvalidPluginId(raw: String, reason: String)
  case DependencyNotFound(plugin: PluginId, dependency: String)
  case VersionMismatch(plugin: PluginId, dependency: String, required: String, actual: String)
  case CyclicDependency(plugins: Set[PluginId])
  case BootError(plugin: PluginId, cause: Throwable)
  case ShutdownError(plugin: PluginId, cause: Throwable)

  def message: String = this match
    case ManifestNotFound(p) => s"Plugin manifest not found: $p"
    case ManifestParseError(p, m) => s"Failed to parse manifest at $p: $m"
    case InvalidPluginId(r, reason) => s"Invalid plugin ID '$r': $reason"
    case DependencyNotFound(p, d) => s"Plugin ${p.fullId} requires $d which is not installed"
    case VersionMismatch(p, d, req, act) => s"Plugin ${p.fullId} requires $d $req but found $act"
    case CyclicDependency(ps) => s"Cyclic dependency detected: ${ps.map(_.fullId).mkString(" -> ")}"
    case BootError(p, c) => s"Plugin ${p.fullId} failed to boot: ${c.getMessage}"
    case ShutdownError(p, c) => s"Plugin ${p.fullId} failed to shutdown: ${c.getMessage}"
```
  </action>
  <verify>Run `./mill summercms.compile` - plugin package compiles without errors</verify>
  <done>PluginId case class with parse methods, PluginError ADT with all error cases</done>
</task>

<task type="auto">
  <name>Task 3: Create manifest parsing and discovery service</name>
  <files>
    summercms/src/plugin/PluginManifest.scala
    summercms/src/plugin/PluginDiscovery.scala
    plugins/.gitkeep
  </files>
  <action>
**PluginManifest.scala:**
```scala
package plugin

import io.circe.*
import io.circe.generic.auto.*
import io.circe.yaml.parser
import java.nio.file.Path

/** Plugin manifest parsed from plugin.yaml */
case class PluginManifest(
  vendor: String,
  name: String,
  version: String,
  description: Option[String] = None,
  author: Option[String] = None,
  homepage: Option[String] = None,
  license: Option[String] = None,
  dependencies: Map[String, String] = Map.empty,      // "vendor.name" -> "^1.2.0"
  optionalDependencies: Map[String, String] = Map.empty,
  priority: Int = 0,                                   // Higher = boots first among peers
  enabled: Boolean = true
):
  /** Derived plugin ID */
  def id: PluginId = PluginId(vendor, name)

object PluginManifest:
  /** Parse YAML string into PluginManifest */
  def parse(yaml: String): Either[PluginError, PluginManifest] =
    parser.parse(yaml)
      .flatMap(_.as[PluginManifest])
      .left.map(e => PluginError.ManifestParseError(
        java.nio.file.Path.of("unknown"),
        e.getMessage
      ))

  /** Parse YAML file at path */
  def parseFile(path: Path, content: String): Either[PluginError, PluginManifest] =
    parser.parse(content)
      .flatMap(_.as[PluginManifest])
      .left.map(e => PluginError.ManifestParseError(path, e.getMessage))
```

**PluginDiscovery.scala:**
```scala
package plugin

import zio.*
import java.nio.file.{Files, Path, Paths}
import scala.jdk.CollectionConverters.*

/** A discovered plugin with its manifest and location */
case class DiscoveredPlugin(
  directory: Path,
  manifest: PluginManifest
):
  def id: PluginId = manifest.id

/** Service for discovering plugins from filesystem */
trait PluginDiscovery:
  /** Discover all plugins in the given directory */
  def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]]

object PluginDiscovery:
  /** Default plugins directory */
  val defaultPluginsDir: Path = Paths.get("plugins")

  /** Access the discovery service */
  def discover(pluginsDir: Path): ZIO[PluginDiscovery, PluginError, List[DiscoveredPlugin]] =
    ZIO.serviceWithZIO[PluginDiscovery](_.discover(pluginsDir))

  /** Live implementation that scans filesystem */
  val live: ULayer[PluginDiscovery] = ZLayer.succeed {
    new PluginDiscovery:
      def discover(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] =
        for
          exists <- ZIO.attemptBlocking(Files.exists(pluginsDir))
            .orDie
          plugins <- if !exists then ZIO.succeed(List.empty)
            else findAndParseManifests(pluginsDir)
        yield plugins

      private def findAndParseManifests(pluginsDir: Path): IO[PluginError, List[DiscoveredPlugin]] =
        ZIO.attemptBlocking {
          // Walk up to 3 levels: plugins/vendor/name/plugin.yaml
          Files.walk(pluginsDir, 3).iterator.asScala
            .filter(p => p.getFileName.toString == "plugin.yaml")
            .toList
        }.orDie.flatMap { manifestPaths =>
          ZIO.foreach(manifestPaths) { path =>
            for
              content <- ZIO.attemptBlocking(Files.readString(path))
                .mapError(_ => PluginError.ManifestNotFound(path))
              manifest <- ZIO.fromEither(PluginManifest.parseFile(path, content))
            yield DiscoveredPlugin(path.getParent, manifest)
          }
        }
  }
```

**Create plugins directory:**
Create `plugins/.gitkeep` (empty file) to ensure the plugins directory exists in git.
  </action>
  <verify>
Run `./mill summercms.compile` - all plugin files compile.

Create a test manifest at `plugins/test/sample/plugin.yaml`:
```yaml
vendor: test
name: sample
version: 1.0.0
description: Test plugin
dependencies:
  golem15.user: "^1.0.0"
```

Then test discovery works by running this command in the Mill REPL:
```bash
./mill -i summercms.console
```

Once in the REPL, verify parsing:
```scala
import plugin._
val yaml = """
vendor: test
name: sample
version: 1.0.0
description: Test plugin
"""
val result = PluginManifest.parse(yaml)
println(result) // Should print Right(PluginManifest(test,sample,1.0.0,...))
```

Exit REPL with `:quit` after verification.
  </verify>
  <done>PluginManifest parses YAML manifests, PluginDiscovery scans plugins/ directory, plugins/ directory exists</done>
</task>

</tasks>

<verification>
1. `./mill summercms.compile` succeeds
2. Dependencies include circe-yaml:0.16.1, circe-generic:0.14.10, semver-parser-scala:0.0.4
3. Plugin directory structure exists: `plugins/`
4. All plugin types compile: PluginId, PluginError, PluginManifest, PluginDiscovery
</verification>

<success_criteria>
- build.mill has all new dependencies
- PluginId.parse("golem15.blog") returns Right(PluginId("golem15", "blog"))
- PluginManifest.parse(validYaml) returns Right(manifest)
- PluginDiscovery.live provides working service
- plugins/ directory exists with .gitkeep
</success_criteria>

<output>
After completion, create `.planning/phases/02-plugin-system/02-01-SUMMARY.md`
</output>
