docs(02): create phase plan
Phase 02: Plugin System - 3 plans in 3 waves (sequential) - 02-01: Plugin discovery and manifest parsing - 02-02: Lifecycle management and dependency resolution - 02-03: Extension API with type-safe plugin-to-plugin communication - Ready for execution
This commit is contained in:
510
.planning/phases/02-plugin-system/02-03-PLAN.md
Normal file
510
.planning/phases/02-plugin-system/02-03-PLAN.md
Normal file
@@ -0,0 +1,510 @@
|
||||
---
|
||||
phase: 02-plugin-system
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["02-02"]
|
||||
files_modified:
|
||||
- summercms/src/plugin/EventService.scala
|
||||
- summercms/src/plugin/ExtensionRegistry.scala
|
||||
- summercms/src/plugin/SummerEvent.scala
|
||||
- summercms/src/plugin/package.scala
|
||||
- summercms/src/Main.scala
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Plugins can publish events to a central hub"
|
||||
- "Plugins can subscribe to events from other plugins"
|
||||
- "Plugins can register type-safe extensions for other plugins"
|
||||
- "Extensions are discoverable by target plugin at boot time"
|
||||
- "Plugin system integrates with Main.scala and boots on application start"
|
||||
artifacts:
|
||||
- path: "summercms/src/plugin/EventService.scala"
|
||||
provides: "ZIO Hub-based pub/sub event system"
|
||||
exports: ["EventService"]
|
||||
- path: "summercms/src/plugin/ExtensionRegistry.scala"
|
||||
provides: "Type-safe extension registration"
|
||||
exports: ["ExtensionRegistry"]
|
||||
- path: "summercms/src/plugin/SummerEvent.scala"
|
||||
provides: "Base event trait and lifecycle events"
|
||||
contains: "sealed trait SummerEvent"
|
||||
- path: "summercms/src/plugin/package.scala"
|
||||
provides: "Plugin package exports and type aliases"
|
||||
contains: "type PluginLayer"
|
||||
key_links:
|
||||
- from: "summercms/src/Main.scala"
|
||||
to: "summercms/src/plugin/PluginManager.scala"
|
||||
via: "loads and boots plugins on startup"
|
||||
pattern: "PluginManager\\.(loadPlugins|bootAll)"
|
||||
- from: "summercms/src/plugin/ExtensionRegistry.scala"
|
||||
to: "summercms/src/plugin/PluginManager.scala"
|
||||
via: "extensions registered during plugin boot"
|
||||
pattern: "ExtensionRegistry\\.register"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the extension API with event system and type-safe extension registry for SummerCMS.
|
||||
|
||||
Purpose: Plugins need to communicate loosely via events and extend each other type-safely. This completes the plugin system, making it ready for components (Phase 3) and themes (Phase 4) to build upon.
|
||||
|
||||
Output: EventService (Hub-based), ExtensionRegistry, SummerEvent trait, plugin layer integration with Main.scala
|
||||
</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
|
||||
@.planning/phases/02-plugin-system/02-01-SUMMARY.md
|
||||
@.planning/phases/02-plugin-system/02-02-SUMMARY.md
|
||||
@summercms/src/plugin/SummerPlugin.scala
|
||||
@summercms/src/plugin/PluginManager.scala
|
||||
@summercms/src/Main.scala
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create event system with ZIO Hub</name>
|
||||
<files>
|
||||
summercms/src/plugin/SummerEvent.scala
|
||||
summercms/src/plugin/EventService.scala
|
||||
</files>
|
||||
<action>
|
||||
**SummerEvent.scala:**
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/** Base trait for all SummerCMS events */
|
||||
sealed trait SummerEvent:
|
||||
def timestamp: Instant = Instant.now()
|
||||
|
||||
object SummerEvent:
|
||||
// ===== Plugin Lifecycle Events =====
|
||||
|
||||
/** Emitted when a plugin completes boot successfully */
|
||||
case class PluginBooted(id: PluginId) extends SummerEvent
|
||||
|
||||
/** Emitted when a plugin fails to boot */
|
||||
case class PluginBootFailed(id: PluginId, error: PluginError) extends SummerEvent
|
||||
|
||||
/** Emitted when a plugin begins shutdown */
|
||||
case class PluginShuttingDown(id: PluginId) extends SummerEvent
|
||||
|
||||
/** Emitted when a plugin completes shutdown */
|
||||
case class PluginStopped(id: PluginId) extends SummerEvent
|
||||
|
||||
// ===== Domain Events (plugins define their own) =====
|
||||
|
||||
/** Marker trait for domain-specific events from plugins */
|
||||
trait DomainEvent extends SummerEvent
|
||||
|
||||
/** Example: User events (will be used by User plugin in Phase 10) */
|
||||
trait UserEvent extends DomainEvent
|
||||
case class UserCreated(userId: Long, email: String) extends UserEvent
|
||||
case class UserLoggedIn(userId: Long) extends UserEvent
|
||||
case class UserLoggedOut(userId: Long) extends UserEvent
|
||||
|
||||
/** Example: Content events (will be used by CMS in Phase 9) */
|
||||
trait ContentEvent extends DomainEvent
|
||||
case class PageCreated(pageId: Long, slug: String) extends ContentEvent
|
||||
case class PagePublished(pageId: Long) extends ContentEvent
|
||||
```
|
||||
|
||||
**EventService.scala:**
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
import zio.*
|
||||
import scala.reflect.ClassTag
|
||||
|
||||
/** Pub/sub event service using ZIO Hub */
|
||||
trait EventService:
|
||||
/** Publish an event to all subscribers */
|
||||
def publish[E <: SummerEvent](event: E): UIO[Boolean]
|
||||
|
||||
/** Subscribe to all events - returns Dequeue in a Scope */
|
||||
def subscribe: ZIO[Scope, Nothing, Dequeue[SummerEvent]]
|
||||
|
||||
/** Subscribe to events of a specific type */
|
||||
def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[Scope, Nothing, Dequeue[E]]
|
||||
|
||||
/** Subscribe with a handler function that processes events */
|
||||
def subscribeWith[E <: SummerEvent: ClassTag](
|
||||
handler: E => UIO[Unit]
|
||||
): ZIO[Scope, Nothing, Fiber.Runtime[Nothing, Nothing]]
|
||||
|
||||
object EventService:
|
||||
/** Publish an event */
|
||||
def publish[E <: SummerEvent](event: E): ZIO[EventService, Nothing, Boolean] =
|
||||
ZIO.serviceWithZIO[EventService](_.publish(event))
|
||||
|
||||
/** Subscribe to all events */
|
||||
def subscribe: ZIO[EventService & Scope, Nothing, Dequeue[SummerEvent]] =
|
||||
ZIO.serviceWithZIO[EventService](_.subscribe)
|
||||
|
||||
/** Subscribe to specific event type */
|
||||
def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[EventService & Scope, Nothing, Dequeue[E]] =
|
||||
ZIO.serviceWithZIO[EventService](_.subscribeFiltered[E])
|
||||
|
||||
/** Live implementation with bounded Hub */
|
||||
val live: ULayer[EventService] = ZLayer.scoped {
|
||||
Hub.bounded[SummerEvent](256).map { hub =>
|
||||
new EventService:
|
||||
def publish[E <: SummerEvent](event: E): UIO[Boolean] =
|
||||
hub.publish(event)
|
||||
|
||||
def subscribe: ZIO[Scope, Nothing, Dequeue[SummerEvent]] =
|
||||
hub.subscribe
|
||||
|
||||
def subscribeFiltered[E <: SummerEvent: ClassTag]: ZIO[Scope, Nothing, Dequeue[E]] =
|
||||
hub.subscribe.map { dequeue =>
|
||||
new Dequeue[E]:
|
||||
def take: UIO[E] =
|
||||
dequeue.take.flatMap {
|
||||
case e: E => ZIO.succeed(e)
|
||||
case _ => take // Skip non-matching events
|
||||
}
|
||||
|
||||
def takeAll: UIO[Chunk[E]] =
|
||||
dequeue.takeAll.map(_.collect { case e: E => e })
|
||||
|
||||
def takeUpTo(max: Int): UIO[Chunk[E]] =
|
||||
dequeue.takeUpTo(max).map(_.collect { case e: E => e })
|
||||
|
||||
def poll: UIO[Option[E]] =
|
||||
dequeue.poll.map(_.collect { case e: E => e })
|
||||
|
||||
def size: UIO[Int] = dequeue.size
|
||||
def capacity: Int = dequeue.capacity
|
||||
def isShutdown: UIO[Boolean] = dequeue.isShutdown
|
||||
def shutdown: UIO[Unit] = dequeue.shutdown
|
||||
def awaitShutdown: UIO[Unit] = dequeue.awaitShutdown
|
||||
}
|
||||
|
||||
def subscribeWith[E <: SummerEvent: ClassTag](
|
||||
handler: E => UIO[Unit]
|
||||
): ZIO[Scope, Nothing, Fiber.Runtime[Nothing, Nothing]] =
|
||||
subscribeFiltered[E].flatMap { queue =>
|
||||
queue.take.flatMap(handler).forever.fork
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - event system compiles. EventService.live provides a Hub-based pub/sub system.</verify>
|
||||
<done>SummerEvent trait with lifecycle and domain events, EventService with publish/subscribe via ZIO Hub</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create extension registry</name>
|
||||
<files>summercms/src/plugin/ExtensionRegistry.scala</files>
|
||||
<action>
|
||||
**ExtensionRegistry.scala:**
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
import zio.*
|
||||
import scala.reflect.ClassTag
|
||||
|
||||
/**
|
||||
* Registry for type-safe plugin extensions.
|
||||
*
|
||||
* Plugins can register extensions for other plugins:
|
||||
* - SEO plugin registers BlogExtension for Blog plugin
|
||||
* - Blog plugin queries registry for all BlogExtension implementations
|
||||
*
|
||||
* Extensions are keyed by trait type (ClassTag) and registered by plugin ID.
|
||||
*/
|
||||
trait ExtensionRegistry:
|
||||
/** Register an extension from a plugin */
|
||||
def register[E: ClassTag](pluginId: PluginId, extension: E): UIO[Unit]
|
||||
|
||||
/** Get all extensions of a type */
|
||||
def getExtensions[E: ClassTag]: UIO[List[(PluginId, E)]]
|
||||
|
||||
/** Get extension from a specific plugin */
|
||||
def getExtension[E: ClassTag](pluginId: PluginId): UIO[Option[E]]
|
||||
|
||||
/** Check if any extensions exist for a type */
|
||||
def hasExtensions[E: ClassTag]: UIO[Boolean]
|
||||
|
||||
/** Remove all extensions from a plugin (used during unload/reload) */
|
||||
def removePlugin(pluginId: PluginId): UIO[Unit]
|
||||
|
||||
object ExtensionRegistry:
|
||||
/** Register an extension */
|
||||
def register[E: ClassTag](pluginId: PluginId, extension: E): ZIO[ExtensionRegistry, Nothing, Unit] =
|
||||
ZIO.serviceWithZIO[ExtensionRegistry](_.register(pluginId, extension))
|
||||
|
||||
/** Get all extensions of a type */
|
||||
def getExtensions[E: ClassTag]: ZIO[ExtensionRegistry, Nothing, List[(PluginId, E)]] =
|
||||
ZIO.serviceWithZIO[ExtensionRegistry](_.getExtensions[E])
|
||||
|
||||
/** Get extension from a specific plugin */
|
||||
def getExtension[E: ClassTag](pluginId: PluginId): ZIO[ExtensionRegistry, Nothing, Option[E]] =
|
||||
ZIO.serviceWithZIO[ExtensionRegistry](_.getExtension[E](pluginId))
|
||||
|
||||
/** Live implementation using Ref */
|
||||
val live: ULayer[ExtensionRegistry] = ZLayer.fromZIO {
|
||||
// Map from extension type key -> Map from plugin ID -> extension instance
|
||||
Ref.make(Map.empty[String, Map[PluginId, Any]]).map { registry =>
|
||||
new ExtensionRegistry:
|
||||
private def typeKey[E: ClassTag]: String =
|
||||
summon[ClassTag[E]].runtimeClass.getName
|
||||
|
||||
def register[E: ClassTag](pluginId: PluginId, extension: E): UIO[Unit] =
|
||||
registry.update { map =>
|
||||
val key = typeKey[E]
|
||||
val existing = map.getOrElse(key, Map.empty)
|
||||
map.updated(key, existing + (pluginId -> extension))
|
||||
}
|
||||
|
||||
def getExtensions[E: ClassTag]: UIO[List[(PluginId, E)]] =
|
||||
registry.get.map { map =>
|
||||
map.getOrElse(typeKey[E], Map.empty)
|
||||
.toList
|
||||
.map { case (id, ext) => (id, ext.asInstanceOf[E]) }
|
||||
}
|
||||
|
||||
def getExtension[E: ClassTag](pluginId: PluginId): UIO[Option[E]] =
|
||||
registry.get.map { map =>
|
||||
map.getOrElse(typeKey[E], Map.empty)
|
||||
.get(pluginId)
|
||||
.map(_.asInstanceOf[E])
|
||||
}
|
||||
|
||||
def hasExtensions[E: ClassTag]: UIO[Boolean] =
|
||||
registry.get.map { map =>
|
||||
map.getOrElse(typeKey[E], Map.empty).nonEmpty
|
||||
}
|
||||
|
||||
def removePlugin(pluginId: PluginId): UIO[Unit] =
|
||||
registry.update { map =>
|
||||
map.view.mapValues(_ - pluginId).toMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example extension trait that plugins can define.
|
||||
* Other plugins implement this to extend the defining plugin.
|
||||
*
|
||||
* Example: Blog plugin defines BlogExtension, SEO plugin implements it.
|
||||
*/
|
||||
trait BlogExtension:
|
||||
/** Additional fields to add to post forms */
|
||||
def extendPostFields: List[FieldDef]
|
||||
|
||||
/** Transform post data before save */
|
||||
def beforeSave(data: Map[String, Any]): Map[String, Any] = data
|
||||
|
||||
/** Validate post data */
|
||||
def validate(data: Map[String, Any]): Either[String, Unit] = Right(())
|
||||
|
||||
// FieldDef is already defined in PluginRegistration.scala, reuse it
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - ExtensionRegistry compiles. It provides type-safe extension registration keyed by ClassTag.</verify>
|
||||
<done>ExtensionRegistry.live provides type-safe extension storage with register/get/remove operations</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create plugin package exports and integrate with Main</name>
|
||||
<files>
|
||||
summercms/src/plugin/package.scala
|
||||
summercms/src/Main.scala
|
||||
</files>
|
||||
<action>
|
||||
**package.scala:**
|
||||
```scala
|
||||
package object plugin:
|
||||
/** Combined layer for all plugin services */
|
||||
type PluginServices = PluginManager & PluginDiscovery & EventService & ExtensionRegistry
|
||||
|
||||
/** Create all plugin service layers */
|
||||
val pluginLayer: zio.ZLayer[Any, Nothing, PluginServices] =
|
||||
PluginDiscovery.live >>>
|
||||
(PluginManager.live ++ EventService.live ++ ExtensionRegistry.live)
|
||||
|
||||
/** Environment provided to plugins during boot - includes all services */
|
||||
type PluginBootEnv = PluginContext & EventService & ExtensionRegistry
|
||||
```
|
||||
|
||||
**Update Main.scala** to integrate the plugin system:
|
||||
```scala
|
||||
import zio.*
|
||||
import zio.http.*
|
||||
import zio.config.typesafe.TypesafeConfigProvider
|
||||
|
||||
import api.Routes
|
||||
import _root_.config.{AppConfig as SummerConfig}
|
||||
import db.QuillContext
|
||||
import plugin.{PluginManager, PluginDiscovery, pluginLayer}
|
||||
|
||||
object Main extends ZIOAppDefault {
|
||||
|
||||
private val banner: String =
|
||||
"""
|
||||
|
|
||||
| | .
|
||||
| `. * | .'
|
||||
| `. ._|_* .' .
|
||||
| . * .' `. *
|
||||
| -------| |-------
|
||||
| . *`.___.' * .
|
||||
| .' |* `. *
|
||||
| .' * | . `.
|
||||
| . |
|
||||
|
|
||||
| S U M M E R C M S
|
||||
|""".stripMargin
|
||||
|
||||
override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] =
|
||||
Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath())
|
||||
|
||||
override def run: ZIO[Any, Any, Any] =
|
||||
for {
|
||||
cfg <- ZIO.config[SummerConfig](SummerConfig.config)
|
||||
_ <- Console.printLine(banner)
|
||||
_ <- Console.printLine(s"Starting on port ${cfg.server.port}...")
|
||||
_ <- Console.printLine("")
|
||||
// Initialize plugin system
|
||||
_ <- Console.printLine("Loading plugins...")
|
||||
_ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir)
|
||||
.catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(()))
|
||||
_ <- PluginManager.bootAll
|
||||
.catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(()))
|
||||
plugins <- PluginManager.listPlugins
|
||||
_ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)")
|
||||
_ <- Console.printLine("")
|
||||
// Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5).
|
||||
_ <- Server.serve(Routes.routes).provide(
|
||||
Server.defaultWithPort(cfg.server.port),
|
||||
QuillContext.dataSourceLayer
|
||||
)
|
||||
} yield ()
|
||||
|
||||
// Provide plugin layers
|
||||
override def run: ZIO[Any, Any, Any] =
|
||||
(for {
|
||||
cfg <- ZIO.config[SummerConfig](SummerConfig.config)
|
||||
_ <- Console.printLine(banner)
|
||||
_ <- Console.printLine(s"Starting on port ${cfg.server.port}...")
|
||||
_ <- Console.printLine("")
|
||||
// Initialize plugin system
|
||||
_ <- Console.printLine("Loading plugins...")
|
||||
_ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir)
|
||||
.catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(()))
|
||||
_ <- PluginManager.bootAll
|
||||
.catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(()))
|
||||
plugins <- PluginManager.listPlugins
|
||||
_ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)")
|
||||
_ <- Console.printLine("")
|
||||
// Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5).
|
||||
_ <- Server.serve(Routes.routes).provide(
|
||||
Server.defaultWithPort(cfg.server.port),
|
||||
QuillContext.dataSourceLayer
|
||||
)
|
||||
} yield ()).provide(pluginLayer)
|
||||
```
|
||||
|
||||
Wait - the above has a duplicate `def run`. Let me provide the correct version:
|
||||
|
||||
**Main.scala (corrected full file):**
|
||||
```scala
|
||||
import zio.*
|
||||
import zio.http.*
|
||||
import zio.config.typesafe.TypesafeConfigProvider
|
||||
|
||||
import api.Routes
|
||||
import _root_.config.{AppConfig as SummerConfig}
|
||||
import db.QuillContext
|
||||
import plugin.{PluginManager, PluginDiscovery, pluginLayer}
|
||||
|
||||
object Main extends ZIOAppDefault {
|
||||
|
||||
private val banner: String =
|
||||
"""
|
||||
|
|
||||
| | .
|
||||
| `. * | .'
|
||||
| `. ._|_* .' .
|
||||
| . * .' `. *
|
||||
| -------| |-------
|
||||
| . *`.___.' * .
|
||||
| .' |* `. *
|
||||
| .' * | . `.
|
||||
| . |
|
||||
|
|
||||
| S U M M E R C M S
|
||||
|""".stripMargin
|
||||
|
||||
override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] =
|
||||
Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath())
|
||||
|
||||
private val startupLogic: ZIO[PluginManager & PluginDiscovery, Any, Nothing] =
|
||||
for {
|
||||
cfg <- ZIO.config[SummerConfig](SummerConfig.config)
|
||||
_ <- Console.printLine(banner)
|
||||
_ <- Console.printLine(s"Starting on port ${cfg.server.port}...")
|
||||
_ <- Console.printLine("")
|
||||
// Initialize plugin system
|
||||
_ <- Console.printLine("Loading plugins...")
|
||||
_ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir)
|
||||
.catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(()))
|
||||
_ <- PluginManager.bootAll
|
||||
.catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(()))
|
||||
plugins <- PluginManager.listPlugins
|
||||
_ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)")
|
||||
_ <- Console.printLine("")
|
||||
// Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5).
|
||||
res <- Server.serve(Routes.routes).provide(
|
||||
Server.defaultWithPort(cfg.server.port),
|
||||
QuillContext.dataSourceLayer
|
||||
)
|
||||
} yield res
|
||||
|
||||
override def run: ZIO[Any, Any, Any] =
|
||||
startupLogic.provide(pluginLayer)
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
Run `./mill summercms.compile` - Main.scala compiles with plugin integration.
|
||||
Run `./mill summercms.run` - Server starts, logs "Loading plugins..." and "Loaded 0 plugin(s)" (since no plugins exist yet).
|
||||
</verify>
|
||||
<done>Plugin package exports combined layer, Main.scala loads and boots plugins on startup</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `./mill summercms.compile` succeeds
|
||||
2. EventService.live provides Hub-based pub/sub
|
||||
3. ExtensionRegistry.live provides type-safe extension storage
|
||||
4. pluginLayer combines all services
|
||||
5. Main.scala boots the application with plugin system initialization
|
||||
6. Running `./mill summercms.run` shows "Loaded 0 plugin(s)" (no plugins installed)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Events can be published and subscribed to via EventService
|
||||
- Extensions can be registered and retrieved by type via ExtensionRegistry
|
||||
- Application startup loads and boots plugins automatically
|
||||
- System works with zero plugins (graceful empty state)
|
||||
- Plugin system is ready for Component System (Phase 3) to build upon
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-plugin-system/02-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user