@db-lyon/flowkit 0.11.2 → 0.13.0

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 (38) hide show
  1. package/README.md +386 -375
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/flow/runner.d.ts +23 -2
  4. package/dist/flow/runner.d.ts.map +1 -1
  5. package/dist/flow/runner.js +89 -29
  6. package/dist/flow/runner.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +2 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/references.d.ts +41 -0
  12. package/dist/references.d.ts.map +1 -0
  13. package/dist/references.js +110 -0
  14. package/dist/references.js.map +1 -0
  15. package/dist/task/agent-task.d.ts.map +1 -1
  16. package/dist/task/agent-task.js +1 -7
  17. package/dist/task/agent-task.js.map +1 -1
  18. package/dist/task/base-task.d.ts +28 -3
  19. package/dist/task/base-task.d.ts.map +1 -1
  20. package/dist/task/base-task.js +13 -4
  21. package/dist/task/base-task.js.map +1 -1
  22. package/dist/task/shell-task.d.ts +2 -0
  23. package/dist/task/shell-task.d.ts.map +1 -1
  24. package/dist/task/shell-task.js +186 -40
  25. package/dist/task/shell-task.js.map +1 -1
  26. package/dist/task/shell-termination.d.ts +34 -0
  27. package/dist/task/shell-termination.d.ts.map +1 -0
  28. package/dist/task/shell-termination.js +179 -0
  29. package/dist/task/shell-termination.js.map +1 -0
  30. package/dist/task/task-resolution.d.ts +33 -0
  31. package/dist/task/task-resolution.d.ts.map +1 -0
  32. package/dist/task/task-resolution.js +34 -0
  33. package/dist/task/task-resolution.js.map +1 -0
  34. package/docs/ai-agents.md +368 -368
  35. package/docs/api-reference.md +458 -430
  36. package/docs/configuration.md +347 -343
  37. package/docs/custom-tasks.md +258 -203
  38. package/package.json +53 -52
