@smthrs/plugin 0.0.0-stage → 1.0.0-rc.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/dist/cjs/Config.d.ts +129 -0
  5. package/dist/cjs/Config.d.ts.map +1 -0
  6. package/dist/cjs/Config.js +115 -0
  7. package/dist/cjs/Config.js.map +7 -0
  8. package/dist/cjs/Hooks.d.ts +176 -0
  9. package/dist/cjs/Hooks.d.ts.map +1 -0
  10. package/dist/cjs/Hooks.js +34 -0
  11. package/dist/cjs/Hooks.js.map +7 -0
  12. package/dist/cjs/Kernel.d.ts +81 -0
  13. package/dist/cjs/Kernel.d.ts.map +1 -0
  14. package/dist/cjs/Kernel.js +57 -0
  15. package/dist/cjs/Kernel.js.map +7 -0
  16. package/dist/cjs/Plugin.d.ts +71 -0
  17. package/dist/cjs/Plugin.d.ts.map +1 -0
  18. package/dist/cjs/Plugin.js +25 -0
  19. package/dist/cjs/Plugin.js.map +7 -0
  20. package/dist/cjs/PluginError.d.ts +50 -0
  21. package/dist/cjs/PluginError.d.ts.map +1 -0
  22. package/dist/cjs/PluginError.js +58 -0
  23. package/dist/cjs/PluginError.js.map +7 -0
  24. package/dist/cjs/Plugins.d.ts +97 -0
  25. package/dist/cjs/Plugins.d.ts.map +1 -0
  26. package/dist/cjs/Plugins.js +169 -0
  27. package/dist/cjs/Plugins.js.map +7 -0
  28. package/dist/cjs/Resolve.d.ts +128 -0
  29. package/dist/cjs/Resolve.d.ts.map +1 -0
  30. package/dist/cjs/Resolve.js +346 -0
  31. package/dist/cjs/Resolve.js.map +7 -0
  32. package/dist/cjs/index.d.ts +83 -0
  33. package/dist/cjs/index.d.ts.map +1 -0
  34. package/dist/cjs/index.js +45 -0
  35. package/dist/cjs/index.js.map +7 -0
  36. package/dist/cjs/internal/Boundary.d.ts +96 -0
  37. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  38. package/dist/cjs/internal/Boundary.js +129 -0
  39. package/dist/cjs/internal/Boundary.js.map +7 -0
  40. package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
  41. package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
  42. package/dist/cjs/internal/mergePluginLayers.js +57 -0
  43. package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
  44. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +23 -0
  45. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  46. package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
  47. package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
  48. package/dist/cjs/package.json +1 -0
  49. package/dist/esm/Config.d.ts +129 -0
  50. package/dist/esm/Config.d.ts.map +1 -0
  51. package/dist/esm/Config.js +160 -0
  52. package/dist/esm/Config.js.map +1 -0
  53. package/dist/esm/Hooks.d.ts +176 -0
  54. package/dist/esm/Hooks.d.ts.map +1 -0
  55. package/dist/esm/Hooks.js +56 -0
  56. package/dist/esm/Hooks.js.map +1 -0
  57. package/dist/esm/Kernel.d.ts +81 -0
  58. package/dist/esm/Kernel.d.ts.map +1 -0
  59. package/dist/esm/Kernel.js +58 -0
  60. package/dist/esm/Kernel.js.map +1 -0
  61. package/dist/esm/Plugin.d.ts +71 -0
  62. package/dist/esm/Plugin.d.ts.map +1 -0
  63. package/dist/esm/Plugin.js +20 -0
  64. package/dist/esm/Plugin.js.map +1 -0
  65. package/dist/esm/PluginError.d.ts +50 -0
  66. package/dist/esm/PluginError.d.ts.map +1 -0
  67. package/dist/esm/PluginError.js +53 -0
  68. package/dist/esm/PluginError.js.map +1 -0
  69. package/dist/esm/Plugins.d.ts +97 -0
  70. package/dist/esm/Plugins.d.ts.map +1 -0
  71. package/dist/esm/Plugins.js +190 -0
  72. package/dist/esm/Plugins.js.map +1 -0
  73. package/dist/esm/Resolve.d.ts +128 -0
  74. package/dist/esm/Resolve.d.ts.map +1 -0
  75. package/dist/esm/Resolve.js +369 -0
  76. package/dist/esm/Resolve.js.map +1 -0
  77. package/dist/esm/index.d.ts +83 -0
  78. package/dist/esm/index.d.ts.map +1 -0
  79. package/dist/esm/index.js +48 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/internal/Boundary.d.ts +96 -0
  82. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  83. package/dist/esm/internal/Boundary.js +162 -0
  84. package/dist/esm/internal/Boundary.js.map +1 -0
  85. package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
  86. package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
  87. package/dist/esm/internal/mergePluginLayers.js +41 -0
  88. package/dist/esm/internal/mergePluginLayers.js.map +1 -0
  89. package/dist/esm/internal/snapshotCacheEnvironment.d.ts +23 -0
  90. package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  91. package/dist/esm/internal/snapshotCacheEnvironment.js +64 -0
  92. package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
  93. package/docs/README.md +133 -0
  94. package/docs/api.md +524 -0
  95. package/docs/concepts/configuration.md +111 -0
  96. package/docs/concepts/hook-kinds.md +107 -0
  97. package/docs/concepts/resolution.md +132 -0
  98. package/docs/guides/cache-identity.md +106 -0
  99. package/docs/guides/contribute-services.md +84 -0
  100. package/docs/guides/host-your-own-hooks.md +129 -0
  101. package/docs/guides/order-handlers.md +76 -0
  102. package/docs/guides/select-plugins.md +72 -0
  103. package/docs/guides/testing.md +149 -0
  104. package/docs/installation.md +75 -0
  105. package/docs/quickstart.md +152 -0
  106. package/docs/troubleshooting.md +180 -0
  107. package/package.json +179 -3
  108. package/src/Config.ts +207 -0
  109. package/src/Hooks.ts +212 -0
  110. package/src/Kernel.ts +119 -0
  111. package/src/Plugin.ts +80 -0
  112. package/src/PluginError.ts +62 -0
  113. package/src/Plugins.ts +271 -0
  114. package/src/Resolve.ts +539 -0
  115. package/src/index.ts +91 -0
  116. package/src/internal/Boundary.ts +252 -0
  117. package/src/internal/mergePluginLayers.ts +57 -0
  118. package/src/internal/snapshotCacheEnvironment.ts +78 -0