@@ -1,343 +1,347 @@
1
- # Configuration
2
-
3
- Flowkit uses YAML files for declarative configuration, with support for layered merging, environment overlays, and schema validation via Zod.
4
-
5
- ## YAML schema
6
-
7
- A flowkit config file has two top-level keys:
8
-
9
- ```yaml
10
- tasks:
11
- # ...
12
- flows:
13
- # ...
14
- ```
15
-
16
- Both default to `{}` if omitted.
17
-
18
- ### Task definition
19
-
20
- ```yaml
21
- tasks:
22
- my_task:
23
- class_path: path.to.MyTask # required — how to resolve the task class
24
- description: What this task does # optional
25
- group: etl # optional — logical grouping label
26
- options: # optional — default options passed to the task
27
- key: value
28
- ```
29
-
30
- | Field | Type | Required | Description |
31
- |-------|------|----------|-------------|
32
- | `class_path` | `string` | yes | Dotted path to the task class, or a registered name |
33
- | `description` | `string` | no | Human-readable description |
34
- | `group` | `string` | no | Logical grouping label |
35
- | `options` | `object` | no | Default options (merged with step-level overrides) |
36
-
37
- ### Flow definition
38
-
39
- ```yaml
40
- flows:
41
- my_flow:
42
- description: What this flow does # required
43
- steps:
44
- 1:
45
- task: my_task # reference a task by name
46
- options: # optional — override/extend task defaults
47
- key: override_value
48
- 2:
49
- flow: other_flow # reference another flow (nesting)
50
- 3:
51
- task: None # skip sentinel — step is always skipped
52
- ```
53
-
54
- | Field | Type | Required | Description |
55
- |-------|------|----------|-------------|
56
- | `description` | `string` | yes | Human-readable flow description |
57
- | `steps` | `object` | yes | Steps keyed by number (execution order) |
58
-
59
- ### Flow step
60
-
61
- Each step must have exactly one of `task` or `flow` (mutually exclusive), unless `task: None` is used to mark a skipped step.
62
-
63
- | Field | Type | Required | Description |
64
- |-------|------|----------|-------------|
65
- | `task` | `string` | one of task/flow | Task name to execute |
66
- | `flow` | `string` | one of task/flow | Nested flow name to execute |
67
- | `options` | `object` | no | Override options for this step |
68
-
69
- Step numbers are sorted numerically at execution time, so `1, 2, 10` runs in that order (not lexicographic `1, 10, 2`).
70
-
71
- ### Options merging
72
-
73
- When a step executes, options are merged as: **task defaults** + **step overrides** (step wins):
74
-
75
- ```yaml
76
- tasks:
77
- deploy:
78
- class_path: tasks.Deploy
79
- options:
80
- environment: staging
81
- notify: true
82
-
83
- flows:
84
- release:
85
- description: Deploy to production
86
- steps:
87
- 1:
88
- task: deploy
89
- options:
90
- environment: production # overrides "staging"
91
- # notify: true is inherited from task defaults
92
- ```
93
-
94
- Runtime parameters passed to `FlowRunner.run({ params })` merge on top with the highest priority (**task defaults < step overrides < runtime params**).
95
-
96
- ### Step references
97
-
98
- Option values may reference the output of earlier steps in the same flow using `${steps.<id>.<path>}`:
99
-
100
- ```yaml
101
- flows:
102
- chain:
103
- description: Pass one step's output into the next
104
- steps:
105
- 1:
106
- task: build
107
- options:
108
- target: plugin
109
- 2:
110
- task: deploy
111
- options:
112
- artifact: ${steps.1.path} # whole-value → raw type preserved
113
- message: "deployed ${steps.build.version}" # embedded → stringified
114
- ```
115
-
116
- - **`<id>`** is a step number (`1`) or a task name (`build`, `level.place_actor`). Task names with dots are matched longest-prefix-first.
117
- - **`<path>`** is a dot path into the step's `result.data`.
118
- - When a task name appears in multiple steps, references resolve to the **most recently completed** one.
119
- - A reference that fills the entire string (`"${steps.1.path}"`) is replaced with the raw value, so objects and arrays round-trip. References embedded inside a larger string are stringified.
120
- - References that can't be resolved throw and fail the step.
121
-
122
- References resolve just before the step runs, against the results of already-completed steps in the current flow. Nested flows have their own reference scope — they don't see their parent flow's steps.
123
-
124
- ### Flow-level hooks
125
-
126
- A flow can attach steps that run around the main step sequence, keyed by flow outcome:
127
-
128
- ```yaml
129
- flows:
130
- deploy:
131
- description: Deploy to prod
132
- on_start: [ { task: notify, options: { msg: "starting" } } ]
133
- on_success: [ { task: notify, options: { msg: "done ${steps.build.version}" } } ]
134
- on_failure: [ { task: notify, options: { msg: "failed: ${error.message}" } } ]
135
- finally: [ { task: cleanup } ]
136
- steps:
137
- 1: { task: build }
138
- 2: { task: push }
139
- ```
140
-
141
- - **`on_start`** runs before any step. Its failure aborts the flow before steps execute.
142
- - **`on_success`** runs when all steps succeed.
143
- - **`on_failure`** runs when any step fails. It can reference the error via the `${error.*}` namespace.
144
- - **`finally`** runs after either outcome, after `on_success`/`on_failure`.
145
-
146
- Hook steps share the full step execution model — same task dispatch, same option merging, same runtime params, same `${steps.X.y}` resolution. Inside `on_failure` and `finally`, the `${error.message}`, `${error.name}`, `${error.stack}`, and `${error.step}` references resolve to the failure that triggered them.
147
-
148
- Hook failures are captured in `FlowRunResult.hookErrors` but **do not** change the flow's primary success/failure outcome — a failed notifier doesn't rewrite history.
149
-
150
- ### Per-step retry
151
-
152
- A step can retry itself on failure:
153
-
154
- ```yaml
155
- steps:
156
- 1:
157
- task: flaky_network_call
158
- retries: 3 # up to 4 total attempts
159
- retryDelay: 500 # ms between attempts
160
- retryOn: "timeout" # only retry when the error message contains this substring
161
- ```
162
-
163
- Omit `retryOn` to retry on any error. The number of attempts taken appears on `FlowStepResult.attempts`.
164
-
165
- ### Rollback on failure
166
-
167
- Mutating tasks may return a `rollback` record on their `TaskResult` pointing to an inverse task:
168
-
169
- ```ts
170
- return {
171
- success: true,
172
- data: { label: 'MyPillar' },
173
- rollback: { taskName: 'delete_actor', payload: { label: 'MyPillar' } },
174
- };
175
- ```
176
-
177
- When a flow sets `rollback_on_failure: true` (or the caller passes it on `FlowRunRunOptions`) and a later step fails, the runner invokes the collected rollback records in **reverse order**, best-effort: it continues past individual failures and reports all errors in `FlowRunResult.rollback`.
178
-
179
- ```yaml
180
- flows:
181
- safe_deploy:
182
- description: Deploy with rollback on failure
183
- rollback_on_failure: true
184
- steps:
185
- 1: { task: create_thing, options: { label: A } }
186
- 2: { task: create_thing, options: { label: B } }
187
- 3: { task: finalize } # if this fails, thing:B then thing:A are rolled back
188
- ```
189
-
190
- Rollback runs after `on_failure` and before `finally`. Nested flow steps' rollback records bubble up to the parent flow so a single `rollback_on_failure` setting covers the whole tree.
191
-
192
- ### `agent_prompt` — LLM step
193
-
194
- When a `LLMProvider` is attached to the context under `ctx.llm`, the built-in `agent_prompt` task invokes it:
195
-
196
- ```yaml
197
- steps:
198
- 1:
199
- task: agent_prompt
200
- options:
201
- system: "You are a deployment triage agent."
202
- prompt: "Last error: ${error.message}. Suggest a fix."
203
- model: claude-opus-4-6
204
- maxTokens: 512
205
- schema: { type: object, properties: { fix: { type: string } } } # optional
206
- ```
207
-
208
- Returns `{ text, parsed?, usage? }`. Provider failures become step failures; missing provider is a clear error.
209
-
210
- ## Config layering
211
-
212
- `loadConfig()` merges up to four layers, left to right:
213
-
214
- ```
215
- defaults (code) → base file → env overlay → local overlay
216
- ```
217
-
218
- | Layer | Source | Purpose |
219
- |-------|--------|---------|
220
- | 1. Defaults | `options.defaults` in code | Hardcoded fallbacks |
221
- | 2. Base file | `pipeline.yml` | Project-level config (committed) |
222
- | 3. Env overlay | `pipeline.staging.yml` | Environment-specific overrides |
223
- | 4. Local overlay | `pipeline.local.yml` | Developer-specific overrides (gitignored) |
224
-
225
- ### Example
226
-
227
- ```typescript
228
- import { loadConfig, EngineConfigSchema } from '@db-lyon/flowkit';
229
-
230
- const { config, configDir } = loadConfig({
231
- filename: 'pipeline.yml',
232
- schema: EngineConfigSchema,
233
-
234
- // Hardcoded defaults merged under everything
235
- defaults: {
236
- tasks: {},
237
- flows: {},
238
- },
239
-
240
- // Environment name — loads pipeline.{env}.yml
241
- env: process.env.NODE_ENV,
242
- // Or read from a specific env var:
243
- // envVar: 'APP_ENV',
244
-
245
- // Directory to search (default: cwd)
246
- configDir: './config',
247
- });
248
- ```
249
-
250
- The `configDir` return value tells you where the config was loaded from.
251
-
252
- ### Environment selection
253
-
254
- You can specify the environment explicitly or via an env var:
255
-
256
- ```typescript
257
- // Explicit
258
- loadConfig({ filename: 'app.yml', schema, env: 'production' });
259
-
260
- // From env var — reads process.env.APP_ENV
261
- loadConfig({ filename: 'app.yml', schema, envVar: 'APP_ENV' });
262
- ```
263
-
264
- If both `env` and `envVar` are provided, `env` takes precedence.
265
-
266
- ## Deep merge behavior
267
-
268
- Config layers are merged using `deepMerge()`, which follows these rules:
269
-
270
- | Scenario | Behavior |
271
- |----------|----------|
272
- | Objects | Recursive key-by-key merge (override wins per-key) |
273
- | Arrays | Override replaces the base array |
274
- | Scalars | Override wins |
275
- | `null` override | Explicitly nullifies the base value |
276
- | `undefined` override | No-op (base preserved) |
277
-
278
- ### Array append mode
279
-
280
- By default, arrays in an overlay replace the base array entirely. To append instead, add `__merge: append` to the override array:
281
-
282
- ```yaml
283
- # base.yml
284
- plugins:
285
- - eslint
286
- - prettier
287
-
288
- # base.local.yml
289
- plugins:
290
- - __merge: append
291
- - my-custom-plugin
292
- ```
293
-
294
- Result: `['eslint', 'prettier', 'my-custom-plugin']`
295
-
296
- The `__merge` annotation is stripped from the final array.
297
-
298
- ## Finding config files
299
-
300
- `findConfigFile()` walks up parent directories to locate a file:
301
-
302
- ```typescript
303
- import { findConfigFile } from '@db-lyon/flowkit';
304
-
305
- const path = findConfigFile('pipeline.yml');
306
- // Searches cwd, then parent, then grandparent, etc.
307
- ```
308
-
309
- Throws if the file isn't found in any ancestor directory.
310
-
311
- ## Loading raw YAML
312
-
313
- For cases where you need the raw parsed YAML without schema validation:
314
-
315
- ```typescript
316
- import { loadRawYaml } from '@db-lyon/flowkit';
317
-
318
- const data = loadRawYaml('/path/to/file.yml');
319
- ```
320
-
321
- ## Custom schemas
322
-
323
- `EngineConfigSchema` is the minimal schema flowkit needs. You can extend it for your own config sections:
324
-
325
- ```typescript
326
- import { z } from 'zod';
327
- import { EngineConfigSchema } from '@db-lyon/flowkit';
328
-
329
- const AppConfigSchema = EngineConfigSchema.extend({
330
- database: z.object({
331
- host: z.string(),
332
- port: z.number().default(5432),
333
- }),
334
- features: z.record(z.boolean()).default({}),
335
- });
336
-
337
- const { config } = loadConfig({
338
- filename: 'app.yml',
339
- schema: AppConfigSchema,
340
- });
341
-
342
- // config.tasks, config.flows, config.database, config.features
343
- ```
1
+ # Configuration
2
+
3
+ Flowkit uses YAML files for declarative configuration, with support for layered merging, environment overlays, and schema validation via Zod.
4
+
5
+ ## YAML schema
6
+
7
+ A flowkit config file has two top-level keys:
8
+
9
+ ```yaml
10
+ tasks:
11
+ # ...
12
+ flows:
13
+ # ...
14
+ ```
15
+
16
+ Both default to `{}` if omitted.
17
+
18
+ ### Task definition
19
+
20
+ ```yaml
21
+ tasks:
22
+ my_task:
23
+ class_path: path.to.MyTask # required — how to resolve the task class
24
+ description: What this task does # optional
25
+ group: etl # optional — logical grouping label
26
+ options: # optional — default options passed to the task
27
+ key: value
28
+ ```
29
+
30
+ | Field | Type | Required | Description |
31
+ |-------|------|----------|-------------|
32
+ | `class_path` | `string` | yes | Dotted path to the task class, or a registered name |
33
+ | `description` | `string` | no | Human-readable description |
34
+ | `group` | `string` | no | Logical grouping label |
35
+ | `options` | `object` | no | Default options (merged with step-level overrides) |
36
+
37
+ ### Flow definition
38
+
39
+ ```yaml
40
+ flows:
41
+ my_flow:
42
+ description: What this flow does # required
43
+ steps:
44
+ 1:
45
+ task: my_task # reference a task by name
46
+ options: # optional — override/extend task defaults
47
+ key: override_value
48
+ 2:
49
+ flow: other_flow # reference another flow (nesting)
50
+ 3:
51
+ task: None # skip sentinel — step is always skipped
52
+ ```
53
+
54
+ | Field | Type | Required | Description |
55
+ |-------|------|----------|-------------|
56
+ | `description` | `string` | yes | Human-readable flow description |
57
+ | `steps` | `object` | yes | Steps keyed by number (execution order) |
58
+
59
+ ### Flow step
60
+
61
+ Each step must have exactly one of `task` or `flow` (mutually exclusive), unless `task: None` is used to mark a skipped step.
62
+
63
+ | Field | Type | Required | Description |
64
+ |-------|------|----------|-------------|
65
+ | `task` | `string` | one of task/flow | Task name to execute |
66
+ | `flow` | `string` | one of task/flow | Nested flow name to execute |
67
+ | `options` | `object` | no | Override options for this step |
68
+
69
+ Step numbers are sorted numerically at execution time, so `1, 2, 10` runs in that order (not lexicographic `1, 10, 2`).
70
+
71
+ ### Options merging
72
+
73
+ When a step executes, options are merged as: **task defaults** + **step overrides** (step wins):
74
+
75
+ ```yaml
76
+ tasks:
77
+ deploy:
78
+ class_path: tasks.Deploy
79
+ options:
80
+ environment: staging
81
+ notify: true
82
+
83
+ flows:
84
+ release:
85
+ description: Deploy to production
86
+ steps:
87
+ 1:
88
+ task: deploy
89
+ options:
90
+ environment: production # overrides "staging"
91
+ # notify: true is inherited from task defaults
92
+ ```
93
+
94
+ Runtime parameters passed to `FlowRunner.run({ params })` merge on top with the highest priority (**task defaults < step overrides < runtime params**).
95
+
96
+ ### Step references
97
+
98
+ Option values may reference the output of earlier steps in the same flow using `${steps.<id>.<path>}`:
99
+
100
+ ```yaml
101
+ flows:
102
+ chain:
103
+ description: Pass one step's output into the next
104
+ steps:
105
+ 1:
106
+ task: build
107
+ options:
108
+ target: plugin
109
+ 2:
110
+ task: deploy
111
+ options:
112
+ artifact: ${steps.1.path} # whole-value → raw type preserved
113
+ message: "deployed ${steps.build.version}" # embedded → stringified
114
+ ```
115
+
116
+ - **`<id>`** is a step number (`1`) or a task name (`build`, `level.place_actor`). Task names with dots are matched longest-prefix-first.
117
+ - **`<path>`** is a dot path into the step's `result.data`.
118
+ - When a task name appears in multiple steps, references resolve to the **most recently completed** one.
119
+ - A reference that fills the entire string (`"${steps.1.path}"`) is replaced with the raw value, so objects and arrays round-trip. References embedded inside a larger string are stringified.
120
+ - References that can't be resolved throw and fail the step.
121
+
122
+ References resolve just before the step runs, against the results of already-completed steps in the current flow. Nested flows have their own reference scope — they don't see their parent flow's steps.
123
+
124
+ ### Flow-level hooks
125
+
126
+ A flow can attach steps that run around the main step sequence, keyed by flow outcome:
127
+
128
+ ```yaml
129
+ flows:
130
+ deploy:
131
+ description: Deploy to prod
132
+ on_start: [ { task: notify, options: { msg: "starting" } } ]
133
+ on_success: [ { task: notify, options: { msg: "done ${steps.build.version}" } } ]
134
+ on_failure: [ { task: notify, options: { msg: "failed: ${error.message}" } } ]
135
+ finally: [ { task: cleanup } ]
136
+ steps:
137
+ 1: { task: build }
138
+ 2: { task: push }
139
+ ```
140
+
141
+ - **`on_start`** runs before any step. Its failure aborts the flow before steps execute.
142
+ - **`on_success`** runs when all steps succeed.
143
+ - **`on_failure`** runs when any step fails. It can reference the error via the `${error.*}` namespace.
144
+ - **`finally`** runs after either outcome, after `on_success`/`on_failure`.
145
+
146
+ Hook steps share the full step execution model — same task dispatch, same option merging, same runtime params, same `${steps.X.y}` resolution. Inside `on_failure` and `finally`, the `${error.message}`, `${error.name}`, `${error.stack}`, and `${error.step}` references resolve to the failure that triggered them.
147
+
148
+ Hook failures are captured in `FlowRunResult.hookErrors` but **do not** change the flow's primary success/failure outcome — a failed notifier doesn't rewrite history.
149
+
150
+ ### Per-step retry
151
+
152
+ A step can retry itself on failure:
153
+
154
+ ```yaml
155
+ steps:
156
+ 1:
157
+ task: flaky_network_call
158
+ retries: 3 # up to 4 total attempts
159
+ retryDelay: 500 # ms between attempts
160
+ retryOn: "timeout" # only retry when the error message contains this substring
161
+ ```
162
+
163
+ Omit `retryOn` to retry on any error. The number of attempts taken appears on `FlowStepResult.attempts`.
164
+
165
+ ### Rollback on failure
166
+
167
+ Mutating tasks may return a `rollback` record on their `TaskResult` pointing to an inverse task:
168
+
169
+ ```ts
170
+ return {
171
+ success: true,
172
+ data: { label: 'MyPillar' },
173
+ rollback: { taskName: 'delete_actor', payload: { label: 'MyPillar' } },
174
+ };
175
+ ```
176
+
177
+ When a flow sets `rollback_on_failure: true` (or the caller passes it on `FlowRunRunOptions`) and a later step fails, the runner invokes the collected rollback records in **reverse order**, best-effort: it continues past individual failures and reports all errors in `FlowRunResult.rollback`.
178
+
179
+ ```yaml
180
+ flows:
181
+ safe_deploy:
182
+ description: Deploy with rollback on failure
183
+ rollback_on_failure: true
184
+ steps:
185
+ 1: { task: create_thing, options: { label: A } }
186
+ 2: { task: create_thing, options: { label: B } }
187
+ 3: { task: finalize } # if this fails, thing:B then thing:A are rolled back
188
+ ```
189
+
190
+ Rollback runs after `on_failure` and before `finally`. Nested flow steps' rollback records bubble up to the parent flow so a single `rollback_on_failure` setting covers the whole tree.
191
+
192
+ The inverse task's configured `options` are resolved for `${ns.path}` references as usual, then the `payload` is merged over them. A payload is runtime data the task recorded, not configuration, so it is passed through **literally** — a `${...}` captured inside one reaches the inverse task unchanged.
193
+
194
+ Rollback runs outside any step's scope, so those configured `options` resolve against the host namespaces only. A `${steps.…}` or `${error.…}` in an inverse task's defaults has nothing to resolve against and fails that one rollback record (it is reported in `rollback.errors`; the remaining records still run). Keep inverse-task defaults to host namespaces and put step-derived values in the `payload`.
195
+
196
+ ### `agent_prompt` — LLM step
197
+
198
+ When a `LLMProvider` is attached to the context under `ctx.llm`, the built-in `agent_prompt` task invokes it:
199
+
200
+ ```yaml
201
+ steps:
202
+ 1:
203
+ task: agent_prompt
204
+ options:
205
+ system: "You are a deployment triage agent."
206
+ prompt: "Last error: ${error.message}. Suggest a fix."
207
+ model: claude-opus-4-6
208
+ maxTokens: 512
209
+ schema: { type: object, properties: { fix: { type: string } } } # optional
210
+ ```
211
+
212
+ Returns `{ text, parsed?, usage? }`. Provider failures become step failures; missing provider is a clear error.
213
+
214
+ ## Config layering
215
+
216
+ `loadConfig()` merges up to four layers, left to right:
217
+
218
+ ```
219
+ defaults (code) → base file → env overlay → local overlay
220
+ ```
221
+
222
+ | Layer | Source | Purpose |
223
+ |-------|--------|---------|
224
+ | 1. Defaults | `options.defaults` in code | Hardcoded fallbacks |
225
+ | 2. Base file | `pipeline.yml` | Project-level config (committed) |
226
+ | 3. Env overlay | `pipeline.staging.yml` | Environment-specific overrides |
227
+ | 4. Local overlay | `pipeline.local.yml` | Developer-specific overrides (gitignored) |
228
+
229
+ ### Example
230
+
231
+ ```typescript
232
+ import { loadConfig, EngineConfigSchema } from '@db-lyon/flowkit';
233
+
234
+ const { config, configDir } = loadConfig({
235
+ filename: 'pipeline.yml',
236
+ schema: EngineConfigSchema,
237
+
238
+ // Hardcoded defaults merged under everything
239
+ defaults: {
240
+ tasks: {},
241
+ flows: {},
242
+ },
243
+
244
+ // Environment name — loads pipeline.{env}.yml
245
+ env: process.env.NODE_ENV,
246
+ // Or read from a specific env var:
247
+ // envVar: 'APP_ENV',
248
+
249
+ // Directory to search (default: cwd)
250
+ configDir: './config',
251
+ });
252
+ ```
253
+
254
+ The `configDir` return value tells you where the config was loaded from.
255
+
256
+ ### Environment selection
257
+
258
+ You can specify the environment explicitly or via an env var:
259
+
260
+ ```typescript
261
+ // Explicit
262
+ loadConfig({ filename: 'app.yml', schema, env: 'production' });
263
+
264
+ // From env var — reads process.env.APP_ENV
265
+ loadConfig({ filename: 'app.yml', schema, envVar: 'APP_ENV' });
266
+ ```
267
+
268
+ If both `env` and `envVar` are provided, `env` takes precedence.
269
+
270
+ ## Deep merge behavior
271
+
272
+ Config layers are merged using `deepMerge()`, which follows these rules:
273
+
274
+ | Scenario | Behavior |
275
+ |----------|----------|
276
+ | Objects | Recursive key-by-key merge (override wins per-key) |
277
+ | Arrays | Override replaces the base array |
278
+ | Scalars | Override wins |
279
+ | `null` override | Explicitly nullifies the base value |
280
+ | `undefined` override | No-op (base preserved) |
281
+
282
+ ### Array append mode
283
+
284
+ By default, arrays in an overlay replace the base array entirely. To append instead, add `__merge: append` to the override array:
285
+
286
+ ```yaml
287
+ # base.yml
288
+ plugins:
289
+ - eslint
290
+ - prettier
291
+
292
+ # base.local.yml
293
+ plugins:
294
+ - __merge: append
295
+ - my-custom-plugin
296
+ ```
297
+
298
+ Result: `['eslint', 'prettier', 'my-custom-plugin']`
299
+
300
+ The `__merge` annotation is stripped from the final array.
301
+
302
+ ## Finding config files
303
+
304
+ `findConfigFile()` walks up parent directories to locate a file:
305
+
306
+ ```typescript
307
+ import { findConfigFile } from '@db-lyon/flowkit';
308
+
309
+ const path = findConfigFile('pipeline.yml');
310
+ // Searches cwd, then parent, then grandparent, etc.
311
+ ```
312
+
313
+ Throws if the file isn't found in any ancestor directory.
314
+
315
+ ## Loading raw YAML
316
+
317
+ For cases where you need the raw parsed YAML without schema validation:
318
+
319
+ ```typescript
320
+ import { loadRawYaml } from '@db-lyon/flowkit';
321
+
322
+ const data = loadRawYaml('/path/to/file.yml');
323
+ ```
324
+
325
+ ## Custom schemas
326
+
327
+ `EngineConfigSchema` is the minimal schema flowkit needs. You can extend it for your own config sections:
328
+
329
+ ```typescript
330
+ import { z } from 'zod';
331
+ import { EngineConfigSchema } from '@db-lyon/flowkit';
332
+
333
+ const AppConfigSchema = EngineConfigSchema.extend({
334
+ database: z.object({
335
+ host: z.string(),
336
+ port: z.number().default(5432),
337
+ }),
338
+ features: z.record(z.boolean()).default({}),
339
+ });
340
+
341
+ const { config } = loadConfig({
342
+ filename: 'app.yml',
343
+ schema: AppConfigSchema,
344
+ });
345
+
346
+ // config.tasks, config.flows, config.database, config.features
347
+ ```