package/docs/README.md ADDED
@@ -0,0 +1,133 @@
1
+ ---
2
+ title: "@smthrs/plugin"
3
+ description: "A typed, bounded plugin kernel for Effect programs: Vite-style hooks and ordering, a config waterfall, and a resolution boundary that copies every value a caller hands it."
4
+ ---
5
+
6
+ `@smthrs/plugin` lets a TypeScript program accept plugins written by other
7
+ people. You name the extension points your program offers, a plugin author
8
+ hands you a plain record of handlers, and the kernel turns that list into an
9
+ ordered, immutable catalog you dispatch against.
10
+
11
+ It is Vite's plugin model, typed end to end and bounded at every edge: a plugin
12
+ is a plain record, a hook is an ordinary [Effect](https://effect.website),
13
+ ordering follows Vite's rules exactly, and no value a caller passes in survives
14
+ as a live reference into dispatch.
15
+
16
+ ## What it solves
17
+
18
+ An extensible program has to answer five questions before its first plugin
19
+ runs: which plugins are selected, what order their handlers run in, what a
20
+ handler may return, what a failing handler does to the caller, and whether a
21
+ plugin can change any of that after startup. This package answers all five, and
22
+ refuses the input that would leave one of them undecided:
23
+
24
+ - **Selection** is the `apply` field: a host target, or a predicate over the
25
+ configuration.
26
+ - **Order** is Vite's rule kept verbatim. `enforce` sorts the plugin list once,
27
+ and a per-hook `order` re-partitions it for a single hook.
28
+ - **Result and failure** come from the hook's kind. `sequential`, `parallel`,
29
+ `first`, and `waterfall` each promise something different, and the kind is
30
+ checked in the type system and again against the host's runtime catalog.
31
+ - **Change after startup** is not possible. Resolution copies every plugin
32
+ record, hook object, and configuration value it accepts, so dispatch never
33
+ reads memory a caller still holds.
34
+
35
+ Reach for it when you are the host: you have a composition, you want other
36
+ people's code inside it at named points, and you would rather refuse a
37
+ malformed plugin at startup with an error code and a value path than debug it
38
+ in production.
39
+
40
+ ## The hook catalog is yours
41
+
42
+ The kernel owns one catalog of its own, the two-step config lifecycle (`config`
43
+ and `configResolved`). Every other hook belongs to a host. You declare your
44
+ hooks with TypeScript module augmentation, hand the kernel the matching runtime
45
+ catalog, and dispatch that catalog and nothing else. The `FlowsHooks` interface
46
+ is open for augmentation and closed for dispatch, so another host augmenting
47
+ the same interface still dispatches only the hooks it supplied.
48
+
49
+ A plugin author writes a record with a `name` and some `hooks`. There is no base
50
+ class, no registration call, and no inheritance.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ npm install @smthrs/plugin@next effect
56
+ ```
57
+
58
+ Node.js 26.4.0 or later is required. For the runtime requirements, the import
59
+ forms, and the subpaths the export map blocks, see
60
+ [Installation](./installation.md).
61
+
62
+ ## The shortest real example
63
+
64
+ A plugin contributes a configuration namespace, and a second hook reads the
65
+ resolved result back:
66
+
67
+ ```ts
68
+ import { type FlowsPlugin, Kernel } from "@smthrs/plugin"
69
+ import { Effect } from "effect"
70
+
71
+ const feature: FlowsPlugin = {
72
+ name: "flows-plugin-feature",
73
+ hooks: {
74
+ config: () => Effect.succeed({ feature: { enabled: true } }),
75
+ configResolved: (config) => Effect.log(`feature enabled: ${config["feature"] !== undefined}`)
76
+ }
77
+ }
78
+
79
+ const kernel = await Effect.runPromise(Kernel.make([feature]))
80
+ ```
81
+
82
+ `Kernel.make` gives you four things:
83
+
84
+ - `kernel.config` is the frozen, deeply copied configuration every plugin
85
+ agreed on.
86
+ - `kernel.plugins` dispatches hooks.
87
+ - `kernel.layer` is every plugin's Effect services merged in resolved order.
88
+ - `kernel.observerErrors` holds the failures that `configResolved` observers
89
+ reported without failing startup.
90
+
91
+ ## Where this sits in Smithers
92
+
93
+ This package is the mechanism, not a host: on its own it dispatches only the
94
+ config lifecycle. The shipped host is [`@smthrs/agent`](/api/agent), the
95
+ Smithers agent loop, which augments `FlowsHooks` with three waterfall hooks of
96
+ its own (`cellRegistry`, `cellFlows`, and `cellModelRequest`), passes the
97
+ matching runtime catalog with `target: "harness"`, and dispatches them around
98
+ each frame of an agent cell. If you want to extend the Smithers agent rather
99
+ than build a host of your own, read that package first: its hooks are the ones
100
+ your plugin declares, and this package explains what those hooks promise.
101
+
102
+ Both sit under the `smthrs` command line, [`@smthrs/cli`](/api/cli), which runs
103
+ and inspects the durable flows a Smithers installation is made of. Start there
104
+ for the whole picture.
105
+
106
+ The kernel depends on [`@smthrs/flow`](/api/flow) for the
107
+ `Action.CacheEnvironment` schema used to
108
+ [declare a cache identity](./guides/cache-identity.md), and
109
+ [`@smthrs/canonical`](/api/canonical) for immutable catalog maps.
110
+
111
+ ## What this package does not do
112
+
113
+ The kernel is not an engine lifecycle registry. Retry, storage, concurrency,
114
+ cache, wait, journal, and ownership policy stay on the Effect service or
115
+ constructor option that applies them. Configuration refuses the root keys
116
+ `engine`, `retry`, `store`, and `plugins` so that a configuration file cannot
117
+ imply otherwise.
118
+
119
+ ## Where to go next
120
+
121
+ - [Installation](./installation.md) for the runtime requirements, the import
122
+ forms, and the subpaths that are not public.
123
+ - [Quickstart](./quickstart.md) to build a host with its own hook, run it, and
124
+ read a typed result.
125
+ - [Hook kinds](./concepts/hook-kinds.md) for what each of the four dispatch
126
+ shapes promises.
127
+ - [Resolution](./concepts/resolution.md) for how a plugin list becomes an
128
+ ordered, immutable catalog.
129
+ - [Configuration](./concepts/configuration.md) for the config waterfall and the
130
+ JSON boundary it enforces.
131
+ - [API reference](./api.md) for every export, with signatures and limits.
132
+ - [Troubleshooting](./troubleshooting.md) for each `PluginError` code, what
133
+ causes it, and what to change.
package/docs/api.md ADDED
@@ -0,0 +1,524 @@
1
+ ---
2
+ title: "API reference"
3
+ description: "Every export of @smthrs/plugin: the plugin and hook declarations, the Kernel, Plugins, Resolve, and Config modules, the PluginError codes, and the published limits."
4
+ ---
5
+
6
+ Import the declarations from the root and the four stateful modules either as
7
+ namespaces from the root or from their own subpaths:
8
+
9
+ ```ts
10
+ import { engineHooks, type FlowsPlugin, make, PluginError } from "@smthrs/plugin"
11
+ import * as Config from "@smthrs/plugin/Config"
12
+ import * as Kernel from "@smthrs/plugin/Kernel"
13
+ import * as Plugins from "@smthrs/plugin/Plugins"
14
+ import * as Resolve from "@smthrs/plugin/Resolve"
15
+ ```
16
+
17
+ Every function that can refuse returns `Effect<A, PluginError>`, except
18
+ `Config.merge` and `Config.deepFreeze`, which are synchronous and throw a
19
+ `PluginError`.
20
+
21
+ ## Example
22
+
23
+ ```ts
24
+ import { Kernel, make } from "@smthrs/plugin"
25
+
26
+ const editor = make({
27
+ name: "flows-plugin-editor",
28
+ hooks: { config: () => Effect.succeed({ editor: { readOnly: false } }) }
29
+ })
30
+ const kernel = await Effect.runPromise(Kernel.make([editor], {}, { target: "harness" }))
31
+ console.log(kernel.config) // { editor: { readOnly: false } }
32
+ ```
33
+
34
+ ## Root exports
35
+
36
+ | Export | Kind | Summary |
37
+ | ------------------------------------------------------------------------ | ------------------ | --------------------------------------------------------------------- |
38
+ | `FlowsHooks` | interface | The augmentable hook catalog. Declares `config` and `configResolved`. |
39
+ | `Apply`, `FlowsPlugin`, `PluginInput`, `make` | from `Plugin` | The plugin record and its constructor. |
40
+ | Hook kinds and type-level helpers, `engineHooks`, `handlerOf`, `orderOf` | from `Hooks` | The hook surface. |
41
+ | `PluginErrorCode`, `PluginError` | from `PluginError` | The single typed failure. |
42
+ | `Config`, `Kernel`, `Plugins`, `Resolve` | namespaces | The four modules. |
43
+
44
+ ### FlowsHooks
45
+
46
+ ```ts
47
+ interface FlowsHooks {
48
+ readonly config: WaterfallHook<(config: FlowsConfig) => Effect.Effect<Partial<FlowsConfig> | void, any, never>>
49
+ readonly configResolved: ParallelHook<(config: ResolvedConfig) => Effect.Effect<void, any, never>>
50
+ }
51
+ ```
52
+
53
+ Declared in the entry point so that `declare module "@smthrs/plugin"` can
54
+ augment it. Open for augmentation, closed for dispatch: the kernel dispatches
55
+ only the config lifecycle, and a host supplies and dispatches its own catalog
56
+ over the same augmented interface.
57
+
58
+ Both startup hooks are context-free. `Kernel.make` runs them before any plugin
59
+ layer is built and supplies no services of its own, so no externally supplied
60
+ service is available during startup. A handler that needs one provides it
61
+ inside the hook with `Effect.provide`. A plugin literal whose `config` hook
62
+ requires a service fails to compile against `FlowsHooks`; a host whose startup
63
+ hooks do require services declares a separate hook interface, and
64
+ `Kernel.make<H>` then carries the requirement as `Kernel.StartupContext<H>`.
65
+
66
+ ## Plugin
67
+
68
+ ```ts
69
+ type Apply = "engine" | "harness" | ((config: FlowsConfig) => boolean)
70
+
71
+ interface FlowsPlugin<H = FlowsHooks> {
72
+ readonly name: string
73
+ readonly version?: string | undefined
74
+ readonly enforce?: "pre" | "post" | undefined
75
+ readonly apply?: Apply | undefined
76
+ readonly layer?: Layer.Layer<never, any, any> | undefined
77
+ readonly hooks?: Partial<H> | undefined
78
+ }
79
+
80
+ type PluginInput<H = FlowsHooks> =
81
+ | FlowsPlugin<H>
82
+ | false
83
+ | null
84
+ | undefined
85
+ | ReadonlyArray<PluginInput<H>>
86
+
87
+ const make: <H = FlowsHooks>(plugin: FlowsPlugin<H>) => FlowsPlugin<H>
88
+ ```
89
+
90
+ | Field | Meaning |
91
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
92
+ | `name` | Required and unique among selected plugins. Compared as an exact Unicode string, at most 256 UTF-16 code units, never empty or whitespace only. Convention: `flows-plugin-<thing>`. |
93
+ | `version` | Semantic identity folded into sealed cache keys. Required when the host declares a `cacheEnvironment`; refused as empty or malformed text. |
94
+ | `enforce` | Ordering group. Omitted means normal. |
95
+ | `apply` | Conditional inclusion. A literal names the host target; a predicate receives the pre-resolution configuration and returns a boolean. |
96
+ | `layer` | Services this plugin contributes to the composition. Must be an Effect `Layer`. |
97
+ | `hooks` | Typed hook entries. Only keys declared in `H` compile, and only names in the host's runtime catalog resolve. |
98
+
99
+ `make` is an identity function that pins a plugin literal to `FlowsPlugin<H>`,
100
+ so an excess or misspelled hook key fails at the definition site.
101
+ `PluginInput` accepts falsy entries and arrays nested up to
102
+ `maximumPluginDepth`, which is what makes a preset an ordinary function that
103
+ returns plugins.
104
+
105
+ ## Hooks
106
+
107
+ ```ts
108
+ type HookKind = "sequential" | "parallel" | "first" | "waterfall"
109
+
110
+ interface HookObject<F> {
111
+ readonly order?: "pre" | "post" | undefined
112
+ readonly handler: F
113
+ }
114
+
115
+ type HookEntry<K extends HookKind, F> = (F | HookObject<F>) & HookMeta<K, F>
116
+
117
+ type SequentialHook<F> = HookEntry<"sequential", F>
118
+ type ParallelHook<F> = HookEntry<"parallel", F>
119
+ type FirstHook<F> = HookEntry<"first", F>
120
+ type WaterfallHook<F> = HookEntry<"waterfall", F>
121
+ ```
122
+
123
+ `HookMeta<K, F>` is a phantom carrier: it records the kind and the handler type
124
+ in the type system and adds nothing at runtime. A hook entry is either the bare
125
+ handler or the `{ order, handler }` object; `undefined` and `null` are refused.
126
+
127
+ Type-level helpers, used by the dispatcher's signatures and available to hosts:
128
+
129
+ | Helper | Extracts |
130
+ | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
131
+ | `KindOf<T>` | The declared kind of a hook entry type. |
132
+ | `HandlerOf<T>` | The handler function type. |
133
+ | `KeysOfKind<H, K>` | The hook names of kind `K` in interface `H`. |
134
+ | `ArgsOf<T>` | The positional argument tuple the handler accepts. |
135
+ | `ReturnOf<T>` | The Effect the handler returns. |
136
+ | `SuccessOf<T>` | That Effect's success value. |
137
+ | `ContextOf<T>` | That Effect's required context. |
138
+ | `HookCatalog<H>` | The runtime catalog for `H`: a name declared in `H` must carry the kind its type declares; other names admit any kind. |
139
+
140
+ Values:
141
+
142
+ ```ts
143
+ const engineHooks: Readonly<{ readonly config: "waterfall"; readonly configResolved: "parallel" }>
144
+ const handlerOf: (entry: unknown) => (...args: Array<any>) => unknown
145
+ const orderOf: (entry: unknown) => "pre" | "post" | undefined
146
+ ```
147
+
148
+ `engineHooks` is the frozen runtime catalog for the shared configuration hooks,
149
+ and the catalog `Resolve.resolve` checks against when a host supplies none.
150
+ Spread it into your own catalog to keep the config lifecycle available.
151
+ `handlerOf` and `orderOf` normalize either hook entry form.
152
+
153
+ ## Kernel
154
+
155
+ ```ts
156
+ interface Kernel<H = FlowsHooks> {
157
+ readonly plugins: Plugins.Service<H>
158
+ readonly config: ResolvedConfig
159
+ readonly layer: Layer.Layer<any, PluginError, any>
160
+ readonly observerErrors: ReadonlyArray<PluginError>
161
+ }
162
+
163
+ type ConfigContext<H> = ContextOf<H["config"]>
164
+ type StartupContext<H> = ConfigContext<H> | ContextOf<H["configResolved"]>
165
+
166
+ const make: <H = FlowsHooks>(
167
+ input: PluginInput<NoInfer<H>>,
168
+ config?: FlowsConfig,
169
+ options?: Omit<Resolve.Options<NoInfer<H>>, "config">
170
+ ) => Effect.Effect<Kernel<H>, PluginError, StartupContext<H>>
171
+
172
+ const runConfig: <H = FlowsHooks>(
173
+ plugins: Plugins.Service<H>,
174
+ config: FlowsConfig
175
+ ) => Effect.Effect<ResolvedConfig, PluginError, ConfigContext<H>>
176
+ ```
177
+
178
+ `StartupContext<H>` is `never` for `FlowsHooks`, whose startup hooks are
179
+ context-free, so `Kernel.make([...])` runs with `Effect.runPromise` as is. For a
180
+ separate hook interface whose `config` or `configResolved` handlers require
181
+ services, the requirement is the startup Effect's context and the caller
182
+ provides it before running; the kernel never supplies startup services itself.
183
+
184
+ `make` performs startup in this order:
185
+
186
+ 1. Snapshot the positional `config`.
187
+ 2. Resolve the plugin list against `options`.
188
+ 3. Run the `config` waterfall, deep-merging each returned patch.
189
+ 4. Decode and freeze the result into a `ResolvedConfig`.
190
+ 5. Run `configResolved` observers in parallel, collecting their failures into
191
+ `observerErrors` rather than failing.
192
+ 6. Merge the selected plugins' layers, left to right in resolved order.
193
+
194
+ The positional `config` is the kernel's only pre-resolution configuration
195
+ source. `Options.config` is excluded from the options type so a caller cannot
196
+ declare a second one, and a `config` key smuggled past the type fails at
197
+ runtime with `invalid_plugin` at `$options.config`, the same refusal an
198
+ unknown option key gets. `runConfig` exposes step 3 and step 4 on their own, for a
199
+ host that resolved its plugin list separately. Before dispatching the first
200
+ hook, it validates and copies the supplied config into a detached, recursively
201
+ frozen snapshot. Invalid or accessor-bearing input fails with `config_invalid`
202
+ before any hook runs, without executing accessors. The final waterfall result
203
+ is admitted again through `Config.resolve`.
204
+
205
+ `observerErrors` is a return value, not a log: a `configResolved` failure never
206
+ fails startup, so a host that ignores the array cannot tell a working observer
207
+ from a broken one.
208
+
209
+ ## Plugins
210
+
211
+ ```ts
212
+ interface Service<H = FlowsHooks> {
213
+ readonly resolved: Resolved<H>
214
+ readonly handlers: (hook: string) => ReadonlyArray<HandlerRecord>
215
+
216
+ readonly sequential: <K extends KeysOfKind<H, "sequential">>(
217
+ hook: K,
218
+ ...args: ArgsOf<H[K]>
219
+ ) => Effect.Effect<ReadonlyArray<SuccessOf<H[K]>>, PluginError, ContextOf<H[K]>>
220
+
221
+ readonly parallel: <K extends KeysOfKind<H, "parallel">>(
222
+ hook: K,
223
+ ...args: ArgsOf<H[K]>
224
+ ) => Effect.Effect<ReadonlyArray<PluginError>, never, ContextOf<H[K]>>
225
+
226
+ readonly first: <K extends KeysOfKind<H, "first">>(
227
+ hook: K,
228
+ ...args: ArgsOf<H[K]>
229
+ ) => Effect.Effect<SuccessOf<H[K]>, PluginError, ContextOf<H[K]>>
230
+
231
+ readonly waterfall: <K extends KeysOfKind<H, "waterfall">>(
232
+ hook: K,
233
+ initial: ArgsOf<H[K]>[0],
234
+ merge: (previous: ArgsOf<H[K]>[0], patch: Exclude<SuccessOf<H[K]>, void>) => ArgsOf<H[K]>[0]
235
+ ) => Effect.Effect<ArgsOf<H[K]>[0], PluginError, ContextOf<H[K]>>
236
+ }
237
+
238
+ const make: <H = FlowsHooks>(resolved: Resolved<H>) => Service<H>
239
+ const makeNoop: <H = FlowsHooks>() => Service<H>
240
+
241
+ class Plugins extends Context.Service<Plugins, Service>()("flows/plugin/Plugins") {}
242
+ const layer: (resolved: Resolved) => Layer.Layer<Plugins>
243
+ const layerNoop: Layer.Layer<Plugins>
244
+ ```
245
+
246
+ Dispatch semantics, one row per kind:
247
+
248
+ | Method | Runs | Returns | On handler failure |
249
+ | ------------ | --------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
250
+ | `sequential` | Every handler, in resolved order, one at a time. | Each handler's success value, in resolved order. | Fails the caller with `hook_failed` and stops. |
251
+ | `parallel` | Every handler, bounded by `Resolved.parallelConcurrency`. | The failures, in resolved handler order. Success values are discarded. | Never fails the caller. |
252
+ | `first` | Handlers in order until one returns `Option.some`. | That `Option`, or `Option.none()`. | Fails with `hook_failed`; a non-`Option` value fails with `invalid_hook_result`. |
253
+ | `waterfall` | Every handler, threading the merged value. | The final value. | Fails with `hook_failed` and stops. |
254
+
255
+ Every method first compares its kind with the runtime catalog the resolution
256
+ admitted (`Resolved.kinds`). A hook the catalog declares with a different kind
257
+ is refused before any handler runs: `sequential`, `first`, and `waterfall` fail
258
+ with `hook_kind_mismatch`, and `parallel` returns that one error. A hook name
259
+ absent from the catalog dispatches nothing, unless a hand-built `Resolved`
260
+ registered handlers under it; those are refused the same way, because no kind
261
+ was declared for them. The typed signatures make the mismatch unreachable
262
+ without a cast; the runtime check covers the cast.
263
+
264
+ Handlers must return an Effect. Synchronous throws, Effect failures and defects,
265
+ and non-Effect results (including `undefined`, scalars, and Promises) become
266
+ `hook_failed` with the plugin and hook names. Non-Effect error messages include
267
+ the result's JavaScript `typeof`. Parallel dispatch collects these errors and
268
+ continues running sibling observers, including with `parallelConcurrency: 1`.
269
+ `Kernel.make` succeeds and reports them in `observerErrors`. Sequential dispatch
270
+ fails with the attributed error and skips later handlers.
271
+
272
+ A waterfall handler whose Effect succeeds with `undefined` leaves the value
273
+ unchanged: the merge function is not called. A merge that throws a `PluginError` keeps that
274
+ error's code and path and gains the handler's attribution; a merge that throws
275
+ anything else becomes `config_invalid` attributed to the handler.
276
+
277
+ Cancellation is fiber interruption through scope closure. Nothing threads an
278
+ `AbortSignal`. Interrupting a dispatch interrupts the handler in flight, runs
279
+ its finalizers, and skips the handlers that had not started.
280
+
281
+ The `Plugins` service tag, `layer`, and `layerNoop` hold one dispatcher over the
282
+ process-wide augmented `FlowsHooks`. A host typed against a separate hook
283
+ interface holds its `Service<H>` directly and uses none of the three.
284
+
285
+ ## Resolve
286
+
287
+ ```ts
288
+ interface HandlerRecord {
289
+ readonly plugin: string
290
+ readonly hook: string
291
+ readonly handler: (...args: Array<any>) => unknown
292
+ }
293
+
294
+ interface Resolved<H = FlowsHooks> {
295
+ readonly plugins: ReadonlyArray<FlowsPlugin<H>>
296
+ readonly handlers: ReadonlyMap<string, ReadonlyArray<HandlerRecord>>
297
+ readonly kinds: Readonly<Record<string, HookKind>>
298
+ readonly parallelConcurrency: number
299
+ readonly cacheEnvironment?: Action.CacheEnvironment | undefined
300
+ }
301
+
302
+ interface Options<H = FlowsHooks> {
303
+ readonly config?: FlowsConfig | undefined
304
+ readonly target?: "engine" | "harness" | undefined
305
+ readonly hooks?: HookCatalog<H> | undefined
306
+ readonly cacheEnvironment?: Action.CacheEnvironment | undefined
307
+ readonly parallelConcurrency?: number | undefined
308
+ }
309
+
310
+ const resolve: <H = FlowsHooks>(
311
+ input: PluginInput<NoInfer<H>>,
312
+ options?: Options<NoInfer<H>>,
313
+ configOverride?: FlowsConfig
314
+ ) => Effect.Effect<Resolved<H>, PluginError>
315
+
316
+ const layer: <H>(resolved: Resolved<H>) => Layer.Layer<any, PluginError, any>
317
+ ```
318
+
319
+ | Option | Default | Meaning |
320
+ | --------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
321
+ | `config` | `{}` | Pre-resolution configuration tested by `apply` predicates. `Kernel.make` supplies it positionally and omits this field. Supplying both `config` and `configOverride` fails with `invalid_plugin` at `$options.config`. |
322
+ | `target` | `"engine"` | The host whose literal `apply` selectors are active. |
323
+ | `hooks` | `engineHooks` | The hook names and kinds this host recognizes, typed as `HookCatalog<H>`. The `unknown_hook` guard checks against it, the frozen copy is `Resolved.kinds`, and `config` or `configResolved` labelled with any kind but `waterfall` or `parallel` fails with `hook_kind_mismatch` at `$options.hooks.<name>`. |
324
+ | `cacheEnvironment` | absent | Complete composition identity for sealed activity keys. Requires a `version` on every selected plugin. |
325
+ | `parallelConcurrency` | `16` | Maximum observers run at once. A positive safe integer through 256. |
326
+
327
+ `resolve` is once-only and everything it returns is a copy the kernel owns.
328
+ Plugin records and hook objects are snapshotted, handler records and ordered
329
+ arrays are frozen, and `handlers` is a read-only map facade with no `set`,
330
+ `delete`, or `clear`. Reflection is descriptor-only: accessors never execute,
331
+ exotic prototypes are refused, and a hostile proxy observes a bounded number of
332
+ traps whose results are copied rather than retained.
333
+
334
+ Order of the checks: options, flatten, validate every record, filter by `apply`,
335
+ check hook names against the catalog for selected plugins, reject duplicate
336
+ names, order, then decode the cache environment. Structure is validated before
337
+ filtering, so exclusion cannot hide a malformed plugin; catalog names are
338
+ checked after, so a shared preset carrying harness-only hooks still resolves
339
+ under an engine kernel.
340
+
341
+ Ordering follows Vite exactly. `enforce` sorts the plugin list once into pre,
342
+ normal, and post, stably within each group. The per-hook `order` then
343
+ re-partitions that list for one hook, so `{ order: "pre", handler }` runs ahead
344
+ of every normal-order handler even when its plugin is `enforce: "post"`. Ties
345
+ keep resolved plugin order.
346
+
347
+ `layer` merges every selected plugin's layer left to right, so an earlier
348
+ plugin's services are visible to a later plugin's layer, and the later plugin
349
+ wins when two provide the same tag. A layer that fails during acquisition
350
+ becomes `layer_failed` with the plugin's name. `layer` also seals
351
+ `Action.CurrentCacheEnvironment`: plugin layers read the declared environment,
352
+ or `undefined` when none was declared, and a plugin layer that provides its own
353
+ environment is overridden.
354
+
355
+ ### Limits
356
+
357
+ | Constant | Value | Refusal |
358
+ | ---------------------------- | ----- | ----------------------------- |
359
+ | `maximumPlugins` | 256 | `resource_limit` |
360
+ | `maximumHandlers` | 1,024 | `resource_limit` |
361
+ | `maximumPluginInputNodes` | 4,096 | `resource_limit` |
362
+ | `maximumPluginDepth` | 64 | `resource_limit` |
363
+ | `maximumParallelConcurrency` | 256 | `resource_limit` |
364
+ | `maximumPluginNameLength` | 256 | `invalid_plugin` |
365
+ | `defaultParallelConcurrency` | 16 | none, it is the default value |
366
+
367
+ A `resource_limit` carries the path of the offending entry or preset array, and
368
+ the handler bound counts the handlers the kernel dispatches. Plugin, version,
369
+ and hook names are a separate check, not a budget: they are control-free,
370
+ well-formed strings within `maximumPluginNameLength` that are neither empty nor
371
+ entirely whitespace, and a name that is not fails with `invalid_plugin` at its
372
+ own path even when every budget is free.
373
+
374
+ ## Config
375
+
376
+ ```ts
377
+ const ConfigValue: Schema.Json
378
+ type ConfigValue = typeof ConfigValue.Type
379
+
380
+ const FlowsConfig: Schema.Codec<Readonly<Record<string, ConfigValue>>, unknown>
381
+ type FlowsConfig = typeof FlowsConfig.Type
382
+
383
+ const ResolvedConfig: typeof FlowsConfig
384
+ type ResolvedConfig = typeof ResolvedConfig.Type
385
+
386
+ const defaults: ResolvedConfig
387
+
388
+ const merge: (base: FlowsConfig, patch: unknown) => FlowsConfig
389
+ const deepFreeze: <A extends ConfigValue>(value: A) => A
390
+ const snapshot: (config: unknown) => Effect.Effect<FlowsConfig, PluginError>
391
+ const resolve: (config: unknown) => Effect.Effect<ResolvedConfig, PluginError>
392
+ ```
393
+
394
+ | Export | Behavior |
395
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
396
+ | `defaults` | The frozen empty configuration. Engine policy is deliberately not defaulted here. |
397
+ | `merge` | Copies and deep-merges a patch over a base. Records merge key by key; every other JSON value replaces wholesale. Raw operands are admitted; unchanged snapshot subtrees are reused. Cached totals enforce result bounds. Throws `PluginError` with code `config_invalid`. |
398
+ | `deepFreeze` | Copies and recursively freezes one JSON value without retaining caller-owned objects. Throws `PluginError`. |
399
+ | `snapshot` | Admits a raw pre-resolution configuration as an immutable snapshot. |
400
+ | `resolve` | Decodes the post-waterfall configuration into its final immutable form. |
401
+
402
+ `FlowsConfig` and `ResolvedConfig` decode through the same admission contract as
403
+ `snapshot` and `resolve`. Decoding raw input detaches and recursively freezes it;
404
+ invalid input produces a schema error. `Schema.is` recognizes admitted snapshots,
405
+ not mutable JSON shapes. Encoding accepts admitted snapshots and returns their
406
+ JSON value. The TypeScript types remain read-only namespace maps.
407
+
408
+ Known snapshots can be reused by `snapshot`, `resolve`, and `merge`. Freezing an
409
+ object yourself does not establish admission. A merge copies each changed record
410
+ and retains unchanged frozen subtrees. It fully admits and detaches patch data,
411
+ including references to previous snapshots, so patches cannot introduce shared
412
+ references. Cached byte, member, node, and depth totals enforce result bounds.
413
+ Admission work scales with the initial tree plus patch data; copying changed
414
+ records also scales with their immediate key counts.
415
+
416
+ Configuration is a plugin-owned JSON namespace map. The root keys `engine`,
417
+ `retry`, `store`, and `plugins` are refused, because the kernel does not apply
418
+ those policies.
419
+
420
+ Refused with `config_invalid`, at the offending path, without executing user
421
+ accessors: cycles, repeated object references, sparse arrays, accessors, symbol
422
+ keys, exotic prototypes, non-finite numbers, unpaired surrogates, `undefined`
423
+ members, and the prototype-control keys `__proto__`, `constructor`, and
424
+ `prototype`. `undefined` is refused rather than dropped, so
425
+ `{ endpoint: undefined }` fails at `$.endpoint` instead of resolving to `{}`.
426
+ Omit the key, or write `null`.
427
+
428
+ ### Limits
429
+
430
+ | Constant | Value |
431
+ | ---------------------- | ----------------------- |
432
+ | `maximumConfigBytes` | 1 MiB of encoded JSON |
433
+ | `maximumConfigDepth` | 64 container edges |
434
+ | `maximumConfigMembers` | 4,096 aggregate members |
435
+ | `maximumConfigNodes` | 8,192 aggregate values |
436
+
437
+ Strings are limited to 64 KiB of encoded JSON and keys to 1 KiB.
438
+
439
+ ## Cache identity
440
+
441
+ `Options.cacheEnvironment` is optional. Omitting it keeps sealed activity keys
442
+ local to the run and leaves plugin `version` optional. Supplying it requires the
443
+ complete `Action.CacheEnvironment` schema and a bounded, non-empty `version` on
444
+ every selected plugin.
445
+
446
+ Resolution copies and freezes every layer entry, capability record, and
447
+ capability array, then prepends each selected `name@version` in resolved order.
448
+ Both halves percent-escape `%` and then `@`, so the identity is injective:
449
+ `a@b` at version `c` and `a` at version `b@c` produce different layer entries
450
+ instead of one shared sealed identity. A scoped name reads as
451
+ `%40scope/name@1.0.0`. A versionless composition fails with
452
+ `cache_environment_invalid` instead of declaring an ambiguous cross-run
453
+ identity. Invalid or mutable caller data never reaches
454
+ `Action.CurrentCacheEnvironment`, and no plugin layer can declare or replace
455
+ it.
456
+
457
+ ## PluginError
458
+
459
+ ```ts
460
+ const PluginErrorCode: Schema.Literals<[
461
+ "duplicate_name",
462
+ "unknown_hook",
463
+ "hook_kind_mismatch",
464
+ "invalid_plugin",
465
+ "apply_failed",
466
+ "config_invalid",
467
+ "cache_environment_invalid",
468
+ "invalid_hook_result",
469
+ "resource_limit",
470
+ "hook_failed",
471
+ "layer_failed"
472
+ ]>
473
+ type PluginErrorCode = typeof PluginErrorCode.Type
474
+
475
+ class PluginError extends Schema.TaggedError<PluginError>()("flows/plugin/PluginError", {
476
+ code: PluginErrorCode,
477
+ message: Schema.String,
478
+ plugin: Schema.optional(Schema.String),
479
+ hook: Schema.optional(Schema.String),
480
+ path: Schema.optional(Schema.String),
481
+ cause: Schema.optional(Schema.Unknown)
482
+ }) {}
483
+ ```
484
+
485
+ Every startup and dispatch refusal uses this one error. `plugin` and `hook` are
486
+ attribution, `path` is a diagnostic dollar-path into the refused value, and
487
+ `cause` carries a failing handler's, layer's, or waterfall merge's original
488
+ failure. Match on `code`; the messages are prose.
489
+
490
+ `cause` is not redacted. It holds whatever the plugin failed or threw with,
491
+ which can include credentials or config values. Keep it in process for
492
+ diagnostics, and redact it before a host logs, persists, or transmits a
493
+ `PluginError`.
494
+
495
+ A dollar-path is not an RFC 6901 JSON Pointer: `$` is the input, `.config` an
496
+ identifier property, `[2]` an array index, and `["not an identifier"]` any other
497
+ key, JSON-quoted. So `$[2].hooks.config` is the third plugin's `config` entry
498
+ and `$options.parallelConcurrency` is an option.
499
+
500
+ | Code | Meaning |
501
+ | --------------------------- | ------------------------------------------------------------------------------ |
502
+ | `duplicate_name` | Two selected plugins have the same exact name. |
503
+ | `unknown_hook` | A plugin declares a hook absent from the host catalog. |
504
+ | `hook_kind_mismatch` | A catalog kind disagrees with the kind the kernel or a dispatch method runs. |
505
+ | `invalid_plugin` | A plugin, preset, option, or hook entry has an invalid runtime shape. |
506
+ | `apply_failed` | An `apply` predicate threw. Its raw failure is not retained. |
507
+ | `config_invalid` | Config is not bounded strict JSON or uses a reserved policy key. |
508
+ | `cache_environment_invalid` | Cache identity is malformed or incomplete. |
509
+ | `invalid_hook_result` | A first hook returned a value other than `Option`. |
510
+ | `resource_limit` | Preset, plugin, handler, or concurrency work exceeds a published bound. |
511
+ | `hook_failed` | A hook Effect failed, defected, threw synchronously, or returned a non-Effect. |
512
+ | `layer_failed` | A validated plugin layer failed during acquisition. |
513
+
514
+ `configResolved` is a lossy observer boundary. Its failures do not fail kernel
515
+ startup; `Kernel.observerErrors` returns them for redacted logging by the host.
516
+ What to change for each code is in [Troubleshooting](./troubleshooting.md).
517
+
518
+ ## Related packages
519
+
520
+ The shipped hook catalog is the Smithers agent loop in
521
+ [`@smthrs/agent`](/api/agent), which augments `FlowsHooks` with three
522
+ waterfalls. `Action.CacheEnvironment` comes from
523
+ [`@smthrs/flow`](/api/flow). The other runtime dependency,
524
+ [`@smthrs/canonical`](/api/canonical), supplies immutable catalog maps.