@kici-dev/compiler 0.1.22 → 0.1.23

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 (39) hide show
  1. package/dist/cli.js +20 -6
  2. package/dist/commands/compile.d.ts +6 -0
  3. package/dist/commands/compile.js +6 -3
  4. package/dist/commands/docs.d.ts +8 -8
  5. package/dist/commands/docs.js +35 -16
  6. package/dist/commands/org.js +2 -2
  7. package/dist/commands/run.d.ts +16 -1
  8. package/dist/commands/run.js +86 -14
  9. package/dist/commands/test.d.ts +4 -0
  10. package/dist/commands/types.d.ts +2 -0
  11. package/dist/commands/types.js +1 -1
  12. package/dist/fixtures/describe-event.d.ts +6 -0
  13. package/dist/fixtures/describe-event.js +18 -0
  14. package/dist/fixtures/picker.d.ts +19 -0
  15. package/dist/fixtures/picker.js +64 -0
  16. package/dist/llm-context/llms-architecture.txt +1440 -0
  17. package/dist/llm-context/llms-cli.txt +2386 -0
  18. package/dist/llm-context/llms-features.txt +2389 -0
  19. package/dist/llm-context/llms-full.txt +976 -317
  20. package/dist/llm-context/llms-getting-started.txt +519 -0
  21. package/dist/llm-context/llms-patterns.txt +1324 -0
  22. package/dist/llm-context/llms-providers.txt +805 -0
  23. package/dist/llm-context/llms-sdk.txt +3725 -0
  24. package/dist/llm-context/llms.txt +13 -0
  25. package/dist/local-executor/index.js +40 -3
  26. package/dist/local-executor/job-runner.d.ts +2 -0
  27. package/dist/local-executor/job-runner.js +36 -4
  28. package/dist/local-executor/types.d.ts +2 -0
  29. package/dist/lockfile/generator.js +13 -4
  30. package/dist/remote/platform-client.d.ts +6 -0
  31. package/dist/remote/uploader.js +1 -0
  32. package/dist/templates/package-json.js +1 -1
  33. package/dist/test-runner/rule-evaluator.d.ts +1 -1
  34. package/dist/test-runner/rule-evaluator.js +2 -1
  35. package/dist/test-runner/step-context.d.ts +1 -1
  36. package/dist/test-runner/step-context.js +7 -2
  37. package/dist/types.d.ts +6 -2
  38. package/package.json +4 -4
  39. package/sbom.spdx.json +35 -35
@@ -0,0 +1,3725 @@
1
+ # KiCI SDK reference
2
+
3
+ This bundle covers: Authoring API: workflow/job/step factories, triggers, rules, matrix, runtime, caching.
4
+
5
+ ## Caching
6
+
7
+ Source: https://docs.kici.dev/user/sdk/caching/
8
+
9
+ KiCI ships a general-purpose cache for any files or directories your workflow produces — compiled artifacts, downloaded toolchains, package manager stores, build outputs. A cache entry is keyed, immutable once written, and shared across runs of the same repository so a later run can restore what an earlier run produced instead of recomputing it.
10
+
11
+ Two surfaces drive the same cache:
12
+
13
+ - **Declarative** — a `cache` field on a job or a step. The runtime restores before the work runs and saves after it succeeds, with no code in your step body.
14
+ - **Imperative** — `ctx.cache.restore(spec)` / `ctx.cache.save(spec)` inside a step body, for fine-grained control over when restore and save happen.
15
+
16
+ The cache is backed by the orchestrator's object storage. Entries are isolated per organization and per ref scope (see [Isolation](https://docs.kici.dev/user/sdk/caching/#isolation)); no other tenant can read your cache, and an untrusted/fork ref can never poison the cache a trusted branch reads.
17
+
18
+ ## CacheSpec
19
+
20
+ Both surfaces take the same shape:
21
+
22
+ ```typescript
23
+ interface CacheSpec {
24
+ /** Exact cache key. First save wins; re-saving an existing key is a no-op. */
25
+ key: string;
26
+ /** Files/directories to cache. Repo-root-relative or `~`-prefixed. */
27
+ paths: string[];
28
+ /** Ordered prefix fallbacks for partial restore; newest matching entry wins. */
29
+ restoreKeys?: string[];
30
+ }
31
+ ```
32
+
33
+ - **`key`** is the exact cache key. It is **immutable** — the first save under a given key wins, and any later save under the same exact key is a no-op (the existing entry is never overwritten). Build keys from inputs that change when the cached content should change, e.g. a hash of your lockfile: `` key: `deps-${await ctx.$`sha256sum pnpm-lock.yaml`}` ``.
34
+ - **`paths`** are the files and directories to archive, repo-root-relative or `~`-prefixed (the agent expands `~` to the workspace home). At least one path is required.
35
+ - **`restoreKeys`** are ordered **prefix** fallbacks tried only when the exact `key` misses on restore. Each prefix is matched against existing entries; the **newest** matching entry wins. This lets a run that changed its lockfile still restore the closest previous cache and rebuild incrementally.
36
+
37
+ ## Declarative cache
38
+
39
+ Add a `cache` field to a job or a step. It accepts one `CacheSpec` or an array of them. The runtime restores every spec before the job/step runs (surfaced as a `cache:restore` pseudo-step) and saves every spec after it completes successfully (surfaced as a `cache:save` pseudo-step):
40
+
41
+ ```typescript
42
+ import { job } from '@kici-dev/sdk';
43
+
44
+ job('build', {
45
+ runsOn: 'linux-x64',
46
+ cache: {
47
+ key: 'mise-tools-v1',
48
+ paths: ['~/.local/share/mise'],
49
+ },
50
+ steps: [
51
+ step('install-tools', async (ctx) => {
52
+ await ctx.$`mise install`;
53
+ }),
54
+ step('build', async (ctx) => {
55
+ await ctx.$`mise exec -- pnpm build`;
56
+ }),
57
+ ],
58
+ });
59
+ ```
60
+
61
+ Step-level cache scopes the restore/save to a single step:
62
+
63
+ ```typescript
64
+ step('deps', {
65
+ cache: { key: `npm-${lockfileHash}`, paths: ['node_modules'], restoreKeys: ['npm-'] },
66
+ run: async (ctx) => {
67
+ await ctx.$`pnpm install --frozen-lockfile`;
68
+ },
69
+ });
70
+ ```
71
+
72
+ On a cache **hit**, the archived paths are restored before the step body runs, so `pnpm install` sees a warm `node_modules`. On a **miss**, the step runs cold and the resulting paths are saved under the exact key for the next run.
73
+
74
+ ## Imperative cache (`ctx.cache`)
75
+
76
+ When you need to decide at runtime whether to restore or save — for example, save only when a build actually changed something — use the imperative API on the step context:
77
+
78
+ ```typescript
79
+ step('build', async (ctx) => {
80
+ const result = await ctx.cache.restore({
81
+ key: `build-${sourceHash}`,
82
+ paths: ['dist'],
83
+ restoreKeys: ['build-'],
84
+ });
85
+
86
+ if (result.hit) {
87
+ ctx.log.info(`restored cache (matched ${result.matchedKey})`);
88
+ }
89
+
90
+ await ctx.$`pnpm build`;
91
+
92
+ await ctx.cache.save({ key: `build-${sourceHash}`, paths: ['dist'] });
93
+ });
94
+ ```
95
+
96
+ `restore(spec)` returns `{ hit, matchedKey? }`:
97
+
98
+ - `hit` is `true` when the exact `key` matched **or** a `restoreKeys` prefix matched.
99
+ - `matchedKey` is the full key that actually matched — the exact key on a direct hit, or the full key of the matched prefix entry on a fallback hit.
100
+
101
+ `save(spec)` archives `spec.paths` under `spec.key`. Like the declarative surface, it is immutable: the first save under an exact key wins, and re-saving the same key is a no-op.
102
+
103
+ ## Restore semantics
104
+
105
+ A restore resolves in this order:
106
+
107
+ 1. **Exact key.** If an entry exists under the exact `key`, it is restored and `matchedKey === key`.
108
+ 2. **restoreKeys prefix fallback.** Each `restoreKeys` prefix is tried in order. Within a prefix, the **newest** matching entry wins; `matchedKey` is that entry's full key.
109
+ 3. **Miss.** If nothing matches, `hit` is `false` and no paths are restored.
110
+
111
+ This mirrors the familiar lockfile-hash pattern: key the entry on the exact lockfile hash, and add a `restoreKeys` prefix so a changed lockfile still restores the most recent prior cache to rebuild from.
112
+
113
+ ## Immutability
114
+
115
+ Cache keys are write-once. The **first** save under an exact key wins; every subsequent save under that same exact key is a no-op and the original bytes are preserved. To publish new content, use a new key (typically by including a content hash in the key). Immutability is what makes a cache hit safe to trust — the bytes behind a given key never change after they are first written.
116
+
117
+ ## Isolation
118
+
119
+ Each cache entry is scoped to your organization and to the ref's trust level:
120
+
121
+ - **Trusted refs** (your repository's own branches, default branch) read and write a **shared** scope visible to the whole org for that repository.
122
+ - **Untrusted / fork refs** read the shared scope as a fallback but write to an **isolated** per-run scope. A fork build can therefore benefit from a warm cache the trusted branch produced, but can never write into the shared scope — so a malicious fork cannot poison the cache a trusted branch later restores.
123
+
124
+ No tenant can read another tenant's cache; the org boundary is enforced in the cache key namespace.
125
+
126
+ ## Eviction
127
+
128
+ Cache storage is bounded per organization. Two mechanisms keep it bounded:
129
+
130
+ - **Quota** — when a save pushes the org over its byte quota (`KICI_USER_CACHE_QUOTA_BYTES`, default 5 GiB), the oldest entries are evicted until the org is back under quota.
131
+ - **TTL** — entries unused for `KICI_USER_CACHE_TTL_MS` (default 7 days) expire. The TTL refreshes on read (touch-on-read), so an actively used cache stays warm.
132
+
133
+ Both knobs are operator-configured on the orchestrator — see [orchestrator storage layout](https://docs.kici.dev/operator/orchestrator/storage-layout/).
134
+
135
+ ## Observability
136
+
137
+ Each cache restore and save surfaces in the run timeline as a `cache:restore` / `cache:save` pseudo-step, reporting the outcome (hit/miss/saved, the matched key, bytes). The same outcomes are recorded as `cache.restore` / `cache.save` run events. See [data flows](https://docs.kici.dev/architecture/data-flows/#user-facing-cache-flow) for the restore/save protocol.
138
+
139
+ ## See also
140
+
141
+ - [Core](https://docs.kici.dev/user/sdk/core/) -- `job()` / `step()` factories the `cache` field attaches to
142
+ - [Runtime](https://docs.kici.dev/user/sdk/runtime/) -- `StepContext`, where `ctx.cache` lives
143
+ - [Orchestrator storage layout](https://docs.kici.dev/operator/orchestrator/storage-layout/) -- cache prefix, quota, TTL, and eviction
144
+ - [Data flows](https://docs.kici.dev/architecture/data-flows/#user-facing-cache-flow) -- restore/save protocol and trust→scope mapping
145
+
146
+ ---
147
+
148
+ ## SDK reference: core
149
+
150
+ Source: https://docs.kici.dev/user/sdk/core/
151
+
152
+ ## Factory functions
153
+
154
+ ### workflow(name, options)
155
+
156
+ Create a workflow containing jobs.
157
+
158
+ ```typescript
159
+ function workflow(name: string, options: WorkflowOptions): Workflow;
160
+ ```
161
+
162
+ **Parameters:**
163
+
164
+ | Parameter | Type | Required | Description |
165
+ | --------------------- | ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
166
+ | `name` | `string` | yes | Unique workflow name |
167
+ | `options.jobs` | `JobOrFactory[]` | yes | Static jobs and/or dynamic job generators |
168
+ | `options.on` | `Trigger \| Trigger[]` | no | When the workflow should trigger |
169
+ | `options.rules` | `Rule[]` | no | Conditions that must pass for execution |
170
+ | `options.description` | `string` | no | Human-readable description |
171
+ | `options.hashFiles` | `string[]` | no | Extra repo-relative paths or globs mixed into the workflow content hash. Changes invalidate the source cache. |
172
+ | `options.registries` | `Registry[]` | no | Private npm registries the agent authenticates against before `npm install`. Each `tokenSecret` uses qualified `<environment>:<secret>` syntax. |
173
+ | `options.installEnv` | `string[]` | no | Qualified `<environment>:<secret>` refs projected as env vars onto the install subprocess (used with a customer-committed `.kici/.npmrc`). |
174
+ | `options.onCancel` | `HookInput` | no | Runs when the workflow is cancelled |
175
+ | `options.cleanup` | `HookInput` | no | Always runs after the workflow (success, failure, or cancel) |
176
+ | `options.onSuccess` | `HookInput` | no | Runs on workflow success |
177
+ | `options.onFailure` | `HookInput` | no | Runs on workflow failure |
178
+ | `options.concurrency` | `{ group: (ctx) => string; cancelInProgress?: boolean; max?: number }` | no | Workflow-scoped concurrency. See [Concurrency](https://docs.kici.dev/user/concurrency/). |
179
+ | `options.timeout` | `number` | no | Whole-run wall-clock timeout in milliseconds across all jobs. On breach the orchestrator cancels the run and marks it timed out. See [Timeouts](https://docs.kici.dev/user/sdk/core/#timeouts). |
180
+
181
+ **Returns:** `Workflow` -- an immutable workflow definition.
182
+
183
+ ```typescript
184
+ export default workflow('ci', {
185
+ on: [pr({ target: 'main' }), push({ branches: 'main' })],
186
+ rules: [rule('has source changes')],
187
+ jobs: [lint, test, deploy],
188
+ description: 'Main CI pipeline',
189
+ });
190
+ ```
191
+
192
+ Secret scoping happens at the job level via `environment` (see [job options](https://docs.kici.dev/user/sdk/core/#jobname-options--joboptions) and [Secrets](https://docs.kici.dev/user/secrets/)) — the workflow itself does not declare which secret environments it can read.
193
+
194
+ ### job(name, options) / job(options)
195
+
196
+ Create a job with an explicit name or auto-generated ID.
197
+
198
+ ```typescript
199
+ function job(name: string, options: JobOptions): Job;
200
+ function job(options: JobOptions): Job;
201
+ ```
202
+
203
+ **Parameters:**
204
+
205
+ | Parameter | Type | Required | Description |
206
+ | -------------------------- | --------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
207
+ | `name` | `string` | no | Job name (auto-generated UUID if omitted) |
208
+ | `options.runsOn` | `RunsOn` | yes | Runner label(s) and optional exclusions (see below) |
209
+ | `options.steps` | `StepInput[]` | yes (or use `run`) | Steps to execute in order. Mutually exclusive with `run`. |
210
+ | `options.run` | `(ctx) => Promise<unknown>` | yes (or use `steps`) | Single-step shorthand -- see [Single-step job shorthand](https://docs.kici.dev/user/sdk/core/#single-step-job-shorthand). Mutually exclusive with `steps`. |
211
+ | `options.needs` | `NeedsEntry[]` | no | Job dependencies (must complete first) -- see [Job dependencies (`needs`)](https://docs.kici.dev/user/sdk/core/#job-dependencies-needs) |
212
+ | `options.rules` | `Rule[]` | no | Conditions for conditional execution |
213
+ | `options.description` | `string` | no | Human-readable description |
214
+ | `options.matrix` | `Matrix` | no | Matrix configuration for job expansion |
215
+ | `options.include` | `MatrixInclude[]` | no | Additional matrix combinations |
216
+ | `options.exclude` | `MatrixExclude[]` | no | Matrix combinations to remove |
217
+ | `options.checkout` | `boolean` | no (default: `true`) | When `false`, agent skips git clone. Useful for deploy/notify jobs. |
218
+ | `options.container` | `string \| ContainerConfig` | no | Docker image for job execution. String form is the image name; object form adds `env`. All steps run inside the container. |
219
+ | `options.environment` | `string \| ((event) => string \| Promise<string>)` | no | Deployment environment for this job. Static string or async/dynamic function -- see [Dynamic values](https://docs.kici.dev/user/dynamic-values/). |
220
+ | `options.env` | `Record<string, string> \| ((event) => Record<string, string>)` | no | Environment variables. Static object or async/dynamic function -- see [Dynamic values](https://docs.kici.dev/user/dynamic-values/). |
221
+ | `options.concurrencyGroup` | `string \| ((event) => string \| Promise<string>)` | no | Concurrency group name (defaults to environment name) -- see [Concurrency](https://docs.kici.dev/user/concurrency/). |
222
+ | `options.onCancel` | `HookInput` | no | Hook that runs when the job is cancelled |
223
+ | `options.cleanup` | `HookInput` | no | Hook that always runs after completion |
224
+ | `options.onSuccess` | `HookInput` | no | Hook that runs when the job succeeds |
225
+ | `options.onFailure` | `HookInput` | no | Hook that runs when the job fails |
226
+ | `options.beforeStep` | `HookInput` | no | Hook that runs before each step |
227
+ | `options.afterStep` | `HookInput` | no | Hook that runs after each step |
228
+ | `options.gracePeriod` | `number` | no | Seconds before SIGKILL after SIGTERM during cancellation -- see [Hooks](https://docs.kici.dev/user/hooks/#hook-timeout). |
229
+ | `options.timeout` | `number` | no | Total job wall-clock timeout in milliseconds (init + all steps + hooks). On breach the job is aborted and reported timed out. See [Timeouts](https://docs.kici.dev/user/sdk/core/#timeouts). |
230
+ | `options.resources` | `ResourceRequest` | no | Per-job CPU / memory request and limit. See [Per-job resources](https://docs.kici.dev/user/sdk/core/#per-job-resources) below. |
231
+ | `options.init` | `InitConfig` | no | Per-job initialization run after clone, before steps -- provisions a toolchain. A generic config, a typed preset (`'mise'` / `{ mise }`), `'auto'`, or `false`. See [Per-job init](https://docs.kici.dev/user/sdk/core/#per-job-init) below. |
232
+
233
+ **Returns:** `Job` -- an immutable job definition.
234
+
235
+ ```typescript
236
+ // Named job
237
+ const build = job('build', {
238
+ runsOn: 'linux',
239
+ steps: [checkout, install, compile],
240
+ needs: [lint],
241
+ });
242
+
243
+ // Anonymous job (auto-generated UUID name)
244
+ const build = job({
245
+ runsOn: 'linux',
246
+ steps: [checkout, install],
247
+ });
248
+ ```
249
+
250
+ #### runsOn forms
251
+
252
+ A job's `runsOn` selects which agents may run it. Every label listed must be present on the agent (a subset match). It accepts three forms, and each label can be an exact string, a glob, or a regular expression (see [Targeting by pattern](https://docs.kici.dev/user/sdk/core/#targeting-by-pattern) below):
253
+
254
+ ```typescript
255
+ // 1. Simple string -- agent must have this label
256
+ runsOn: 'kici:os:linux'
257
+
258
+ // 2. Array of required labels -- agent must have ALL labels
259
+ runsOn: ['kici:os:linux', 'gpu']
260
+
261
+ // 3. Object form with exclusions -- agent must have ALL required labels
262
+ // and NONE of the excluded labels
263
+ runsOn: { labels: ['kici:os:linux'], exclude: ['kici:host:box-01'] }
264
+ ```
265
+
266
+ **The label model:**
267
+
268
+ - Every agent automatically reports `kici:os:<platform>`, `kici:arch:<cpu>`, and `kici:host:<hostname>`, so `runsOn: 'kici:os:linux'` targets any connected Linux agent without configuring labels — a fresh `kici init` matches out of the box.
269
+ - Use **custom labels** (e.g. `'gpu'`, `'prod-pool'`) — defined in your scaler's `labelSet` — to target a specific agent pool.
270
+ - You can also target scaler-assigned labels (`kici:agent:<backend>`, `kici:scaler:<name>`), but those names are deployment-specific, so custom labels are more portable.
271
+ - `runsOn` is a _requirement_ on candidate agents, never a _grant_: targeting a label only narrows the candidate set. Users cannot _set_ `kici:` labels on agents — that namespace is reserved for the scaler and the agent's self-reported platform facts — but they may freely _target_ any label in `runsOn`.
272
+
273
+ **Semantics:**
274
+
275
+ - **Required labels:** The agent must have every label in the `labels` array (or the string/array form).
276
+ - **Excluded labels:** The agent must NOT have any label in the `exclude` array. This includes auto-derived labels like `kici:arch:arm64`, `kici:os:linux`, etc.
277
+ - **Compile-time validation:** The compiler will error if any label appears in both `labels` and `exclude` (overlap detection).
278
+ - **Operator-declared mandatory labels:** Operators may mark a scaler with `mandatoryLabels` (Kubernetes-taint-style opt-in). When a scaler declares a mandatory label, a job is only allowed to land on it if `runsOn.labels` includes that label. A workflow targeting such a scaler must explicitly list the mandatory label in `runsOn`. See the [auto-scaler mandatory labels](https://docs.kici.dev/operator/orchestrator/auto-scaler/common-config/#mandatory--exclude-labels) for details.
279
+
280
+ ```typescript
281
+ // Route to any Linux agent that does NOT have the 'gpu' label
282
+ const build = job('build', {
283
+ runsOn: { labels: ['linux'], exclude: ['gpu'] },
284
+ steps: [checkout, compile],
285
+ });
286
+
287
+ // Route to arm64 Linux agents, excluding those with 'staging' label
288
+ const deploy = job('deploy', {
289
+ runsOn: { labels: ['linux', 'arch:arm64'], exclude: ['staging'] },
290
+ steps: [deployStep],
291
+ });
292
+ ```
293
+
294
+ #### Single-host selection: `pick`
295
+
296
+ When more than one agent matches a `runsOn` selector, the object form's `pick`
297
+ field controls **which** one runs the job:
298
+
299
+ ```typescript
300
+ // Always the same host across re-runs (default — can be omitted)
301
+ runsOn: { labels: ['role:db'], pick: 'deterministic' }
302
+
303
+ // Any available host (load spread)
304
+ runsOn: { labels: ['role:db'], pick: 'any' }
305
+ ```
306
+
307
+ - **`'deterministic'` (the default)** — the orchestrator sorts the matching
308
+ agents by their agent id and picks the lowest. A job that must run exactly
309
+ once on one stable host — a database migration, a backup dump — lands on the
310
+ **same** host every run. The string and array shorthand forms
311
+ (`runsOn: 'role:db'`, `runsOn: ['role:db', 'linux']`) inherit this default.
312
+ - **`'any'`** — pick any available matching agent. Use this for jobs that don't
313
+ need a stable host and benefit from spreading load across an equivalent pool.
314
+
315
+ **Trade-off:** `'deterministic'` can hot-spot — if many jobs target the same
316
+ label set, they all pin to the same lowest-id agent. Use `'any'` to spread those
317
+ across the pool; keep `'deterministic'` when reproducibility matters more than
318
+ balance. (`pick` selects among **single-agent** candidates; to fan a job out to
319
+ **every** matching host, use [`runsOnAll`](https://docs.kici.dev/user/sdk/runs-on-all/) instead.)
320
+
321
+ #### Targeting by pattern
322
+
323
+ Every selector element — in `runsOn`, in `runsOnAll`, on both the include and the exclude side — can be a plain string, a glob pattern, or a regular expression. KiCI picks the matching mode from the value itself:
324
+
325
+ - **Plain string → exact match.** `'kici:os:linux'` matches the label `kici:os:linux` and nothing else.
326
+ - **String with glob metacharacters (`*`, `?`, `[]`, `{}`) → glob.** `'kici:host:web-*'` matches every host label starting with `kici:host:web-`. `'kici:host:box-0[1-3]'` matches `box-01`, `box-02`, `box-03`.
327
+ - **`RegExp` literal → regular expression.** `/kici:host:box-0[1-3]/` matches any label the expression matches.
328
+
329
+ Both the required (include) side and the excluded side accept all three forms:
330
+
331
+ ```typescript
332
+ // Glob include + regex exclude, single-agent targeting.
333
+ const build = job('build', {
334
+ runsOn: { labels: ['kici:os:linux', 'kici:host:web-*'], exclude: [/.*-canary$/] },
335
+ steps: [compile],
336
+ });
337
+
338
+ // A bare regex picks any agent whose label the expression matches.
339
+ const probe = job('probe', {
340
+ runsOn: /kici:host:box-0[1-3]/,
341
+ steps: [smoke],
342
+ });
343
+ ```
344
+
345
+ In the `runsOnAll` array form, a leading `!` still routes an entry to the exclude side. The `!` is stripped **before** the matching mode is decided, so `'!kici:host:box-*'` is an exclude **glob** and `'!box-01'` an exclude **exact** match. Regular-expression exclusions use the structured `exclude: [/…/]` form (a `RegExp` cannot carry a `!` prefix). The structured `runsOnAll` form below targets every Linux host in the `db` or `replica` role except those whose hostname ends in `-canary`:
346
+
347
+ ```typescript
348
+ const fanout = job('deploy', {
349
+ runsOnAll: {
350
+ include: [{ all: ['kici:os:linux', 'kici:host:web-*'] }],
351
+ exclude: [/.*-canary$/],
352
+ },
353
+ run: async (ctx) => {
354
+ /* runs once per matched host */
355
+ },
356
+ });
357
+ ```
358
+
359
+ **Edge case — custom labels that contain glob metacharacters.** Because the matching mode is inferred from the value, a custom label that literally contains `*`, `?`, `[]`, or `{}` is always treated as a glob and can no longer be matched exactly. Avoid glob metacharacters in label names you intend to target by exact string.
360
+
361
+ **ReDoS protection.** Glob patterns are linear by construction. A regular expression you supply is validated for catastrophic-backtracking (ReDoS) when you run `kici compile` — a pattern that could hang on a crafted input is rejected with an error, so it never reaches the orchestrator. The orchestrator re-validates every pattern when it loads the lock file.
362
+
363
+ ### step(name, run) / step(name, options)
364
+
365
+ Create a step with a run function or with typed outputs.
366
+
367
+ ```typescript
368
+ // Simple form (no outputs)
369
+ function step(name: string, run: StepRunFn): Step;
370
+
371
+ // Full form (with outputs)
372
+ function step<TOutputs extends OutputSchema>(
373
+ name: string,
374
+ options: StepOptions<TOutputs>,
375
+ ): Step<TOutputs>;
376
+ ```
377
+
378
+ **Simple form:**
379
+
380
+ ```typescript
381
+ const checkout = step('checkout', async ({ $ }) => {
382
+ await $`git checkout`;
383
+ });
384
+ ```
385
+
386
+ **With typed outputs:**
387
+
388
+ ```typescript
389
+ import { z } from 'zod';
390
+
391
+ const build = step('build', {
392
+ outputs: {
393
+ version: z.string(),
394
+ artifacts: z.array(z.string()),
395
+ },
396
+ run: async ({ $ }) => {
397
+ await $`pnpm build`;
398
+ return { version: '1.0.0', artifacts: ['dist/main.js'] };
399
+ },
400
+ });
401
+ ```
402
+
403
+ **StepRunFn type:** `(ctx: StepContext) => Promise<void>`
404
+
405
+ **With a check facet (idempotent step):**
406
+
407
+ Add a `check` function to describe _desired state_ instead of a fixed action. When
408
+ `check` is present, `run` becomes the _apply_ function and receives the drift value
409
+ `check` returned; `summarize` (required) renders that drift for logs and the
410
+ dashboard; `whenInSync` optionally produces the step's outputs when already in sync.
411
+
412
+ ```typescript
413
+ const configureNginx = step('configure-nginx', {
414
+ check: async (ctx) => ((await inSync(ctx)) ? null : { want: DESIRED }),
415
+ summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
416
+ run: async (ctx, drift) => {
417
+ await writeConfig(drift.want);
418
+ return { reloaded: true };
419
+ },
420
+ whenInSync: async () => ({ reloaded: false }),
421
+ });
422
+ ```
423
+
424
+ A checked step can run in apply mode (converge) or `--check` preview mode (report
425
+ drift, change nothing). See [Idempotent steps and check mode](https://docs.kici.dev/user/idempotent-steps/).
426
+
427
+ ### Per-job resources
428
+
429
+ `options.resources` declares the CPU and memory the job needs. The orchestrator's auto-scaler uses these numbers to:
430
+
431
+ 1. **Bill against capacity caps** (`request`). Decides whether the job can be admitted under the per-scaler `maxAgents`, per-scaler `resourceCap`, orchestrator-wide `globalResourceCap`, and machine-pool caps.
432
+ 2. **Enforce kernel limits** (`limit`). Sets the cgroup `memory.max` and CPU quota on the running container / VM / scope.
433
+
434
+ The shape mirrors Kubernetes:
435
+
436
+ ```typescript
437
+ const heavy = job('build', {
438
+ runsOn: 'linux',
439
+ resources: {
440
+ requests: { memory: '2g', cpus: 1 },
441
+ limits: { memory: '4g', cpus: 2 },
442
+ },
443
+ steps: [...],
444
+ });
445
+ ```
446
+
447
+ Three input shapes are accepted; all normalise to the same `{ requests, limits }` pair:
448
+
449
+ | Shape | Example | Effective behavior |
450
+ | -------------- | ---------------------------------------------------------- | ------------------------------------------------ |
451
+ | Both | `{ requests: { memory: '2g' }, limits: { memory: '4g' } }` | Used as-is |
452
+ | Request only | `{ requests: { memory: '2g' } }` | `limits` mirrors the request |
453
+ | Limit only | `{ limits: { memory: '4g' } }` | `requests` mirrors the limit |
454
+ | Flat shorthand | `{ memory: '2g', cpus: 1 }` | Both `requests` and `limits` set to these values |
455
+
456
+ Memory accepts container-style suffixes: `512m`, `4g`, `2048k`. CPUs are fractional cores (`0.5`, `2`).
457
+
458
+ If `resources` is omitted, the job inherits the matched scaler's label-set or default resources (configured by the operator in `scalers.yaml`). This keeps existing workflows behaving as they did before per-job resources existed.
459
+
460
+ Per-backend kernel enforcement of `limits`:
461
+
462
+ - **Container backend** (Docker / Podman): always enforced via cgroup.
463
+ - **Firecracker backend:** always enforced. Fractional CPU rounds up to the nearest integer vCPU.
464
+ - **Bare-metal backend:** advisory by default — the scaler caps still apply, but no cgroup is created. Operators can opt in to kernel enforcement via `enforceCgroups: true` on the scaler entry.
465
+
466
+ ### Per-job init
467
+
468
+ `options.init` declares a hand-written command that runs **after the repo is cloned and before the job's steps execute**. Its purpose is to provision a repo-declared toolchain (a `mise` toolchain, a custom setup script, a language runtime) and put it on the environment every subsequent step sees.
469
+
470
+ ```typescript
471
+ import { workflow, job, step, push } from '@kici-dev/sdk';
472
+
473
+ export const build = workflow('build', {
474
+ on: [push()],
475
+ jobs: [
476
+ job('build', {
477
+ runsOn: 'linux',
478
+ init: {
479
+ run: `
480
+ set -euo pipefail
481
+ command -v mise >/dev/null || curl -fsSL https://mise.run | sh
482
+ export PATH="$HOME/.local/bin:$PATH"
483
+ mise install
484
+ mise env -s bash | sed -n 's/^export //p' >> "$KICI_ENV"
485
+ echo "$HOME/.local/share/mise/shims" >> "$KICI_PATH"
486
+ `,
487
+ cache: { key: 'mise-jq-1.7.1', paths: ['~/.local/share/mise'] },
488
+ timeout: 600_000,
489
+ },
490
+ steps: [
491
+ step('show-jq-version', async (ctx) => {
492
+ // jq is on PATH because the init phase appended the mise shims dir to $KICI_PATH.
493
+ const { stdout } = await ctx.$`jq --version`;
494
+ ctx.log.info(`jq version: ${stdout.trim()}`);
495
+ }),
496
+ ],
497
+ }),
498
+ ],
499
+ });
500
+ ```
501
+
502
+ **`GenericInitConfig` shape:**
503
+
504
+ | Field | Type | Required | Description |
505
+ | --------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
506
+ | `run` | `string` | yes | Command run after clone, before steps. Runs in the job's sandbox at the clone root. Must be a non-empty command. |
507
+ | `shell` | `string` | no | Shell used to run `run`. Defaults to `bash`. |
508
+ | `cache` | `CacheSpec` | no | Cache spec for binaries the command installs -- restored before the command, saved after on a key miss. See [Caching](https://docs.kici.dev/user/sdk/caching/). |
509
+ | `timeout` | `number` | no | Max wall-clock for this init command in milliseconds. Defaults to 10 minutes. On breach the init is aborted and the job is reported timed out. |
510
+ | `env` | `Record<string,string>` | no | Static environment variables available to the command. |
511
+
512
+ **The `$KICI_ENV` / `$KICI_PATH` handoff.** The init command does not mutate the step environment directly. Instead it writes what it wants visible to later steps to two files the agent allocates and exposes as environment variables:
513
+
514
+ - **`$KICI_ENV`** -- append one `KEY=value` line per environment variable. The agent reads the file after the command and makes each variable available to every subsequent step.
515
+ - **`$KICI_PATH`** -- append one directory per line. The agent prepends each directory to `PATH` for every subsequent step.
516
+
517
+ The agent reads both files after the command succeeds, applies the delta, and the resulting environment is visible to all steps that follow (and to any later init command).
518
+
519
+ **Failure before steps.** If the init command exits non-zero or exceeds its `timeout`, the job **fails before any step runs** -- the init surfaces as a failed `init:<n>` pseudo-step in the run timeline (alongside the step list), its logs are attached, and the step loop never executes. This makes a broken toolchain a clear, early failure rather than a confusing mid-run error.
520
+
521
+ **Arrays run in order.** Passing `GenericInitConfig[]` runs the inits sequentially; each one's `$KICI_ENV` / `$KICI_PATH` delta is applied before the next runs, so a later init sees an earlier init's tools on `PATH`. The first init to fail stops the sequence and fails the job.
522
+
523
+ **`init: false`** is an explicit opt-out; it behaves the same as omitting `init`.
524
+
525
+ #### Toolchain presets
526
+
527
+ For the common case, a typed preset removes the hand-written `run` block entirely. The agent expands the preset to the same generic init it would otherwise run.
528
+
529
+ - **`init: 'mise'`** -- zero-config. Installs mise, trusts and runs `mise install` against the committed mise config (`mise.toml` / `.mise.toml` / `.tool-versions`), hands mise's env + shims dir to subsequent steps, and caches mise's data dir under a key derived from the committed config (so a config change rotates the cache). The committed config is trusted automatically — committing it to your repo is the trust signal.
530
+ - **`init: { mise: { cache, timeout, env, shell } }`** -- the same preset with overrides. These tune the generic fields a hand-written init exposes (minus `run`): `cache: false` disables caching, a `CacheSpec` replaces the default key/paths, and `timeout` / `env` / `shell` map straight through. `init: 'mise'` is exactly `init: { mise: {} }`.
531
+
532
+ ```typescript
533
+ const build = job('build', {
534
+ runsOn: 'linux',
535
+ init: 'mise', // committed mise.toml pins the toolchain; jq, node, etc. land on PATH
536
+ steps: [
537
+ step('show-jq-version', async (ctx) => {
538
+ const { stdout } = await ctx.$`jq --version`;
539
+ ctx.log.info(`jq version: ${stdout.trim()}`);
540
+ }),
541
+ ],
542
+ });
543
+ ```
544
+
545
+ #### Auto-detect (`init: 'auto'`)
546
+
547
+ **`init: 'auto'`** detects the toolchain from committed files instead of naming a preset. The agent scans the clone root and selects a preset when a marker is present: `mise.toml` / `.mise.toml` / `.tool-versions` -> the mise preset. With no markers found, `'auto'` is a logged no-op.
548
+
549
+ `'auto'` is opt-in: an **unset** `init` does nothing even when the repo carries a `mise.toml` for local development. Use `'auto'` to enable detection and `false` to keep the explicit opt-out.
550
+
551
+ #### Cross-platform
552
+
553
+ The mise preset works on Linux, macOS, and Windows. On Linux and macOS mise is installed via its standalone install script; on Windows it is installed from its standalone GitHub release. The resulting toolchain reaches every step the same way on all three. On Windows the standalone mise binary requires the Microsoft Visual C++ runtime (`vc_redist.x64`) to be present on the agent host — install it once when provisioning a Windows agent that uses the mise preset.
554
+
555
+ ## Step & job authoring patterns
556
+
557
+ KiCI supports several authoring patterns for steps and jobs to reduce boilerplate and improve developer experience.
558
+
559
+ ### Bare function steps
560
+
561
+ Async functions are accepted directly in a job's `steps` array without wrapping in `step()`. They receive auto-generated counter names (`step-1`, `step-2`) at compile time. Return values are captured at runtime.
562
+
563
+ ```typescript
564
+ const myJob = job('example', {
565
+ runsOn: 'default',
566
+ steps: [
567
+ async (ctx) => {
568
+ ctx.log.info('hello from bare function');
569
+ },
570
+ step('named', async (ctx) => {
571
+ // Named steps keep their explicit name
572
+ }),
573
+ async (ctx) => {
574
+ // This becomes step-2 (counter skips named steps)
575
+ return { value: 42 };
576
+ },
577
+ ],
578
+ });
579
+ ```
580
+
581
+ ### Id-less steps and jobs
582
+
583
+ Steps and jobs can be created without a name. The compiler assigns counter-based IDs at compile time.
584
+
585
+ **Id-less steps:**
586
+
587
+ ```typescript
588
+ // Id-less step with just a function
589
+ const s = step(async (ctx) => {
590
+ await ctx.$`echo hello`;
591
+ });
592
+
593
+ // Id-less step with full options
594
+ const s = step({
595
+ run: async (ctx) => {
596
+ return { version: '1.0.0' };
597
+ },
598
+ timeout: 60000,
599
+ });
600
+ ```
601
+
602
+ **Id-less jobs:**
603
+
604
+ ```typescript
605
+ const deploy = job({
606
+ runsOn: 'default',
607
+ steps: [step('deploy', async (ctx) => { ... })],
608
+ });
609
+ // deploy.name is a UUID at definition time, replaced with job-1 at compile time
610
+ ```
611
+
612
+ ### Step output types
613
+
614
+ Steps have three output tiers:
615
+
616
+ | Tier | Syntax | Naming | TypeScript Type | Zod Validation |
617
+ | ---- | ------------------------------ | ---------------- | --------------------- | -------------- |
618
+ | 1 | Bare function | Auto (`step-N`) | Inferred return type | No |
619
+ | 2 | `step(name, fn)` or `step(fn)` | Explicit or auto | Inferred return type | No |
620
+ | 3 | `step(name, { outputs, run })` | Explicit or auto | Inferred + Zod schema | Yes (runtime) |
621
+
622
+ ```typescript
623
+ import { z } from '@kici-dev/sdk';
624
+
625
+ // Tier 3: step with Zod outputs (validated at runtime)
626
+ const build = step('build', {
627
+ outputs: {
628
+ version: z.string(),
629
+ artifact: z.string(),
630
+ },
631
+ run: async (ctx) => {
632
+ return { version: '2.0.0', artifact: 'dist/main.js' };
633
+ },
634
+ });
635
+ ```
636
+
637
+ ### Single-step job shorthand
638
+
639
+ Use the `run` property as an alternative to `steps` for jobs with a single step:
640
+
641
+ ```typescript
642
+ const deploy = job('deploy', {
643
+ runsOn: 'default',
644
+ run: async (ctx) => {
645
+ ctx.log.info('Deploying...');
646
+ return { url: 'https://app.example.com' };
647
+ },
648
+ });
649
+ ```
650
+
651
+ The `run` function is stored as the job's only step with an auto-generated name (`step-1`). `run` and `steps` are mutually exclusive -- providing both throws an error.
652
+
653
+ ### Timeouts
654
+
655
+ `timeout` (milliseconds) can be set at three levels. Each level caps **its own scope** independently — a workflow or job timeout is a separate wall-clock cap, **not** a default that flows down to steps.
656
+
657
+ | Level | Field | Caps | Enforced by | On breach |
658
+ | ------------ | ---------------------------- | ------------------------------------------------------ | ---------------- | ----------------------------------------------------------------- |
659
+ | **step** | `step(..., { timeout })` | A single step's wall-clock. | the agent | The step fails; falls back to the 30-minute default when unset. |
660
+ | **job** | `job(..., { timeout })` | The job's total wall-clock (init + all steps + hooks). | the agent | The job is aborted and reported failed with a "timed out" reason. |
661
+ | **workflow** | `workflow(..., { timeout })` | The whole run's wall-clock across all jobs. | the orchestrator | The run is cancelled with a "timed out" reason. |
662
+
663
+ ```typescript
664
+ export default workflow('ci', {
665
+ timeout: 1_800_000, // whole run must finish within 30 minutes
666
+ jobs: [
667
+ job('build', {
668
+ runsOn: 'linux',
669
+ timeout: 600_000, // this job (init + steps + hooks) within 10 minutes
670
+ steps: [
671
+ step('compile', {
672
+ timeout: 120_000, // this single step within 2 minutes
673
+ run: async (ctx) => {
674
+ await ctx.$`make build`;
675
+ },
676
+ }),
677
+ ],
678
+ }),
679
+ ],
680
+ });
681
+ ```
682
+
683
+ **Precedence — each scope caps its own scope.** The three timeouts are independent caps, not a fallback chain:
684
+
685
+ - A **step** with no `timeout` falls back to the 30-minute agent default, regardless of the job or workflow timeout. A job timeout never becomes a step's default.
686
+ - A **job** `timeout` bounds the job's total wall-clock (its init, every step including their own per-step timeouts, and its hooks). It does not change any step's individual cap.
687
+ - A **workflow** `timeout` is a run-level deadline. The orchestrator records it when the run starts and cancels the run if its wall-clock exceeds the timeout, even when individual jobs and steps are still within their own caps.
688
+
689
+ Workflow and job timeouts surface with a distinct "timed out" reason so the dashboard labels the run or job as timed out rather than a generic failure or cancel.
690
+
691
+ ### Retries
692
+
693
+ A step can declare a `retry` policy so a thrown attempt is re-run automatically instead of failing the job on the first error. Use it for genuinely transient failures — a flaky network call, an occasional 503, a dependency that is briefly not ready.
694
+
695
+ ```typescript
696
+ step('publish', {
697
+ retry: 3, // shorthand for { maxAttempts: 3 } with the defaults below
698
+ run: async (ctx) => {
699
+ await ctx.$`pnpm publish`;
700
+ },
701
+ });
702
+
703
+ step('fetch-token', {
704
+ retry: {
705
+ maxAttempts: 5, // total attempts including the first; must be >= 1
706
+ delayMs: 500, // base delay between attempts (default 1000)
707
+ backoff: 'exponential', // 'exponential' (default) or 'fixed'
708
+ maxDelayMs: 30_000, // cap for exponential growth (default 30000)
709
+ retryIf: (err) => err instanceof TransientError, // default: retry on any throw
710
+ },
711
+ run: async (ctx) => {
712
+ await fetchToken();
713
+ },
714
+ });
715
+ ```
716
+
717
+ - **`retry: N`** is shorthand for `{ maxAttempts: N }` with all defaults applied.
718
+ - **Defaults:** `delayMs: 1000`, `backoff: 'exponential'`, `maxDelayMs: 30000`, and "retry on any throw" when no `retryIf` is given.
719
+ - **Backoff.** With `'exponential'`, the wait after the `n`-th attempt (1-based) is `min(delayMs * 2 ** (n - 1), maxDelayMs)` — 1s, 2s, 4s, … capped at `maxDelayMs`. With `'fixed'`, the wait is always `delayMs`.
720
+ - **`retryIf(err)`** runs against the thrown error before each retry; return `false` to stop retrying immediately and let the failure stand.
721
+ - **Timeout is per-attempt.** Each attempt gets the step's full `timeout` budget — a timed-out attempt counts as one failed attempt and is retried while attempts remain. The total wall-clock can therefore approach `maxAttempts * (timeout + delay)`, so keep `maxAttempts` and `maxDelayMs` sane (the job-level `timeout` still bounds the whole job).
722
+ - **Retries exhaust before `continueOnError`.** A step with both retries first; only the _final_ failure is then softened to a warning by `continueOnError`.
723
+
724
+ `retry` works identically under `kici run local` and on a remote agent, and applies to dynamically-generated job steps too. The `retryIf` predicate is an in-memory function: it is honored at execution time but never serialized into the lock file.
725
+
726
+ > **Retry vs. wait-until-condition.** `retry` re-runs a step that _throws_. To poll until a condition becomes true (a port listening, a `/health` endpoint returning 200, a unit becoming active), use [`waitForStep`](https://docs.kici.dev/user/sdk/wait-for/) instead — it is purpose-built for declarative wait-for-condition with intervals, a timeout, and on-timeout handling.
727
+
728
+ ### Output chaining
729
+
730
+ Steps and jobs can access outputs from preceding steps/jobs using two patterns.
731
+
732
+ **Within-job output chaining:**
733
+
734
+ ```typescript
735
+ const buildStep = step('build', async (ctx) => {
736
+ return { version: '2.0.0' };
737
+ });
738
+
739
+ const lint = async (ctx) => {
740
+ return { warnings: 0 };
741
+ };
742
+
743
+ const pipeline = job('pipeline', {
744
+ runsOn: 'default',
745
+ steps: [
746
+ buildStep,
747
+ lint,
748
+ step(async (ctx) => {
749
+ // Pattern 1: .result proxy on Step objects
750
+ const version = buildStep.result.version;
751
+
752
+ // Pattern 2: ctx.outputsOf() for Step or bare function references
753
+ const lintOutputs = ctx.outputsOf(lint);
754
+ console.log(lintOutputs.warnings); // 0
755
+ }),
756
+ ],
757
+ });
758
+ ```
759
+
760
+ **Cross-job output chaining:**
761
+
762
+ ```typescript
763
+ const setup = job('setup', {
764
+ runsOn: 'default',
765
+ run: async (ctx) => {
766
+ return { env: 'production' };
767
+ },
768
+ });
769
+
770
+ const build = job('build', {
771
+ runsOn: 'default',
772
+ needs: [setup],
773
+ steps: [
774
+ step('compile', async (ctx) => {
775
+ return { version: '2.0.0' };
776
+ }),
777
+ ],
778
+ });
779
+
780
+ const deploy = job('deploy', {
781
+ runsOn: 'default',
782
+ needs: [build],
783
+ steps: [
784
+ step(async (ctx) => {
785
+ // Multi-step job: jobRef.result.stepName.field
786
+ const version = build.result.compile.version;
787
+
788
+ // Single-step job (run shorthand): jobRef.result.field
789
+ const env = setup.result.env;
790
+
791
+ // Explicit context method
792
+ const buildOutputs = ctx.jobOutputs(build);
793
+ }),
794
+ ],
795
+ });
796
+ ```
797
+
798
+ **Access patterns summary:**
799
+
800
+ | Pattern | Scope | Notes |
801
+ | ------------------------------ | ------------------------- | ----------------------------- |
802
+ | `stepRef.result.field` | Within-job | Proxy on Step object |
803
+ | `ctx.outputsOf(stepRef)` | Within-job | Works with bare function refs |
804
+ | `jobRef.result.stepName.field` | Cross-job (multi-step) | Proxy on Job object |
805
+ | `jobRef.result.field` | Cross-job (run shorthand) | Flat for single-step jobs |
806
+ | `ctx.jobOutputs(jobRef)` | Cross-job | Explicit context method |
807
+
808
+ **Important:** `needs` must be declared explicitly. Output chaining does not auto-infer dependencies -- you must list job dependencies in `needs` even if you access their outputs via `.result`.
809
+
810
+ Cross-job output chaining works in both local test mode (`kici test`) and remote pipeline execution. The orchestrator's needs-aware dispatch scheduler guarantees upstream jobs reach a terminal state before downstream jobs dispatch, and upstream outputs are transported to the downstream agent sandbox via the `upstreamJobOutputs` field on `job.dispatch`. See [needs-scheduler](https://docs.kici.dev/architecture/execution/needs-scheduler/) for the full dispatch semantics.
811
+
812
+ ### Job dependencies (`needs`)
813
+
814
+ The `needs` array accepts four entry forms. Mix freely within the same array.
815
+
816
+ ```typescript
817
+ // 1. Reference by Job object (type-safe, preferred)
818
+ const test = job('test', { needs: [lint], ... });
819
+
820
+ // 2. Reference by string name
821
+ const test = job('test', { needs: ['lint'], ... });
822
+
823
+ // 3. Object form with a per-edge run condition (`when`)
824
+ const cleanup = job('cleanup', {
825
+ needs: [{ name: 'build', when: 'always' }],
826
+ ...
827
+ });
828
+
829
+ // 4. Dynamic group reference (for static jobs that depend on a dynamicJob group)
830
+ const deploy = job('deploy', {
831
+ needs: [dynamicGroup('test-shards')],
832
+ ...
833
+ });
834
+ ```
835
+
836
+ **Run condition (`when`):** controls when a downstream edge is satisfied, based on the upstream's terminal status. `when` is keyword sugar (or a raw status-set) that resolves at compile time to the set of upstream terminal statuses that satisfy the edge. The downstream edge is satisfied when the upstream's terminal status is a member of that set.
837
+
838
+ | Keyword | Satisfied when the upstream is… | Use for |
839
+ | ------------------------ | ------------------------------- | ------------------------------------------- |
840
+ | `'on-success'` (default) | `success` | normal dependencies |
841
+ | `'always'` | any terminal status | cleanup / notification / teardown jobs |
842
+ | `'on-skip'` | `success` or `skipped` | continue when an upstream was narrowed out |
843
+ | `'on-failure'` | `failed` or `timed_out_stale` | error-handler jobs that run only on failure |
844
+
845
+ For full control, pass a raw status-set instead of a keyword: `when: ['skipped', 'failed', 'timed_out_stale']`. The valid members are the terminal job statuses: `success`, `failed`, `cancelled`, `skipped`, `timed_out_stale`, `drift_dropped`.
846
+
847
+ String and `Job`-reference entries default to `when: 'on-success'`. To override, use the object form (`{ name, when }` for static upstreams, `{ group, when }` for dynamic groups -- `dynamicGroup(name, { when: 'always' })` produces the latter).
848
+
849
+ When an upstream's terminal status is **not** in the edge's set, the downstream transitions directly to `skipped`. Because a skipped job is itself terminal, this propagates transitively: each downstream's `when` set governs whether the skip cascades further.
850
+
851
+ **Dispatch gate:** `needs` is a hard dispatch gate. A job dispatches only after every upstream in its `needs` array reaches a terminal status that satisfies that edge's `when` set. Root jobs (empty `needs`, no dynamic group refs) dispatch immediately. The scheduler is DB-backed and fully recovers across orchestrator restarts.
852
+
853
+ **Reading an upstream's status in a step:** inside a running job, `ctx.needs.<job>.status` exposes each upstream's terminal status (`success`, `failed`, `skipped`, …) and `ctx.needs.<job>.result` its outputs. A group / matrix / `runsOnAll` fan-out upstream is an ordered array of `{ name, result, status }`, one per child. Use this to branch in TypeScript:
854
+
855
+ ```typescript
856
+ job('report', {
857
+ needs: [{ name: 'probe', when: 'always' }],
858
+ run: async (ctx) => {
859
+ if (ctx.needs.probe.status === 'failed') await fileIncident(ctx.needs.probe.result);
860
+ else await publish(ctx.needs.probe.result);
861
+ },
862
+ });
863
+ ```
864
+
865
+ For an arbitrary outcome-based gate that prevents a job from dispatching at all, use a result-aware `dynamicJob` that returns `[]` or `[job]` based on `ctx.needs.<job>.status` — see [Dynamic jobs](https://docs.kici.dev/architecture/execution/dynamic-jobs/).
866
+
867
+ **DAG validation:** three-layer cycle detection.
868
+
869
+ 1. Compile time: `validateDag` (see below) catches static-to-static cycles.
870
+ 2. Eval time: after dynamic jobs are generated, a full topological sort runs on the resolved graph. Cycles reject the run with a clear error.
871
+ 3. Runtime: a defensive invariant check flags stuck jobs as an internal-bug backstop.
872
+
873
+ ### dynamicGroup(name, options?)
874
+
875
+ Create a reference to a dynamic job group, for use inside a static job's `needs` array.
876
+
877
+ ```typescript
878
+ function dynamicGroup(
879
+ name: string,
880
+ options?: { when?: 'on-success' | 'always' | 'on-skip' | 'on-failure' | string[] },
881
+ ): DynamicGroupRef;
882
+ ```
883
+
884
+ Use when a static downstream must wait for every generated job tagged with a given group name to complete. If the dynamic group produces zero jobs, the downstream dispatches immediately (empty group satisfies all upstreams).
885
+
886
+ ```typescript
887
+ const shardedTests = dynamicJob('test-shards', async (ctx) => {
888
+ return ctx.shardIndices.map((i) =>
889
+ job(`test-shard-${i}`, { runsOn: 'linux', run: async () => {} }),
890
+ );
891
+ });
892
+
893
+ const deploy = job('deploy', {
894
+ runsOn: 'linux',
895
+ needs: [dynamicGroup('test-shards')],
896
+ run: async () => {
897
+ // Runs after ALL test-shards jobs have reached a terminal state
898
+ },
899
+ });
900
+ ```
901
+
902
+ ### dynamicJob(groupName, fn)
903
+
904
+ Tag a dynamic job generator function with a group name so other jobs can reference it via `dynamicGroup()`.
905
+
906
+ ```typescript
907
+ function dynamicJob(groupName: string, fn: DynamicJobFn): DynamicJobFn;
908
+ ```
909
+
910
+ The generator runs twice: once in the init phase (to register expected job names) and once inside the executing agent (to produce the actual jobs). Mismatches between the two evaluations are detected as determinism drift -- see [dynamic-jobs](https://docs.kici.dev/architecture/execution/dynamic-jobs/).
911
+
912
+ ### Auto-generated IDs
913
+
914
+ Unnamed steps and jobs receive counter-based IDs at compile time:
915
+
916
+ - **Steps:** `step-1`, `step-2`, etc. Counter is scoped per job and only increments for unnamed entries. Named steps do not consume counter values.
917
+ - **Jobs:** `job-1`, `job-2`, etc. Counter is scoped per workflow and only increments for unnamed entries.
918
+
919
+ These IDs are stable as long as the order of unnamed entries does not change. Adding or removing unnamed entries shifts subsequent IDs.
920
+
921
+ ---
922
+
923
+ ## Event payload reference
924
+
925
+ Source: https://docs.kici.dev/user/sdk/event-payloads/
926
+
927
+ <!-- Generated by scripts/docs-gen-event-payloads.ts — do not edit by hand. Regenerate: pnpm docs:gen:events -->
928
+
929
+ ## The envelope
930
+
931
+ The normalized event envelope is the single event contract in KiCI. Rules receive it as `ctx.event`, and every dynamic function — `environment:`, `env:`, and `concurrencyGroup:` resolvers, generated jobs, and a workflow's `concurrency.group` — receives the same envelope as its argument.
932
+
933
+ Narrow on the `type` field to branch per trigger kind (`if (event.type === 'push')`). The raw provider webhook body is nested at `payload`; the typed variants below describe its shape per event type.
934
+
935
+ These fields are present on every envelope (the `EventBase` shape):
936
+
937
+ | Field | Type | Description |
938
+ | ----------------- | ------------------------- | ------------------------------------------------------------------------------- |
939
+ | `type` | `string` | Normalized event type discriminant. |
940
+ | `action?` | `string` | Sub-action (e.g. 'opened', 'created', 'submitted'). |
941
+ | `targetBranch?` | `string` | Target branch (push target, PR base, or default branch). |
942
+ | `sourceBranch?` | `string` | Source branch (PR head branch). Only set for PR-like events. |
943
+ | `provider?` | `string` | Provider that originated this event. |
944
+ | `isForkPR?` | `boolean` | Whether this PR comes from a fork. Only set for PR-like events. |
945
+ | `baseBranch?` | `string` | Base branch ref for PR events. |
946
+ | `senderUsername?` | `string` | Sender username from the webhook payload. |
947
+ | `sourceRepo?` | `string` | Repository identifier (e.g. "owner/repo"). |
948
+ | `changedFiles?` | `string[]` | Files changed in this event (for path filtering). |
949
+ | `payload?` | `Record<string, unknown>` | Raw webhook payload from the provider. May be absent in flattened event forms. |
950
+ | `[key: string]` | `unknown` | Index signature for backward compatibility — untyped fields resolve to unknown. |
951
+
952
+ ## Event types
953
+
954
+ One section per member of the `EventPayload` union. The heading is the `type` literal; the table lists the fields of that event's `payload` property when it declares a typed shape.
955
+
956
+ ### `pull_request`
957
+
958
+ Carried by `PullRequestEventPayload`. The `payload` property has the following shape:
959
+
960
+ | Field | Type | Description |
961
+ | --------------- | ------------------- | ----------- |
962
+ | `action` | `string` | |
963
+ | `number` | `number` | |
964
+ | `pull_request` | `GitHubPullRequest` | |
965
+ | `repository` | `GitHubRepository` | |
966
+ | `sender` | `GitHubUser` | |
967
+ | `[key: string]` | `unknown` | |
968
+
969
+ ### `push`
970
+
971
+ Carried by `PushEventPayload`. The `payload` property has the following shape:
972
+
973
+ | Field | Type | Description |
974
+ | --------------- | ------------------ | ----------- |
975
+ | `ref` | `string` | |
976
+ | `after` | `string` | |
977
+ | `before` | `string` | |
978
+ | `head_commit?` | `GitHubCommit` | |
979
+ | `commits?` | `GitHubCommit[]` | |
980
+ | `repository` | `GitHubRepository` | |
981
+ | `sender?` | `GitHubUser` | |
982
+ | `forced?` | `boolean` | |
983
+ | `[key: string]` | `unknown` | |
984
+
985
+ ### `tag`
986
+
987
+ Carried by `TagEventPayload`. The `payload` property has the following shape:
988
+
989
+ | Field | Type | Description |
990
+ | --------------- | ------------------ | ----------- |
991
+ | `ref` | `string` | |
992
+ | `after` | `string` | |
993
+ | `repository` | `GitHubRepository` | |
994
+ | `sender?` | `GitHubUser` | |
995
+ | `[key: string]` | `unknown` | |
996
+
997
+ ### `comment`
998
+
999
+ Carried by `CommentEventPayload`. The `payload` property has the following shape:
1000
+
1001
+ | Field | Type | Description |
1002
+ | --------------- | ------------------------------------------------------------------------------------ | ----------- |
1003
+ | `action` | `string` | |
1004
+ | `comment` | `GitHubComment` | |
1005
+ | `issue?` | `{ number: number; title?: string; pull_request?: unknown; [key: string]: unknown }` | |
1006
+ | `repository` | `GitHubRepository` | |
1007
+ | `sender` | `GitHubUser` | |
1008
+ | `[key: string]` | `unknown` | |
1009
+
1010
+ ### `review`
1011
+
1012
+ Carried by `ReviewEventPayload`. The `payload` property has the following shape:
1013
+
1014
+ | Field | Type | Description |
1015
+ | --------------- | ------------------- | ----------- |
1016
+ | `action` | `string` | |
1017
+ | `review` | `GitHubReview` | |
1018
+ | `pull_request` | `GitHubPullRequest` | |
1019
+ | `repository` | `GitHubRepository` | |
1020
+ | `sender` | `GitHubUser` | |
1021
+ | `[key: string]` | `unknown` | |
1022
+
1023
+ ### `review_comment`
1024
+
1025
+ Carried by `ReviewCommentEventPayload`. The `payload` property has the following shape:
1026
+
1027
+ | Field | Type | Description |
1028
+ | --------------- | ------------------- | ----------- |
1029
+ | `action` | `string` | |
1030
+ | `comment` | `GitHubComment` | |
1031
+ | `pull_request` | `GitHubPullRequest` | |
1032
+ | `repository` | `GitHubRepository` | |
1033
+ | `sender` | `GitHubUser` | |
1034
+ | `[key: string]` | `unknown` | |
1035
+
1036
+ ### `release`
1037
+
1038
+ Carried by `ReleaseEventPayload`. The `payload` property has the following shape:
1039
+
1040
+ | Field | Type | Description |
1041
+ | --------------- | ------------------ | ----------- |
1042
+ | `action` | `string` | |
1043
+ | `release` | `GitHubRelease` | |
1044
+ | `repository` | `GitHubRepository` | |
1045
+ | `sender` | `GitHubUser` | |
1046
+ | `[key: string]` | `unknown` | |
1047
+
1048
+ ### `dispatch`
1049
+
1050
+ Carried by `DispatchEventPayload`. The `payload` property has the following shape:
1051
+
1052
+ | Field | Type | Description |
1053
+ | ----------------- | ------------------------- | ----------- |
1054
+ | `action` | `string` | |
1055
+ | `client_payload?` | `Record<string, unknown>` | |
1056
+ | `repository` | `GitHubRepository` | |
1057
+ | `sender?` | `GitHubUser` | |
1058
+ | `[key: string]` | `unknown` | |
1059
+
1060
+ ### `create`
1061
+
1062
+ Carried by `CreateEventPayload`. The `payload` property has the following shape:
1063
+
1064
+ | Field | Type | Description |
1065
+ | --------------- | ------------------ | ----------- |
1066
+ | `ref` | `string` | |
1067
+ | `ref_type` | `string` | |
1068
+ | `repository` | `GitHubRepository` | |
1069
+ | `sender` | `GitHubUser` | |
1070
+ | `[key: string]` | `unknown` | |
1071
+
1072
+ ### `delete`
1073
+
1074
+ Carried by `DeleteEventPayload`. The `payload` property has the following shape:
1075
+
1076
+ | Field | Type | Description |
1077
+ | --------------- | ------------------ | ----------- |
1078
+ | `ref` | `string` | |
1079
+ | `ref_type` | `string` | |
1080
+ | `repository` | `GitHubRepository` | |
1081
+ | `sender` | `GitHubUser` | |
1082
+ | `[key: string]` | `unknown` | |
1083
+
1084
+ ### `status`
1085
+
1086
+ Carried by `StatusEventPayload`. The `payload` property has the following shape:
1087
+
1088
+ | Field | Type | Description |
1089
+ | --------------- | ------------------------------------------------- | ----------- |
1090
+ | `state` | `string` | |
1091
+ | `sha` | `string` | |
1092
+ | `context` | `string` | |
1093
+ | `description?` | `string` | |
1094
+ | `target_url?` | `string` | |
1095
+ | `branches?` | `Array<{ name: string; [key: string]: unknown }>` | |
1096
+ | `repository` | `GitHubRepository` | |
1097
+ | `sender` | `GitHubUser` | |
1098
+ | `[key: string]` | `unknown` | |
1099
+
1100
+ ### `workflow_run`
1101
+
1102
+ Carried by `WorkflowRunEventPayload`. The `payload` property has the following shape:
1103
+
1104
+ | Field | Type | Description |
1105
+ | --------------- | ------------------------------------------------------------------------------------------------------ | ----------- |
1106
+ | `action` | `string` | |
1107
+ | `workflow_run` | `{ head_branch: string; name: string; conclusion?: string; status?: string; [key: string]: unknown; }` | |
1108
+ | `repository` | `GitHubRepository` | |
1109
+ | `sender` | `GitHubUser` | |
1110
+ | `[key: string]` | `unknown` | |
1111
+
1112
+ ### `fork`
1113
+
1114
+ Carried by `ForkEventPayload`. The `payload` property has the following shape:
1115
+
1116
+ | Field | Type | Description |
1117
+ | --------------- | ----------------------------------------------- | ----------- |
1118
+ | `forkee` | `{ full_name: string; [key: string]: unknown }` | |
1119
+ | `repository` | `GitHubRepository` | |
1120
+ | `sender` | `GitHubUser` | |
1121
+ | `[key: string]` | `unknown` | |
1122
+
1123
+ ### `star`
1124
+
1125
+ Carried by `StarEventPayload`. The `payload` property has the following shape:
1126
+
1127
+ | Field | Type | Description |
1128
+ | --------------- | ------------------ | ----------- |
1129
+ | `action` | `string` | |
1130
+ | `repository` | `GitHubRepository` | |
1131
+ | `sender` | `GitHubUser` | |
1132
+ | `[key: string]` | `unknown` | |
1133
+
1134
+ ### `watch`
1135
+
1136
+ Carried by `WatchEventPayload`. The `payload` property has the following shape:
1137
+
1138
+ | Field | Type | Description |
1139
+ | --------------- | ------------------ | ----------- |
1140
+ | `action` | `string` | |
1141
+ | `repository` | `GitHubRepository` | |
1142
+ | `sender` | `GitHubUser` | |
1143
+ | `[key: string]` | `unknown` | |
1144
+
1145
+ ### `webhook`
1146
+
1147
+ Carried by `WebhookEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1148
+
1149
+ ### `kici_event`
1150
+
1151
+ Carried by `KiciEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1152
+
1153
+ ### `workflow_complete`
1154
+
1155
+ Carried by `WorkflowCompleteEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1156
+
1157
+ ### `job_complete`
1158
+
1159
+ Carried by `JobCompleteEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1160
+
1161
+ ### `generic_webhook`
1162
+
1163
+ Carried by `GenericWebhookEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1164
+
1165
+ ### `schedule`
1166
+
1167
+ Carried by `ScheduleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1168
+
1169
+ ### `lifecycle`
1170
+
1171
+ Carried by `LifecycleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1172
+
1173
+ ### `rerun`
1174
+
1175
+ Carried by `RerunEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1176
+
1177
+ ### `manual_schedule`
1178
+
1179
+ Carried by `ManualScheduleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1180
+
1181
+ ### `unknown`
1182
+
1183
+ Carried by `UnknownEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
1184
+
1185
+ ## Shared GitHub object shapes
1186
+
1187
+ The typed `payload` shapes above reference these partial GitHub object types. Each lists only the commonly accessed fields; the index signature on every shape resolves any other field to `unknown`.
1188
+
1189
+ ### `GitHubRepository`
1190
+
1191
+ | Field | Type | Description |
1192
+ | ---------------- | ------------------------------------------- | ----------- |
1193
+ | `full_name` | `string` | |
1194
+ | `default_branch` | `string` | |
1195
+ | `name?` | `string` | |
1196
+ | `owner?` | `{ login: string; [key: string]: unknown }` | |
1197
+ | `private?` | `boolean` | |
1198
+ | `[key: string]` | `unknown` | |
1199
+
1200
+ ### `GitHubUser`
1201
+
1202
+ | Field | Type | Description |
1203
+ | --------------- | --------- | ----------- |
1204
+ | `login` | `string` | |
1205
+ | `id?` | `number` | |
1206
+ | `[key: string]` | `unknown` | |
1207
+
1208
+ ### `GitHubPullRequest`
1209
+
1210
+ | Field | Type | Description |
1211
+ | --------------- | ------------------------------------------------------------------------------------------------------------- | ----------- |
1212
+ | `number` | `number` | |
1213
+ | `draft?` | `boolean` | |
1214
+ | `title?` | `string` | |
1215
+ | `body?` | `string` | |
1216
+ | `state?` | `string` | |
1217
+ | `merged?` | `boolean` | |
1218
+ | `head` | `{ ref: string; sha: string; repo?: { full_name: string; [key: string]: unknown }; [key: string]: unknown; }` | |
1219
+ | `base` | `{ ref: string; repo?: { full_name: string; [key: string]: unknown }; [key: string]: unknown; }` | |
1220
+ | `user?` | `GitHubUser` | |
1221
+ | `labels?` | `Array<{ name: string; [key: string]: unknown }>` | |
1222
+ | `[key: string]` | `unknown` | |
1223
+
1224
+ ### `GitHubCommit`
1225
+
1226
+ | Field | Type | Description |
1227
+ | --------------- | ------------------------------------------------------------------------------ | ----------- |
1228
+ | `id` | `string` | |
1229
+ | `message` | `string` | |
1230
+ | `author?` | `{ name?: string; email?: string; username?: string; [key: string]: unknown }` | |
1231
+ | `timestamp?` | `string` | |
1232
+ | `added?` | `string[]` | |
1233
+ | `removed?` | `string[]` | |
1234
+ | `modified?` | `string[]` | |
1235
+ | `[key: string]` | `unknown` | |
1236
+
1237
+ ### `GitHubComment`
1238
+
1239
+ | Field | Type | Description |
1240
+ | --------------- | ------------ | ----------- |
1241
+ | `id` | `number` | |
1242
+ | `body` | `string` | |
1243
+ | `user` | `GitHubUser` | |
1244
+ | `[key: string]` | `unknown` | |
1245
+
1246
+ ### `GitHubReview`
1247
+
1248
+ | Field | Type | Description |
1249
+ | --------------- | ------------ | ----------- |
1250
+ | `id` | `number` | |
1251
+ | `state` | `string` | |
1252
+ | `body?` | `string` | |
1253
+ | `user` | `GitHubUser` | |
1254
+ | `[key: string]` | `unknown` | |
1255
+
1256
+ ### `GitHubRelease`
1257
+
1258
+ | Field | Type | Description |
1259
+ | ------------------- | --------- | ----------- |
1260
+ | `id` | `number` | |
1261
+ | `tag_name` | `string` | |
1262
+ | `name?` | `string` | |
1263
+ | `body?` | `string` | |
1264
+ | `draft?` | `boolean` | |
1265
+ | `prerelease?` | `boolean` | |
1266
+ | `target_commitish?` | `string` | |
1267
+ | `[key: string]` | `unknown` | |
1268
+
1269
+ ---
1270
+
1271
+ ## SDK reference: idempotent
1272
+
1273
+ Source: https://docs.kici.dev/user/sdk/idempotent/
1274
+
1275
+ The SDK exposes three idempotency helpers — a generic function `idempotent()`, the step factory `idempotentStep()`, and its check-mode-aware sibling `checkStep()` — for the common case where a workflow step should:
1276
+
1277
+ 1. **Check** whether the desired state is already in place.
1278
+ 2. **Apply** the change only when drift is detected.
1279
+ 3. **Surface** the resource (or its identifier) on both branches, so downstream steps don't need to know whether work happened or was skipped.
1280
+
1281
+ `idempotent()` and `idempotentStep()` wrap the same underlying runner and always apply on drift. Pick `idempotentStep()` when the operation is the whole job of a step; use `idempotent()` from anywhere — inside a multi-action step, a hook, or a bare async function. Pick `checkStep()` when the step should respect the run-level check mode — `kici run --check` previews the drift without applying it.
1282
+
1283
+ ## `idempotent(options)`
1284
+
1285
+ Run a single check / apply cycle and return a discriminated result describing the outcome.
1286
+
1287
+ ### Parameters
1288
+
1289
+ | Name | Type | Required | Description |
1290
+ | ------------ | -------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ |
1291
+ | `name` | `string` | No | Label that appears in log lines. Defaults to `'idempotent'`. |
1292
+ | `check` | `() => Promise<TDrift \| null>` | Yes | Read-only inspection. Return `null` when the system is already in the desired state. |
1293
+ | `apply` | `(drift: TDrift) => Promise<TApplied>` | Yes | Brings the system to the desired state when `check()` returned a non-null drift value. |
1294
+ | `whenInSync` | `() => Promise<TInSync>` | No | Runs when `check()` returned `null`. Use it to fetch the already-satisfied resource. |
1295
+ | `summarize` | `(drift: TDrift) => string` | No | Human-readable, multi-line summary of what `apply()` would do. Defaults to a JSON dump of drift. |
1296
+ | `log` | `(line: string) => void` | No | Sink for status lines. Defaults to `console.log`. |
1297
+
1298
+ ### Result
1299
+
1300
+ `idempotent()` resolves to a discriminated `IdempotentResult` union:
1301
+
1302
+ | Outcome | `drift` | `result` |
1303
+ | ----------- | -------- | ------------------------------------------------ |
1304
+ | `'skipped'` | `null` | The `whenInSync()` return value, or `undefined`. |
1305
+ | `'applied'` | `TDrift` | The `apply()` return value. |
1306
+
1307
+ Narrow on `result.outcome` before reading `result.result` to get the correct typed shape.
1308
+
1309
+ ### Example
1310
+
1311
+ ```typescript
1312
+ import { idempotent } from '@kici-dev/sdk';
1313
+
1314
+ const result = await idempotent({
1315
+ name: 'create-dns-record',
1316
+ check: async () => {
1317
+ const existing = await dns.getRecord('api.example.com');
1318
+ return existing ? null : { fqdn: 'api.example.com', target: '203.0.113.10' };
1319
+ },
1320
+ whenInSync: async () => {
1321
+ const existing = await dns.getRecord('api.example.com');
1322
+ return { id: existing.id };
1323
+ },
1324
+ apply: async (drift) => {
1325
+ const created = await dns.createRecord(drift.fqdn, drift.target);
1326
+ return { id: created.id };
1327
+ },
1328
+ summarize: (drift) => `Create A record ${drift.fqdn} → ${drift.target}`,
1329
+ });
1330
+
1331
+ // Both branches surface the record id.
1332
+ const recordId = result.result.id;
1333
+ ```
1334
+
1335
+ ## `idempotentStep(name, options)`
1336
+
1337
+ A factory returning an SDK `Step` whose `run` body executes `idempotent(...)` and routes status lines through the step's structured logger.
1338
+
1339
+ ### Parameters
1340
+
1341
+ | Name | Type | Required | Description |
1342
+ | --------- | ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------ |
1343
+ | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
1344
+ | `options` | `Omit<IdempotentOptions, 'name' \| 'log'>` | Yes | Same shape as `idempotent()` minus `name` (already provided) and `log` (provided by the step context). |
1345
+
1346
+ ### Result
1347
+
1348
+ `idempotentStep(...)` returns `Step<IdempotentResult<TDrift, TInSync, TApplied>>`. Other steps can consume the result through the standard step output mechanisms.
1349
+
1350
+ ### Example
1351
+
1352
+ ```typescript
1353
+ import { idempotentStep, job } from '@kici-dev/sdk';
1354
+
1355
+ const ensureBucket = idempotentStep('ensure-bucket', {
1356
+ check: async () => {
1357
+ const exists = await s3.bucketExists('app-cache');
1358
+ return exists ? null : { bucket: 'app-cache', region: 'eu-central-1' };
1359
+ },
1360
+ whenInSync: async () => ({ arn: 'arn:aws:s3:::app-cache' }),
1361
+ apply: async (drift) => {
1362
+ const created = await s3.createBucket(drift.bucket, drift.region);
1363
+ return { arn: created.arn };
1364
+ },
1365
+ summarize: (drift) => `Create S3 bucket ${drift.bucket} in ${drift.region}`,
1366
+ });
1367
+
1368
+ export const setup = job('setup', {
1369
+ runsOn: 'linux',
1370
+ steps: [ensureBucket],
1371
+ });
1372
+ ```
1373
+
1374
+ ## `checkStep(name, options)`
1375
+
1376
+ The check-mode-aware sibling of `idempotentStep()`. It takes the **same option shape**, but behaves differently when a run is started in check mode (`kici run --check`):
1377
+
1378
+ | Factory | Behavior under `kici run --check` |
1379
+ | ---------------- | ----------------------------------------- |
1380
+ | `idempotentStep` | always applies on drift |
1381
+ | `checkStep` | reports drift, applies only in apply mode |
1382
+
1383
+ Use `checkStep()` for deploy-style steps where you want a dry-run preview of pending changes before committing them, and `idempotentStep()` for steps that must always converge (for example inside a hook). A `checkStep()` desugars to the first-class step check facet (`check` / `summarize` / `run(ctx, drift)` / `whenInSync`), so it participates in run-level check mode automatically: `kici run --check` reports the drift and skips `apply`, `kici run --check --fail-on-drift` exits non-zero when drift is detected, and apply mode applies the change.
1384
+
1385
+ ### Parameters
1386
+
1387
+ | Name | Type | Required | Description |
1388
+ | ----------------- | ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
1389
+ | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
1390
+ | `check` | `(ctx) => Promise<TDrift \| null>` | Yes | Read-only inspection. Return `null` when the system is already in the desired state. |
1391
+ | `apply` | `(ctx, drift: TDrift) => Promise<TApplied>` | Yes | Brings the system to the desired state. Runs only in apply mode (skipped under `kici run --check`). |
1392
+ | `summarize` | `(drift: TDrift) => string` | Yes | Human-readable summary of what `apply()` would do; shown in check-mode drift output. |
1393
+ | `whenInSync` | `(ctx) => Promise<TInSync>` | No | Runs when `check()` returned `null` (already in sync). |
1394
+ | `continueOnError` | `boolean` | No | When true, the job proceeds even if this step fails. |
1395
+ | `timeout` | `number` | No | Step-level timeout in milliseconds. |
1396
+
1397
+ The one signature difference from `idempotentStep`: `apply` and `whenInSync` receive `ctx` as their first argument, so the apply logic has access to `ctx.$`, `ctx.log`, and `ctx.secrets`.
1398
+
1399
+ ### Result
1400
+
1401
+ `checkStep(...)` returns `Step<TApplied | TInSync>` — the output is whichever of `apply` / `whenInSync` ran.
1402
+
1403
+ ### Example
1404
+
1405
+ ```typescript
1406
+ import { checkStep, job } from '@kici-dev/sdk';
1407
+
1408
+ const ensureDnsRecord = checkStep('ensure-dns-record', {
1409
+ check: async (ctx) => {
1410
+ const existing = await ctx.$`dig +short api.example.com`;
1411
+ return existing.stdout.trim() ? null : { fqdn: 'api.example.com', target: '203.0.113.10' };
1412
+ },
1413
+ summarize: (drift) => `Create A record ${drift.fqdn} → ${drift.target}`,
1414
+ apply: async (ctx, drift) => {
1415
+ await ctx.$`dns-cli create ${drift.fqdn} ${drift.target}`;
1416
+ return { created: true };
1417
+ },
1418
+ whenInSync: async () => ({ created: false }),
1419
+ });
1420
+
1421
+ export const deploy = job('deploy', {
1422
+ runsOn: 'linux',
1423
+ steps: [ensureDnsRecord],
1424
+ });
1425
+ ```
1426
+
1427
+ Run `kici run --check` against this workflow to see the drift summary without touching DNS; run it without `--check` to apply.
1428
+
1429
+ ## Worked example: create-if-missing returning a resource id
1430
+
1431
+ The typical use case is **resource provisioning that should be safe to re-run**. The helper guarantees the same downstream typed shape whether the resource already existed or was just created:
1432
+
1433
+ ```typescript
1434
+ import { idempotent } from '@kici-dev/sdk';
1435
+
1436
+ interface BucketDrift {
1437
+ bucket: string;
1438
+ region: string;
1439
+ }
1440
+
1441
+ interface BucketHandle {
1442
+ arn: string;
1443
+ }
1444
+
1445
+ async function ensureBucket(bucket: string, region: string): Promise<BucketHandle> {
1446
+ const result = await idempotent<BucketDrift, BucketHandle, BucketHandle>({
1447
+ name: `ensure-${bucket}`,
1448
+ check: async () => {
1449
+ const existing = await s3.describeBucket(bucket);
1450
+ return existing ? null : { bucket, region };
1451
+ },
1452
+ whenInSync: async () => {
1453
+ const existing = await s3.describeBucket(bucket);
1454
+ return { arn: existing.arn };
1455
+ },
1456
+ apply: async (drift) => {
1457
+ const created = await s3.createBucket(drift.bucket, drift.region);
1458
+ return { arn: created.arn };
1459
+ },
1460
+ summarize: (drift) => `Create S3 bucket ${drift.bucket} in ${drift.region}`,
1461
+ });
1462
+
1463
+ return result.result;
1464
+ }
1465
+ ```
1466
+
1467
+ The caller never has to branch on outcome — `result.result` is always a `BucketHandle`. A second invocation against the same bucket logs a single "in sync, skipping" line and returns the same ARN.
1468
+
1469
+ ## See also
1470
+
1471
+ - [Core SDK reference](https://docs.kici.dev/user/sdk/core/) — the `step()`, `job()`, and `workflow()` factories that `idempotentStep()` builds on.
1472
+ - [Runtime types](https://docs.kici.dev/user/sdk/runtime/) — `StepContext`, `Logger`, and other surface used inside the helpers.
1473
+
1474
+ ---
1475
+
1476
+ ## SDK reference: rules, matrix, dynamic jobs
1477
+
1478
+ Source: https://docs.kici.dev/user/sdk/rules-matrix-dynamic/
1479
+
1480
+ ## Rules
1481
+
1482
+ Rules control conditional execution of workflows and jobs. A rule that returns `false` (or whose check function returns `false`) prevents execution.
1483
+
1484
+ ### rule(label) / rule(label, check)
1485
+
1486
+ Create a rule.
1487
+
1488
+ ```typescript
1489
+ function rule(label: string): Rule;
1490
+ function rule(label: string, check: RuleCheckFn): Rule;
1491
+ ```
1492
+
1493
+ **Without check function:** Always passes. Useful as a marker in the decision trace.
1494
+
1495
+ ```typescript
1496
+ rule('ci: required check');
1497
+ ```
1498
+
1499
+ **With check function:** Passes when the function returns `true`.
1500
+
1501
+ ```typescript
1502
+ rule('has source changes', async (ctx) => {
1503
+ return ctx.changedFiles.some((f) => f.startsWith('src/'));
1504
+ });
1505
+ ```
1506
+
1507
+ ### skip(label, check)
1508
+
1509
+ Create a rule that skips when the condition is met. Inverts the check function.
1510
+
1511
+ ```typescript
1512
+ function skip(label: string, check: RuleCheckFn): Rule;
1513
+ ```
1514
+
1515
+ When the check returns `true` (condition met), the rule returns `false` (skip execution).
1516
+ When the check returns `false` (condition not met), the rule returns `true` (allow execution).
1517
+
1518
+ ```typescript
1519
+ // Skip when only docs changed
1520
+ skip('docs only PR', async (ctx) => {
1521
+ return ctx.changedFiles.every((f) => f.endsWith('.md'));
1522
+ });
1523
+ ```
1524
+
1525
+ ### RuleCheckFn
1526
+
1527
+ ```typescript
1528
+ type RuleCheckFn = (ctx: RuleContext) => Promise<boolean> | boolean;
1529
+ ```
1530
+
1531
+ Can be sync or async. Receives a `RuleContext`:
1532
+
1533
+ | Property | Type | Description |
1534
+ | -------------- | ----------------------------------- | --------------------------------------------------------------------- |
1535
+ | `event` | `EventPayload` | The triggering event payload (discriminated union — narrow on `type`) |
1536
+ | `changedFiles` | `string[]` | Files changed in this event |
1537
+ | `env` | `Record<string, string\|undefined>` | Environment variables |
1538
+ | `$` | zx shell | Shell executor for running commands |
1539
+
1540
+ ### evaluateRules(rules, context, label, onRuleResult?)
1541
+
1542
+ Evaluate an array of rules sequentially with fail-fast behavior. Stops on the first failure.
1543
+
1544
+ ```typescript
1545
+ function evaluateRules(
1546
+ rules: Rule[],
1547
+ context: RuleContext,
1548
+ label: string,
1549
+ onRuleResult?: (result: RuleResult) => void,
1550
+ ): Promise<RuleEvaluationResult>;
1551
+ ```
1552
+
1553
+ Returns a `RuleEvaluationResult`:
1554
+
1555
+ ```typescript
1556
+ interface RuleEvaluationResult {
1557
+ allPassed: boolean;
1558
+ results: RuleResult[];
1559
+ }
1560
+ ```
1561
+
1562
+ ### isEventType(event, type)
1563
+
1564
+ Type guard that narrows an `EventPayload` to a specific event type variant. Use this in rule check functions to get autocomplete on provider-specific fields.
1565
+
1566
+ ```typescript
1567
+ function isEventType<T extends EventPayload['type']>(
1568
+ event: EventPayload,
1569
+ type: T,
1570
+ ): event is Extract<EventPayload, { type: T }>;
1571
+ ```
1572
+
1573
+ **Example — skip draft PRs:**
1574
+
1575
+ ```typescript
1576
+ rule('skip-draft-prs', (ctx) => {
1577
+ if (!isEventType(ctx.event, 'pull_request')) return true;
1578
+ // ctx.event is now PullRequestEventPayload — full autocomplete
1579
+ return !ctx.event.payload.pull_request.draft;
1580
+ });
1581
+ ```
1582
+
1583
+ **Example — branch-based rule with push narrowing:**
1584
+
1585
+ ```typescript
1586
+ rule('only-main-pushes', (ctx) => {
1587
+ if (!isEventType(ctx.event, 'push')) return false;
1588
+ // ctx.event.payload.ref is typed as string
1589
+ return ctx.event.payload.ref === 'refs/heads/main';
1590
+ });
1591
+ ```
1592
+
1593
+ You can also narrow directly with `if (ctx.event.type === 'pull_request')` — TypeScript's discriminated union narrowing works on the `type` field.
1594
+
1595
+ ### EventPayload
1596
+
1597
+ `EventPayload` is a discriminated union over the `type` field. Each variant provides typed access to the normalized event fields and the raw webhook payload.
1598
+
1599
+ Every variant carries the shared `EventBase` fields — `type`, `action`, `targetBranch`, `sourceBranch`, `provider`, `isForkPR`, `baseBranch`, `senderUsername`, `sourceRepo`, `changedFiles`, and the raw `payload` — plus a per-type `payload` shape for the typed variants. The complete field-by-field schema, including every typed `payload` shape and the shared GitHub object types, is in the [event payload reference](https://docs.kici.dev/user/sdk/event-payloads/).
1600
+
1601
+ **Typed variants** (with GitHub-specific payload fields): `pull_request`, `push`, `tag`, `comment`, `review`, `review_comment`, `release`, `dispatch`, `create`, `delete`, `status`, `workflow_run`, `fork`, `star`, `watch`.
1602
+
1603
+ **Generic variants** (payload is `Record<string, unknown>`): `webhook`, `kici_event`, `workflow_complete`, `job_complete`, `generic_webhook`, `schedule`, `lifecycle`.
1604
+
1605
+ ## Matrix
1606
+
1607
+ Matrix configurations expand a single job into multiple instances, one per parameter combination. Maximum 256 combinations.
1608
+
1609
+ Expansion happens at **dispatch time**: the orchestrator materializes the matrix into N execution jobs — one per combination, each dispatched to its own agent — before any job runs. Each instance receives its combination as `ctx.matrix`. This is identical whether the workflow runs via `kici run local` or remotely through a webhook trigger, and the dashboard groups the N instances under one parent node.
1610
+
1611
+ ### Static array (single dimension)
1612
+
1613
+ ```typescript
1614
+ matrix: ['18', '20', '22'];
1615
+ ```
1616
+
1617
+ Creates 3 job instances. In steps, the current value is `matrix.value`:
1618
+
1619
+ ```typescript
1620
+ step('test', async ({ $, matrix }) => {
1621
+ console.log(matrix!.value); // '18', '20', or '22'
1622
+ });
1623
+ ```
1624
+
1625
+ ### Static object (multi-dimensional)
1626
+
1627
+ ```typescript
1628
+ matrix: {
1629
+ os: ['linux', 'arm64'],
1630
+ node: ['18', '20'],
1631
+ }
1632
+ ```
1633
+
1634
+ Creates 4 job instances (2 x 2). The `os` values (`linux`, `arm64`) are **customer-defined scaler labels** matched by subset semantics against the labels your orchestrator advertises in its scaler `labelSets` — not hosted-runner names. In steps, values are named properties:
1635
+
1636
+ ```typescript
1637
+ step('test', async ({ $, matrix }) => {
1638
+ console.log(matrix!.os); // 'linux' or 'arm64'
1639
+ console.log(matrix!.node); // '18' or '20'
1640
+ });
1641
+ ```
1642
+
1643
+ ### Dynamic function
1644
+
1645
+ Compute matrix values at runtime:
1646
+
1647
+ ```typescript
1648
+ matrix: async ({ $ }) => {
1649
+ const result = await $`ls packages/`;
1650
+ return result.stdout.trim().split('\n');
1651
+ };
1652
+ ```
1653
+
1654
+ The function receives a `DynamicMatrixContext`:
1655
+
1656
+ | Property | Type | Description |
1657
+ | -------- | ----------------------------------- | ------------------------- |
1658
+ | `$` | zx shell | Shell executor |
1659
+ | `ctx` | `{ workflow, job }` | Workflow and job metadata |
1660
+ | `log` | `Logger` | Structured logger |
1661
+ | `env` | `Record<string, string\|undefined>` | Environment variables |
1662
+
1663
+ Must return `string[]` (single dimension) or `Record<string, string[]>` (multi-dimensional).
1664
+
1665
+ A dynamic matrix is resolved at runtime, then materialized into N instances exactly like a static matrix. Because the combinations are not known until the function runs, the 256-combination cap (and the "zero combinations" guard) is enforced at that point: a dynamic matrix that resolves to more than 256 combinations, or to none, fails the job with a matrix-expansion error rather than dispatching.
1666
+
1667
+ ### Include and exclude
1668
+
1669
+ Fine-tune matrix combinations on multi-dimensional matrices:
1670
+
1671
+ ```typescript
1672
+ matrix: {
1673
+ os: ['linux', 'arm64', 'windows'],
1674
+ node: ['18', '20', '22'],
1675
+ },
1676
+ exclude: [
1677
+ { os: 'windows', node: '18' },
1678
+ ],
1679
+ include: [
1680
+ { os: 'linux', node: '23' },
1681
+ ],
1682
+ ```
1683
+
1684
+ **Exclude** removes combinations matching all specified keys. Applied first.
1685
+ **Include** adds exact combinations. Applied after exclude.
1686
+
1687
+ Types:
1688
+
1689
+ ```typescript
1690
+ type MatrixInclude = Record<string, string>;
1691
+ type MatrixExclude = Record<string, string>;
1692
+ ```
1693
+
1694
+ ### MatrixValues
1695
+
1696
+ The shape of `matrix` in `StepContext`:
1697
+
1698
+ ```typescript
1699
+ interface MatrixValues {
1700
+ value?: string; // Single-dimension value
1701
+ [dimension: string]: string | undefined; // Named dimensions
1702
+ }
1703
+ ```
1704
+
1705
+ ### Bounding matrix concurrency (maxParallel / failFast)
1706
+
1707
+ A matrix fan-out runs every combination at once by default. The fan-out-generic
1708
+ `maxParallel` and `failFast` job options bound it the same way they bound a
1709
+ [`runsOnAll`](https://docs.kici.dev/user/sdk/runs-on-all/#rolling-rollout-maxparallel--failfast) host fan-out:
1710
+
1711
+ ```typescript
1712
+ const test = job('test', {
1713
+ runsOn: 'linux',
1714
+ matrix: { os: ['ubuntu', 'macos', 'windows'] },
1715
+ maxParallel: 1, // run one combination at a time (sliding window)
1716
+ failFast: true, // stop launching combinations after the first failure
1717
+ run: async (ctx) => {
1718
+ /* ctx.matrix.os */
1719
+ },
1720
+ });
1721
+ ```
1722
+
1723
+ `maxParallel` is a sliding window (each combination that finishes releases the next;
1724
+ `1` = serial; must be `>= 1`); `failFast` halts the fan-out on the first failure and
1725
+ skips the held remainder (default `false`). They are ignored on a job with no `matrix`
1726
+ or `runsOnAll`.
1727
+
1728
+ ### Consuming matrix outputs downstream
1729
+
1730
+ A downstream job that lists a matrix job in its `needs` receives a **keyed envelope** instead of a flat outputs object, because the upstream produced N sets of outputs (one per combination). `ctx.jobOutputs(matrixJob)` returns a `MatrixJobOutputs`:
1731
+
1732
+ ```typescript
1733
+ interface MatrixJobOutputs<T = Record<string, unknown>> {
1734
+ /** Keyed by the combination suffix — the text inside `(...)` of the child name. */
1735
+ byMatrix: Record<string, T>;
1736
+ /** Last-write-wins flat merge across children, in child (name) order. */
1737
+ merged: T;
1738
+ }
1739
+ ```
1740
+
1741
+ The suffix key matches the child job's display name: `byMatrix['a']` for a single-dimension `['a', 'b']` matrix, `byMatrix['linux, arm64']` for a multi-dimension combination. Use `isMatrixJobOutputs` (or `'byMatrix' in result`) to discriminate:
1742
+
1743
+ ```typescript
1744
+ import { isMatrixJobOutputs } from '@kici-dev/sdk';
1745
+
1746
+ step('collect', async ({ jobOutputs }) => {
1747
+ const out = jobOutputs(buildMatrixJob);
1748
+ if (isMatrixJobOutputs(out)) {
1749
+ console.log(out.byMatrix['a']); // outputs of the `a` combination
1750
+ console.log(out.merged); // last-write-wins across all combinations
1751
+ }
1752
+ });
1753
+ ```
1754
+
1755
+ The downstream job waits for **all** matrix combinations to terminate before it dispatches. A non-matrix upstream keeps the flat outputs shape. The envelope is identical under `kici run local` and the remote path.
1756
+
1757
+ ### Matrix type guards
1758
+
1759
+ ```typescript
1760
+ import { isStaticArray, isStaticObject, isDynamicFunction } from '@kici-dev/sdk';
1761
+
1762
+ isStaticArray(matrix); // true if string[]
1763
+ isStaticObject(matrix); // true if Record<string, string[]>
1764
+ isDynamicFunction(matrix); // true if async function
1765
+ ```
1766
+
1767
+ ### Matrix expansion utilities
1768
+
1769
+ ```typescript
1770
+ import { expandMatrix, applyIncludeExclude } from '@kici-dev/sdk';
1771
+ ```
1772
+
1773
+ `expandMatrix(matrix)` takes a `StaticMatrixArray` or `StaticMatrixObject` and returns all combinations as `MatrixValues[]`. For a single-dimension array, each value becomes `{ value: '...' }`. For multi-dimensional objects, it produces the Cartesian product.
1774
+
1775
+ `applyIncludeExclude(values, include?, exclude?)` filters an expanded matrix: removes combinations matching any exclude entry, then appends include entries. Returns the filtered `MatrixValues[]`.
1776
+
1777
+ ## Dynamic jobs
1778
+
1779
+ Generate jobs at runtime using async factory functions.
1780
+
1781
+ ### DynamicJobFn
1782
+
1783
+ ```typescript
1784
+ type DynamicJobFn = (context: DynamicJobContext) => Promise<Job[]>;
1785
+ ```
1786
+
1787
+ Receives a `DynamicJobContext`:
1788
+
1789
+ | Property | Type | Description |
1790
+ | -------- | ----------------------------------- | --------------------------- |
1791
+ | `$` | zx shell | Shell executor |
1792
+ | `ctx` | `{ workflow, event? }` | Workflow metadata and event |
1793
+ | `log` | `Logger` | Structured logger |
1794
+ | `env` | `Record<string, string\|undefined>` | Environment variables |
1795
+
1796
+ ```typescript
1797
+ const discoverJobs: DynamicJobFn = async ({ $ }) => {
1798
+ const result = await $`ls packages/`;
1799
+ const packages = result.stdout.trim().split('\n');
1800
+ return packages.map((pkg) =>
1801
+ job(`test-${pkg}`, {
1802
+ runsOn: 'linux',
1803
+ steps: [
1804
+ step('test', async ({ $ }) => {
1805
+ await $`cd packages/${pkg} && pnpm test`;
1806
+ }),
1807
+ ],
1808
+ }),
1809
+ );
1810
+ };
1811
+
1812
+ export default workflow('ci', {
1813
+ jobs: [discoverJobs],
1814
+ });
1815
+ ```
1816
+
1817
+ ### dynamicJob — result-aware generation
1818
+
1819
+ `dynamicJob(group, fnOrConfig)` tags a generator with a group name (so static jobs can depend on it via `needs: [dynamicGroup('group')]`). It is polymorphic:
1820
+
1821
+ - **Function form** — event-only, dispatched at webhook time: `dynamicJob('shards', async ({ ctx }) => [...])`.
1822
+ - **Options-object form** — result-aware, deferred until its declared `needs` complete, then run with the upstreams' frozen outputs as `ctx.needs`: `dynamicJob('reports', { needs, generate })`.
1823
+
1824
+ ```typescript
1825
+ import { workflow, job, step, dynamicJob, dynamicGroup, z } from '@kici-dev/sdk';
1826
+
1827
+ // Upstream job A discovers a list of targets at runtime.
1828
+ const discover = job('discover', {
1829
+ runsOn: 'linux',
1830
+ steps: [
1831
+ step('emit', {
1832
+ outputs: { targets: z.array(z.string()) },
1833
+ run: async () => ({ targets: ['api', 'web'] }),
1834
+ }),
1835
+ ],
1836
+ });
1837
+
1838
+ // Result-aware generator fans out one report job per discovered target.
1839
+ const reports = dynamicJob('reports', {
1840
+ needs: ['discover'],
1841
+ generate: async ({ ctx }) => {
1842
+ const targets = ctx.needs.discover.result.targets; // OutputProxy over discover's outputs
1843
+ return targets.map((target) =>
1844
+ job(`report-${target}`, {
1845
+ runsOn: 'linux',
1846
+ run: async ({ log }) => log.info(`reporting on ${target}`),
1847
+ }),
1848
+ );
1849
+ },
1850
+ });
1851
+
1852
+ export default workflow('discovery-fan-out', { jobs: [discover, reports] });
1853
+ ```
1854
+
1855
+ `ctx.needs` shape:
1856
+
1857
+ | Need form | `ctx.needs[...]` value |
1858
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1859
+ | `'jobName'` / `{ name, when }` | `{ result, status }` — `result` is an `OutputProxy` (`ctx.needs.<job>.result.<step>.<field>`; single-step `run` jobs flatten to `ctx.needs.<job>.result.<field>`); `status` is the upstream's terminal status |
1860
+ | `dynamicGroup('g')` / `dynamicGroup('g', { when })` | ordered array of `{ name, result, status }`, one per group member |
1861
+
1862
+ `ctx.needs` is deterministic — a snapshot of upstream outputs frozen at first eval and replayed unchanged on re-eval, like `ctx.event`. Use result-aware generation for same-run fan-out from a prior job's result; use [`jobComplete()`](https://docs.kici.dev/user/sdk/triggers/) for cross-workflow reactions to a job finishing. See the architecture deep-dive in [dynamic jobs](https://docs.kici.dev/architecture/execution/dynamic-jobs/#result-aware-generation).
1863
+
1864
+ ### JobOrFactory
1865
+
1866
+ The `jobs` array in `WorkflowOptions` accepts both static jobs and dynamic generators:
1867
+
1868
+ ```typescript
1869
+ type JobOrFactory = Job | DynamicJobFn;
1870
+ ```
1871
+
1872
+ ### isDynamicJobFn(item)
1873
+
1874
+ Type guard to distinguish static jobs from dynamic generators:
1875
+
1876
+ ```typescript
1877
+ function isDynamicJobFn(item: JobOrFactory): item is DynamicJobFn;
1878
+ ```
1879
+
1880
+ ```typescript
1881
+ for (const item of workflow.jobs) {
1882
+ if (isDynamicJobFn(item)) {
1883
+ const generatedJobs = await item(context);
1884
+ } else {
1885
+ // item is Job
1886
+ }
1887
+ }
1888
+ ```
1889
+
1890
+ ---
1891
+
1892
+ ## SDK reference: runsOnAll host fan-out
1893
+
1894
+ Source: https://docs.kici.dev/user/sdk/runs-on-all/
1895
+
1896
+ ## runsOnAll
1897
+
1898
+ `runsOnAll` fans a single job out to **every** host in the orchestrator's declared
1899
+ roster that matches a label predicate — one pinned execution per host. Use it for
1900
+ fleet-wide operations: patch every web tier, smoke-test every node, collect uptime
1901
+ from the fleet.
1902
+
1903
+ `runsOnAll` is mutually exclusive with [`runsOn`](https://docs.kici.dev/user/sdk/core/): a job declares one
1904
+ or the other. Where `runsOn` picks a **single** agent that satisfies the labels,
1905
+ `runsOnAll` targets **all** matching hosts and runs the job once on each, pinned to
1906
+ that specific host.
1907
+
1908
+ ```typescript
1909
+ import { job } from '@kici-dev/sdk';
1910
+
1911
+ // Run on every host labelled role:web.
1912
+ const patch = job('patch', {
1913
+ runsOnAll: 'role:web',
1914
+ run: async (ctx) => {
1915
+ await ctx.$`sudo apt-get update && sudo apt-get upgrade -y`;
1916
+ ctx.log.info(`patched ${ctx.host}`);
1917
+ },
1918
+ });
1919
+ ```
1920
+
1921
+ ### Input forms
1922
+
1923
+ `runsOnAll` accepts three shapes:
1924
+
1925
+ - **Bare string** — one required label.
1926
+
1927
+ ```typescript
1928
+ runsOnAll: 'role:web';
1929
+ ```
1930
+
1931
+ - **Array** — every positive entry is required (AND); a `!`-prefixed entry excludes a host.
1932
+
1933
+ ```typescript
1934
+ runsOnAll: ['kici:os:linux', 'role:db', '!kici:host:db-01'];
1935
+ ```
1936
+
1937
+ - **Structured** — explicit OR-of-AND include groups plus excludes.
1938
+
1939
+ ```typescript
1940
+ runsOnAll: {
1941
+ include: [{ all: ['kici:os:linux', 'role:db'] }, { all: ['role:replica'] }],
1942
+ exclude: ['kici:host:db-01'],
1943
+ };
1944
+ ```
1945
+
1946
+ A host matches when it satisfies **any** include group (all labels in that group)
1947
+ and carries **none** of the exclude labels.
1948
+
1949
+ #### Targeting by pattern
1950
+
1951
+ Every entry in any of these forms — include or exclude — can be an exact string, a
1952
+ glob, or a regular expression, exactly like [`runsOn`](https://docs.kici.dev/user/sdk/core/#targeting-by-pattern):
1953
+
1954
+ - **Plain string → exact match** (`'role:web'`).
1955
+ - **String with glob metacharacters (`*`, `?`, `[]`, `{}`) → glob** (`'kici:host:web-*'`).
1956
+ - **`RegExp` literal → regular expression** (`/.*-canary$/`).
1957
+
1958
+ In the array form, a leading `!` routes an entry to the exclude side and is stripped
1959
+ **before** the matching mode is decided, so `'!kici:host:box-*'` is an exclude **glob**
1960
+ and `'!box-01'` an exclude **exact** match. A regular-expression exclusion uses the
1961
+ structured `exclude: [/…/]` form (a `RegExp` cannot carry a `!` prefix):
1962
+
1963
+ ```typescript
1964
+ const fanout = job('deploy', {
1965
+ runsOnAll: {
1966
+ include: [{ all: ['kici:os:linux', 'kici:host:web-*'] }],
1967
+ exclude: [/.*-canary$/],
1968
+ },
1969
+ run: async (ctx) => {
1970
+ /* runs once per matched host */
1971
+ },
1972
+ });
1973
+ ```
1974
+
1975
+ A custom label that literally contains glob metacharacters is always treated as a glob
1976
+ and can no longer be matched exactly. A regular expression you supply is validated for
1977
+ catastrophic-backtracking (ReDoS) when you run `kici compile` and rejected if it could
1978
+ hang on a crafted input.
1979
+
1980
+ ### Per-host execution model
1981
+
1982
+ Each matching host runs the job as its own pinned child, named `<job> (<hostname>)`
1983
+ (e.g. `patch (web-01)`). The children fan in for downstream `needs:` exactly like a
1984
+ matrix job — a downstream that needs the base job waits for every host child.
1985
+
1986
+ The job runs once per host with concurrency `unlimited` (all hosts in parallel).
1987
+
1988
+ ### ctx.host and ctx.agent
1989
+
1990
+ Inside a `runsOnAll` step, two extra context fields identify the host the child is
1991
+ running on:
1992
+
1993
+ - `ctx.host` — the hostname (string).
1994
+ - `ctx.agent` — the resolved agent facts: `{ host, labels, platform?, arch? }`.
1995
+
1996
+ ```typescript
1997
+ run: async (ctx) => {
1998
+ ctx.log.info(`running on ${ctx.host} (${ctx.agent?.platform}/${ctx.agent?.arch})`);
1999
+ };
2000
+ ```
2001
+
2002
+ Both are `undefined` for jobs that do not use `runsOnAll`.
2003
+
2004
+ ### ctx.fanout — fan-out position
2005
+
2006
+ Every fan-out child — a `runsOnAll` host **or** a matrix combination — also
2007
+ carries its **position** within the fan-out:
2008
+
2009
+ ```typescript
2010
+ ctx.fanout?: {
2011
+ index: number; // 0-based position in the deterministically-ordered fan-out
2012
+ total: number; // number of children in this fan-out
2013
+ first: boolean; // index === 0
2014
+ last: boolean; // index === total - 1
2015
+ };
2016
+ ```
2017
+
2018
+ The order is a **documented guarantee**: host fan-out is ordered by agent id,
2019
+ matrix fan-out by its combination label. So `ctx.fanout.first` is always the
2020
+ same (lowest-agent-id) host across re-runs, and `ctx.fanout.last` the same final
2021
+ one. `ctx.fanout` is `undefined` on a job that is not fanned out.
2022
+
2023
+ ### Run-once steps: onlyOnFirstHost / onlyOnLastHost / onlyOnFanoutIndex
2024
+
2025
+ For ordered, stateful rollouts you often need a step that runs on exactly **one**
2026
+ host — enable a leader before the rest join, run a one-time migration, take a
2027
+ single dump. Three rule helpers express this by reading `ctx.fanout`:
2028
+
2029
+ ```typescript
2030
+ import { job, step, onlyOnFirstHost, onlyOnLastHost, onlyOnFanoutIndex } from '@kici-dev/sdk';
2031
+
2032
+ const rollout = job('rollout', {
2033
+ runsOnAll: 'role:db',
2034
+ maxParallel: 1, // serial, so "first" runs before the rest
2035
+ steps: [
2036
+ // Runs only on the first (lowest-agent-id) host — KiCI's run-once primitive.
2037
+ step('enable-sync-mode', { rules: [onlyOnFirstHost()] }, async (ctx) => {
2038
+ /* configure the leader before standbys join */
2039
+ }),
2040
+ // Runs on every host.
2041
+ step('apply', async (ctx) => {
2042
+ /* ... */
2043
+ }),
2044
+ // Runs only on the last host.
2045
+ step('finalize', { rules: [onlyOnLastHost()] }, async (ctx) => {
2046
+ /* ... */
2047
+ }),
2048
+ ],
2049
+ });
2050
+ ```
2051
+
2052
+ - A step gated this way is **skipped** (not failed) on non-matching hosts — its
2053
+ outputs exist only on the host where it ran.
2054
+ - `onlyOnFanoutIndex(n)` targets the host at a specific position.
2055
+ - **Non-fan-out safety:** on a job that is not fanned out, `ctx.fanout` is
2056
+ `undefined` and these helpers treat the job as a single implicit child at
2057
+ index 0 — so `onlyOnFirstHost()` runs normally there (there is one host, which
2058
+ is the first). This means you can author a step with `onlyOnFirstHost()` and it
2059
+ behaves correctly whether or not the job ends up fanning out.
2060
+ - The helpers are host-flavored by name (the dominant use case) but read
2061
+ `ctx.fanout`, so they work for matrix fan-out too — `onlyOnFirstHost()` runs on
2062
+ the first combination.
2063
+
2064
+ ### byHost outputs
2065
+
2066
+ A downstream that `needs:` a `runsOnAll` job receives a **byHost** envelope instead
2067
+ of a flat outputs object — keyed by hostname, with a per-host summary:
2068
+
2069
+ ```typescript
2070
+ import { isHostJobOutputs } from '@kici-dev/sdk';
2071
+
2072
+ const report = job('report', {
2073
+ runsOn: 'role:control',
2074
+ needs: [patch],
2075
+ run: async (ctx) => {
2076
+ const outputs = ctx.jobOutputs(patch);
2077
+ if (isHostJobOutputs(outputs)) {
2078
+ ctx.log.info(`succeeded: ${outputs.summary.succeededHosts.join(', ')}`);
2079
+ ctx.log.info(`failed: ${outputs.summary.failedHosts.join(', ')}`);
2080
+ // Per-host outputs, keyed by hostname:
2081
+ const version = outputs.byHost['web-01']?.version;
2082
+ // Array view of one output key across every host:
2083
+ const allVersions = outputs.summary.outputs.version;
2084
+ }
2085
+ },
2086
+ });
2087
+ ```
2088
+
2089
+ Unlike the matrix envelope's last-write-wins `merged`, the host summary never collapses
2090
+ to a single scalar: `summary.outputs[key]` is an array of every host's value, and
2091
+ `succeededHosts` / `failedHosts` record each host's terminal outcome.
2092
+
2093
+ ### onUnreachable: skip | fail | hold
2094
+
2095
+ Resolution is backed by the **declared host roster** (see the operator
2096
+ [host roster](https://docs.kici.dev/operator/orchestrator/host-roster/) doc), not just the live registry.
2097
+ This lets KiCI surface an expected-but-absent host instead of silently fanning out to a
2098
+ partial fleet. The `onUnreachable` policy controls what happens when a **durable**
2099
+ (static) host in the roster is matched but not currently connected:
2100
+
2101
+ - **`hold`** (default) — queue a pinned child for the absent host and wait for it to
2102
+ reconnect. The fan-out is honest: a 5-host fleet with 1 host rebooting reports
2103
+ `4 ran, 1 held`, not a silent 4-of-5 success.
2104
+ - **`skip`** — omit the absent durable host and run only on the reachable hosts.
2105
+ - **`fail`** — fail the run init if any expected durable host is unreachable.
2106
+
2107
+ ```typescript
2108
+ const patch = job('patch', {
2109
+ runsOnAll: 'role:web',
2110
+ onUnreachable: 'skip',
2111
+ run: async (ctx) => {
2112
+ /* ... */
2113
+ },
2114
+ });
2115
+ ```
2116
+
2117
+ Ephemeral (scaled-down) hosts that are no longer connected are **always** skipped,
2118
+ independent of `onUnreachable` — a scaled-down node may never return. A `runsOnAll`
2119
+ that matches zero usable hosts fails the run rather than reporting a silent zero-child
2120
+ success.
2121
+
2122
+ ### includeUninitialized: converge a fresh fleet
2123
+
2124
+ `onUnreachable` governs declared hosts that _had_ an agent and are momentarily absent.
2125
+ A **never-initialized** host — a freshly-provisioned box reachable over SSH but with no
2126
+ agent yet — is a different case: there is nothing to run on. Set
2127
+ `includeUninitialized: true` to widen the fan-out to those hosts and bring them up:
2128
+
2129
+ ```typescript
2130
+ const converge = job('converge', {
2131
+ runsOnAll: 'kici:group:prod',
2132
+ includeUninitialized: true,
2133
+ steps: [partitionDisk, formatLuks, debootstrap, installAgent],
2134
+ });
2135
+ ```
2136
+
2137
+ For each un-agented declared host (one carrying SSH reach metadata), KiCI brings up a
2138
+ temporary init-runner over SSH and runs the **same steps** on it; hosts that already
2139
+ have a live agent run the steps on their own agent. One workflow converges the whole
2140
+ fleet — fresh boxes get built, live boxes run the same phases.
2141
+
2142
+ Because the steps run on already-initialized hosts too, the bootstrap phases **must be
2143
+ idempotent [check-steps](https://docs.kici.dev/user/sdk/core/)**: each step's `check()` reports in-sync on a
2144
+ live box so the partition / format / install steps **skip** there and run only on fresh
2145
+ boxes. This is the safety guard — an OS or disk-format step must never re-run on a host
2146
+ that is already built. Re-running the workflow is a no-op everywhere. See the operator
2147
+ [fresh-box bootstrap](https://docs.kici.dev/operator/orchestrator/host-roster/) doc for the bring-up,
2148
+ capability gating, and lifecycle details.
2149
+
2150
+ `includeUninitialized` is only meaningful alongside `runsOnAll`; it is ignored on a
2151
+ single-agent `runsOn` job.
2152
+
2153
+ ### Rolling rollout: maxParallel + failFast
2154
+
2155
+ By default a `runsOnAll` fan-out dispatches to every matched host at once — fine for
2156
+ collecting state across the fleet, dangerous for a deploy that takes the whole tier
2157
+ down simultaneously. Two job options bound the rollout:
2158
+
2159
+ - **`maxParallel`** — the fan-out width: at most this many hosts run at once. It is a
2160
+ sliding window — each host that finishes (success or failure) releases the next held
2161
+ host. `maxParallel: 1` is a strictly serial, one-host-at-a-time rolling deploy. Must
2162
+ be `>= 1`.
2163
+ - **`failFast`** — when `true`, the first host failure halts the rollout: no further
2164
+ held hosts are started, and the remaining ones are marked skipped. Default `false`
2165
+ (every host runs regardless of sibling outcomes — the same as the unbounded fan-out).
2166
+
2167
+ ```typescript
2168
+ const deploy = job('deploy', {
2169
+ runsOnAll: 'role:web',
2170
+ onUnreachable: 'skip', // see the caveat below
2171
+ maxParallel: 1, // strictly one host at a time
2172
+ failFast: true, // stop the roll on the first failure
2173
+ run: async (ctx) => {
2174
+ /* patch ctx.host */
2175
+ },
2176
+ });
2177
+ ```
2178
+
2179
+ Both options are **fan-out-generic** — they bound a `matrix` fan-out exactly the same
2180
+ way (the children are matrix combinations instead of hosts). They are ignored on a job
2181
+ with neither `matrix` nor `runsOnAll` (there is no fan-out to bound).
2182
+
2183
+ **Caveat — use `onUnreachable: 'skip'` or `'fail'` for rolling deploys, not `'hold'`.**
2184
+ A held host occupies a wave slot indefinitely while it waits to reconnect, stalling the
2185
+ roll behind an absent box. `skip` (run only reachable hosts) or `fail` (refuse the roll
2186
+ if any expected host is down) keep the window moving.
2187
+
2188
+ ### Narrowing the roster at run time with `--target`
2189
+
2190
+ A `runsOnAll` predicate is authored once in the workflow, but you can narrow it for a
2191
+ single run with `kici run --target <selector>` — an Ansible-`--limit`-style runtime
2192
+ filter. The effective host set becomes `runsOnAll ∩ target`: the selector can only
2193
+ _remove_ hosts from the matched roster, never add them. The narrowing is **run-global**
2194
+ (it applies to every `runsOnAll` job) and **`runsOnAll`-only** (single `runsOn`-pinned
2195
+ jobs are untouched). Repeated `--target` values AND-combine — a host must satisfy every
2196
+ selector to survive.
2197
+
2198
+ ```bash
2199
+ # Patch only the role:web subset of whatever role:* hosts the job would match
2200
+ kici run remote deploy --target role:web
2201
+
2202
+ # Intersect two selectors: hosts must be BOTH role:web AND dc:eu
2203
+ kici run remote deploy --target role:web --target dc:eu
2204
+ ```
2205
+
2206
+ When `--target` narrows a `runsOnAll` job to zero hosts, the run **fails** by default
2207
+ (a mistyped selector should be loud, not silently no-op). Pass `--target-allow-empty`
2208
+ to **skip** the zeroed job instead — it records a `skipped` status, and downstream jobs
2209
+ gated with `when: 'on-skip'` (or `when: 'always'`) still run, exactly as for an
2210
+ `onUnreachable: 'skip'` zero-host fan-out. See the [CLI reference](https://docs.kici.dev/user/cli-reference/#host-narrowing-with---target)
2211
+ for the full flag behavior and the [`needs` gating model](https://docs.kici.dev/user/sdk/core/#job-dependencies-needs)
2212
+ for how a skipped upstream propagates.
2213
+
2214
+ ### Limits (v0)
2215
+
2216
+ - Per-host secret scoping is not yet available — all hosts receive the job's resolved
2217
+ secrets.
2218
+
2219
+ ---
2220
+
2221
+ ## SDK reference: runtime
2222
+
2223
+ Source: https://docs.kici.dev/user/sdk/runtime/
2224
+
2225
+ ## Types
2226
+
2227
+ All types are exported from `@kici-dev/sdk` as type-only imports.
2228
+
2229
+ ### Core types
2230
+
2231
+ | Type | Description |
2232
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2233
+ | `Workflow` | Workflow definition returned by `workflow()` |
2234
+ | `WorkflowOptions` | Options for `workflow()` factory |
2235
+ | `Job` | Job definition returned by `job()` |
2236
+ | `JobOptions` | Options for `job()` factory |
2237
+ | `Step<TOutputs>` | Step definition returned by `step()` |
2238
+ | `StepOptions<T>` | Options for `step()` factory (full form with outputs) |
2239
+ | `StepRunFn` | Simple step function type: `(ctx) => Promise<void>` |
2240
+ | `BareStepFn` | Bare step function (no options, just `(ctx) => ...`) |
2241
+ | `StepInput` | Union of step input forms accepted by `job()` |
2242
+ | `OutputSchema` | Record of Zod types for step outputs |
2243
+ | `InferOutputs<T>` | Infer output type from output schema |
2244
+ | `ContainerConfig` | Container config for job execution (`image`, `env?`) |
2245
+ | `RunsOn` | Union of `runsOn` forms: `string \| RegExp \| (string \| RegExp)[] \| RunsOnSelector`. A plain string matches exactly, a string with glob metacharacters (`*?[]{}`) is a glob, and a `RegExp` is a regular expression. See [Targeting by pattern](https://docs.kici.dev/user/sdk/core/#targeting-by-pattern). |
2246
+ | `RunsOnSelector` | Object form for `runsOn` with `labels` (required) and `exclude` (optional) properties. Each element accepts the exact / glob / regex forms on both sides. |
2247
+ | `RunsOnAllInput` | Union of `runsOnAll` host fan-out forms: `string \| RegExp \| (string \| RegExp)[] \| { include: { all: (string \| RegExp)[] }[]; exclude?: (string \| RegExp)[] }`. Same exact / glob / regex semantics per element. See [runsOnAll](https://docs.kici.dev/user/sdk/runs-on-all/#targeting-by-pattern). |
2248
+ | `Fixture` | Test fixture definition returned by `fixture()` |
2249
+ | `FixtureOptions` | Options for `fixture()` factory |
2250
+ | `Registry` | Private npm registry declaration used in `WorkflowOptions.registries` |
2251
+
2252
+ ### Trigger types
2253
+
2254
+ | Type | Description |
2255
+ | ------------------------------- | --------------------------------------------------------------------- |
2256
+ | `Trigger` | Trigger definition (trigger config + source location) |
2257
+ | `TriggerConfig` | Union of all 22 trigger config types |
2258
+ | `PrTriggerConfig` | PR trigger configuration (from `pr()`) |
2259
+ | `PushTriggerConfig` | Push trigger configuration (from `push()`) |
2260
+ | `TagTriggerConfig` | Tag trigger configuration (from `tag()`) |
2261
+ | `CommentTriggerConfig` | Comment trigger configuration (from `comment()`) |
2262
+ | `ReviewTriggerConfig` | Review trigger configuration (from `review()`) |
2263
+ | `ReviewCommentTriggerConfig` | Review comment trigger configuration (from `reviewComment()`) |
2264
+ | `ReleaseTriggerConfig` | Release trigger configuration (from `release()`) |
2265
+ | `DispatchTriggerConfig` | Repository dispatch trigger configuration (from `dispatch()`) |
2266
+ | `CreateTriggerConfig` | Ref creation trigger configuration (from `create()`) |
2267
+ | `DeleteTriggerConfig` | Ref deletion trigger configuration (from `delete()`) |
2268
+ | `StatusTriggerConfig` | Commit status trigger configuration (from `status()`) |
2269
+ | `WorkflowRunTriggerConfig` | Workflow run trigger configuration (from `workflowRun()`) |
2270
+ | `ForkTriggerConfig` | Fork trigger configuration (from `fork()`) |
2271
+ | `StarTriggerConfig` | Star trigger configuration (from `star()`) |
2272
+ | `WatchTriggerConfig` | Watch trigger configuration (from `watch()`) |
2273
+ | `WebhookTriggerConfig` | Catch-all webhook trigger configuration (from `webhook()`) |
2274
+ | `KiciEventTriggerConfig` | Custom event trigger configuration (from `kiciEvent()`) |
2275
+ | `WorkflowCompleteTriggerConfig` | Workflow completion trigger configuration (from `workflowComplete()`) |
2276
+ | `JobCompleteTriggerConfig` | Job completion trigger configuration (from `jobComplete()`) |
2277
+ | `GenericWebhookTriggerConfig` | Generic webhook trigger configuration (from `genericWebhook()`) |
2278
+ | `ScheduleTriggerConfig` | Schedule trigger configuration (from `schedule()`) |
2279
+ | `LifecycleTriggerConfig` | Lifecycle trigger configuration (from `lifecycle()`) |
2280
+ | `PrConfigInput` | Config object for `pr()` factory |
2281
+ | `PushConfigInput` | Config object for `push()` factory |
2282
+ | `BranchPattern` | `{ type: 'glob', pattern } \| { type: 'regex', pattern, flags? }` |
2283
+ | `PrEvent` | PR event string literal union (17 event types) |
2284
+ | `GenericWebhookConfigInput` | Config object for `genericWebhook()` factory |
2285
+ | `GenericWebhookAuth` | Union of generic webhook auth types (HMAC or API key) |
2286
+ | `GenericWebhookHmacAuth` | HMAC-SHA256 auth configuration for generic webhooks |
2287
+ | `GenericWebhookApiKeyAuth` | API key auth configuration for generic webhooks |
2288
+ | `GenericWebhookAuthMethod` | Auth method string literal (`'hmac-sha256' \| 'api-key'`) |
2289
+
2290
+ ### Rule types
2291
+
2292
+ | Type | Description |
2293
+ | ---------------------- | ----------------------------------------------------------------------- |
2294
+ | `Rule` | Rule definition returned by `rule()` / `skip()` |
2295
+ | `RuleCheckFn` | `(ctx: RuleContext) => Promise<boolean> \| boolean` |
2296
+ | `RuleContext` | Context passed to rule check functions |
2297
+ | `RuleResult` | Result of rule evaluation (label, passed, duration) |
2298
+ | `EventPayload` | Discriminated union over event type (narrow on `type` for autocomplete) |
2299
+ | `RuleEvaluationResult` | Result of `evaluateRules()` (allPassed + results) |
2300
+
2301
+ ### Matrix types
2302
+
2303
+ | Type | Description |
2304
+ | ---------------------- | ------------------------------------------------------------------- |
2305
+ | `Matrix` | Union: `StaticMatrixArray \| StaticMatrixObject \| DynamicMatrixFn` |
2306
+ | `StaticMatrixArray` | `string[]` |
2307
+ | `StaticMatrixObject` | `Record<string, string[]>` |
2308
+ | `DynamicMatrixFn` | `(ctx) => Promise<StaticMatrixArray \| StaticMatrixObject>` |
2309
+ | `DynamicMatrixContext` | Context passed to dynamic matrix functions |
2310
+ | `MatrixValues` | Values exposed to steps (`value?` + named dimensions) |
2311
+ | `MatrixInclude` | `Record<string, string>` -- additional combinations |
2312
+ | `MatrixExclude` | `Record<string, string>` -- removed combinations |
2313
+
2314
+ ### Hook types
2315
+
2316
+ | Type | Description |
2317
+ | ----------------- | --------------------------------------------------------------- |
2318
+ | `HookConfig` | Hook definition returned by hook factories (`onCancel()`, etc.) |
2319
+ | `HookFn` | Hook function type: `(ctx: HookContext) => Promise<void>` |
2320
+ | `HookInput` | Hook input: `HookFn \| { run: HookFn; timeout?: number }` |
2321
+ | `HookContext` | Context passed to hook functions |
2322
+ | `OutcomeMetadata` | Metadata about the outcome that triggered the hook |
2323
+
2324
+ ### Dynamic job types
2325
+
2326
+ | Type | Description |
2327
+ | ------------------- | ---------------------------------- |
2328
+ | `DynamicJobFn` | `(ctx) => Promise<Job[]>` |
2329
+ | `DynamicJobContext` | Context for dynamic job generators |
2330
+ | `JobOrFactory` | `Job \| DynamicJobFn` |
2331
+
2332
+ ### Context types
2333
+
2334
+ | Type | Description |
2335
+ | --------------------- | ------------------------------------------------------------------ |
2336
+ | `StepContext<T>` | Context passed to step run functions |
2337
+ | `Logger` | Logger interface (info, warn, error, debug) |
2338
+ | `WorkflowInfo` | Workflow metadata: `{ name: string }` |
2339
+ | `JobInfo` | Job metadata: `{ name: string, runsOn: string }` |
2340
+ | `RepoInfo` | Repository metadata available in step context |
2341
+ | `StepSecrets` | Async accessor interface for step secrets (`get`, `expose`, `has`) |
2342
+ | `StepSecretsTyped` | Typed step secrets with known key inference |
2343
+ | `KnownSecretKeys` | String literal union of declared secret context keys |
2344
+ | `SecretNotFoundError` | Thrown when accessing a nonexistent key in secrets |
2345
+
2346
+ ## StepContext
2347
+
2348
+ The context object passed to every step's `run` function:
2349
+
2350
+ ```typescript
2351
+ interface StepContext<TInputs = Record<string, unknown>> {
2352
+ /** zx shell executor for running commands */
2353
+ $: typeof Shell;
2354
+ /** Structured logger */
2355
+ log: Logger;
2356
+ /** Environment variables */
2357
+ env: Record<string, string | undefined>;
2358
+ /** Set an environment variable visible to this step and all subsequent steps */
2359
+ setEnv(key: string, value: string): void;
2360
+ /** Prepend a directory to PATH, visible to this step and all subsequent steps */
2361
+ addPath(dir: string): void;
2362
+ /** Typed inputs from dependency step outputs */
2363
+ inputs: TInputs;
2364
+ /** Current workflow metadata */
2365
+ workflow: WorkflowInfo;
2366
+ /** Current job metadata */
2367
+ job: JobInfo;
2368
+ /** Matrix values for current job instance (undefined without matrix) */
2369
+ matrix?: MatrixValues;
2370
+ /** Raw webhook payload from the git provider */
2371
+ rawPayload?: Record<string, unknown>;
2372
+ /** Which git provider triggered this workflow (e.g. 'github', 'gitlab') */
2373
+ provider?: string;
2374
+ /** Whether this execution was triggered by `kici test` (remote test run) */
2375
+ isTestRun: boolean;
2376
+ /** The resolved deployment environment name for this job (undefined without environment) */
2377
+ environment?: string;
2378
+ /** Flat secrets resolved for this job. Throws SecretNotFoundError on missing key. */
2379
+ secrets: StepSecrets;
2380
+ /** Emit a custom event that can trigger other workflows */
2381
+ emit(
2382
+ eventName: string,
2383
+ payload?: Record<string, unknown>,
2384
+ options?: EventEmitOptions,
2385
+ ): Promise<{ deliveryId: string }>;
2386
+ /** Resolve outputs from a preceding step by reference */
2387
+ outputsOf<T>(ref: { _tag: 'Step'; name: string } | ((...args: any[]) => any)): T;
2388
+ /** Resolve outputs from a preceding job by reference */
2389
+ jobOutputs(ref: { name: string }): Record<string, unknown>;
2390
+ /** Publish a secret output value from this job (encrypted before leaving the agent) */
2391
+ setSecretOutput(key: string, value: string): void;
2392
+ }
2393
+ ```
2394
+
2395
+ ### Logger
2396
+
2397
+ ```typescript
2398
+ interface Logger {
2399
+ info(message: string, ...args: unknown[]): void;
2400
+ warn(message: string, ...args: unknown[]): void;
2401
+ error(message: string, ...args: unknown[]): void;
2402
+ debug(message: string, ...args: unknown[]): void;
2403
+ }
2404
+ ```
2405
+
2406
+ ### Usage
2407
+
2408
+ ```typescript
2409
+ step('example', async ({ $, log, env, matrix, workflow, job }) => {
2410
+ log.info(`Running in workflow: ${workflow.name}`);
2411
+ log.info(`Job: ${job.name} on ${job.runsOn}`);
2412
+
2413
+ if (matrix) {
2414
+ log.info(`Matrix value: ${matrix.value}`);
2415
+ }
2416
+
2417
+ const token = env.GITHUB_TOKEN;
2418
+ await $`echo "Building..."`;
2419
+ });
2420
+ ```
2421
+
2422
+ ### `rawPayload` and rule-context parity
2423
+
2424
+ `ctx.rawPayload` carries the same data that rule contexts access via `ctx.event.payload` — the unmodified webhook body from the git provider. A rule that branches on `ctx.event.payload.client_payload.foo` and a step body that reads `ctx.rawPayload.client_payload.foo` see the same value. Use it inside steps when the operator's dispatch payload (or any other provider-specific field) needs to drive runtime behavior — e.g. a `--dry-run` toggle or a deploy target — without bouncing the data through an env var.
2425
+
2426
+ **What's captured in the dashboard log viewer.** KiCI captures user output from every place in a workflow that can run TypeScript:
2427
+
2428
+ - **Inside a step body** — the agent merges three streams into the step's log: `ctx.log.*` structured calls, subprocess stdout/stderr from `ctx.$`, and any direct `console.log` / `.error` / `.warn` / `.info` / `.debug` (or other library that writes to `process.stdout` / `process.stderr`).
2429
+ - **Inside hooks** (`beforeStep`, `afterStep`, `onSuccess`, `onFailure`, `onCancel`, `cleanup`) — the same three streams are captured; per-step hooks share the step's log, post-loop hooks get their own dashboard row.
2430
+ - **At workflow module top-level, in rule `check` functions, and in the workflow `concurrency.group` function** — captured to the workflow-level `prepare` log bucket for the job, alongside KiCI's own setup narration.
2431
+ - **Inside a dynamic `environment` / `env` / `concurrencyGroup` function** on a static job — captured to the `__init__` job's synthetic step-0 log, which appears in the timeline as "Init: _jobname_".
2432
+ - **Inside a `DynamicJobFn` body and the per-generated-job `environment` / `env` / `concurrencyGroup` / `matrix` functions** — captured to the `__dynamic__` job's synthetic step-0 log ("Evaluate: _jobname_" in the timeline). The `$` parameter in that context is a scoped zx shell, so `await $\`...\`` subprocess output is captured too.
2433
+
2434
+ Use whichever style is convenient — you don't have to wrap `console.log` in the provided `log` parameter to make it visible. One limitation applies to in-process contexts only (init, build, dynamic-eval): direct `process.stdout.write` / `printf` is not captured there, because the agent's own logger uses that path and we don't want agent-internal output leaking into your step logs. Use `console.*` or the `log` parameter instead. See [Log streaming](https://docs.kici.dev/architecture/execution/job-execution/#log-streaming) for the full capture surface and limits (default 10 MB per step, backpressure behavior).
2435
+
2436
+ ### setEnv(key, value)
2437
+
2438
+ Export an environment variable to later steps in the same job. This is the canonical way to hand a value computed in one step to the steps that follow — the equivalent of `echo "KEY=VALUE" >> $GITHUB_ENV` in GitHub Actions. The value is visible to the current step and all subsequent steps in the job.
2439
+
2440
+ ```typescript
2441
+ step('setup', async (ctx) => {
2442
+ // Install a tool and record its version
2443
+ await ctx.$`npm install -g some-tool`;
2444
+ const version = (await ctx.$`some-tool --version`).stdout.trim();
2445
+ ctx.setEnv('TOOL_VERSION', version);
2446
+ });
2447
+
2448
+ step('use', async (ctx) => {
2449
+ // TOOL_VERSION is available here
2450
+ ctx.log.info(`Using tool version: ${ctx.env.TOOL_VERSION}`);
2451
+ });
2452
+ ```
2453
+
2454
+ **Behavior:**
2455
+
2456
+ - Last-write-wins -- if multiple steps set the same key, the last value is used
2457
+ - Cannot override operator-injected secrets (the operator value takes precedence)
2458
+ - Changes take effect immediately in the current step and persist for all subsequent steps
2459
+ - Shell commands export the same way by appending to `$KICI_ENV` (see [Exporting env from shell commands](https://docs.kici.dev/user/sdk/runtime/#exporting-env-from-shell-commands-kici_env--kici_path) below)
2460
+
2461
+ ### addPath(dir)
2462
+
2463
+ Prepend a directory to `PATH` for the current step and all subsequent steps in the same job. Useful for tools installed to non-standard locations.
2464
+
2465
+ ```typescript
2466
+ step('install-go', async (ctx) => {
2467
+ await ctx.$`curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /tmp -xz`;
2468
+ ctx.addPath('/tmp/go/bin');
2469
+ });
2470
+
2471
+ step('build', async (ctx) => {
2472
+ // `go` is now on PATH
2473
+ await ctx.$`go build ./...`;
2474
+ });
2475
+ ```
2476
+
2477
+ ### Exporting env from shell commands ($KICI_ENV / $KICI_PATH)
2478
+
2479
+ `setEnv` and `addPath` are the TypeScript form of "export env to later steps". A shell command — including a non-JS toolchain installer — exports env the same way by appending to two files the agent points at before every step:
2480
+
2481
+ - **`$KICI_ENV`** — append `KEY=value` lines. Each becomes an environment variable visible to subsequent steps, exactly like `ctx.setEnv('KEY', 'value')`.
2482
+ - **`$KICI_PATH`** — append one directory per line. Each is prepended to `PATH` for subsequent steps, exactly like `ctx.addPath(dir)`. The first directory appended ends up first on `PATH`.
2483
+
2484
+ ```typescript
2485
+ step('install-tool', async (ctx) => {
2486
+ await ctx.$`./install-mytool.sh`; // installs to /opt/mytool
2487
+ // Export from the shell, no JS round-trip needed:
2488
+ await ctx.$`echo "MYTOOL_HOME=/opt/mytool" >> "$KICI_ENV"`;
2489
+ await ctx.$`echo "/opt/mytool/bin" >> "$KICI_PATH"`;
2490
+ });
2491
+
2492
+ step('build', async (ctx) => {
2493
+ // MYTOOL_HOME is set and /opt/mytool/bin is on PATH here.
2494
+ await ctx.$`mytool build`;
2495
+ });
2496
+ ```
2497
+
2498
+ **Format (v1):**
2499
+
2500
+ - One `KEY=value` per line in `$KICI_ENV`. The split is on the first `=`, so the value may contain `=`. Blank lines and lines without a `=` are ignored.
2501
+ - One directory per line in `$KICI_PATH`. Blank lines are ignored.
2502
+ - Values must be single-line — embedded newlines are not supported in v1.
2503
+
2504
+ **Behavior (shared with `setEnv` / `addPath`):**
2505
+
2506
+ - Applied after the step completes and visible to every later step in the job.
2507
+ - Last-write-wins on a repeated key.
2508
+ - Cannot override an operator-injected secret — a collision is ignored and logged, and the operator value is preserved.
2509
+ - The files are reset before each step, so each step sees only its own appended lines.
2510
+
2511
+ ### setSecretOutput(key, value)
2512
+
2513
+ Publish an encrypted secret output from this job. Downstream jobs that list this job in their `needs` array receive the value merged into `ctx.secrets`.
2514
+
2515
+ ```typescript
2516
+ const generateToken = job('generate-token', {
2517
+ steps: [
2518
+ step('create', async (ctx) => {
2519
+ const token = (await ctx.$`vault write -f auth/token/create`).stdout.trim();
2520
+ ctx.setSecretOutput('DEPLOY_TOKEN', token);
2521
+ }),
2522
+ ],
2523
+ });
2524
+
2525
+ const deploy = job('deploy', {
2526
+ needs: [generateToken],
2527
+ steps: [
2528
+ step('deploy', async (ctx) => {
2529
+ // DEPLOY_TOKEN is available as a secret (decrypted by the orchestrator)
2530
+ const token = await ctx.secrets.get('DEPLOY_TOKEN');
2531
+ await ctx.$`DEPLOY_TOKEN=${token} ./deploy.sh`;
2532
+ }),
2533
+ ],
2534
+ });
2535
+ ```
2536
+
2537
+ **Security model:**
2538
+
2539
+ - The value is encrypted on the agent before leaving the machine (X25519 ECDH + AES-256-GCM)
2540
+ - The orchestrator decrypts and re-encrypts with its own key before storing
2541
+ - The ephemeral key pair is deleted when the run completes (forward secrecy)
2542
+ - Downstream agents never see the plaintext -- they receive it as part of their injected secrets
2543
+
2544
+ **Limits:**
2545
+
2546
+ - Maximum 20 secret outputs per job
2547
+ - Maximum 64 KB per value
2548
+
2549
+ ### ctx.kici.oidc.token({ audience })
2550
+
2551
+ Request a short-lived OIDC ID token for the current job, bound to an `audience`. The token is a signed JWT whose identity claims (`repository`, `ref`, `sha`, `kici_run_id`, `kici_job_id`) are derived by the build platform from the run context — a step cannot spoof them. Use it to authenticate the build to an external service that trusts the platform's OIDC issuer (for example, when generating build provenance).
2552
+
2553
+ ```typescript
2554
+ const publish = job('publish', {
2555
+ steps: [
2556
+ step('mint', async (ctx) => {
2557
+ const { token, expiresIn } = await ctx.kici.oidc.token({ audience: 'sigstore' });
2558
+ ctx.log.info(`Got an ID token valid for ${expiresIn}s`);
2559
+ // Hand `token` to a tool that exchanges it with the trusting service.
2560
+ }),
2561
+ ],
2562
+ });
2563
+ ```
2564
+
2565
+ **Behavior:**
2566
+
2567
+ - The token is short-lived (about 10 minutes) and scoped to the current run and job.
2568
+ - The returned token value is automatically masked in step logs.
2569
+ - The step never holds platform credentials — the request is relayed through the orchestrator, which mints the token on the step's behalf.
2570
+ - Only available inside a running job step; calling it outside one (for example, during local execution) rejects with a clear error.
2571
+
2572
+ ### ctx.kici.inventory.query(selector?) / .get(agentId)
2573
+
2574
+ Query the **host inventory** — the roster of agents in the caller's orchestrator cluster — from inside a workflow. Each host is a `HostInventoryEntry`:
2575
+
2576
+ ```typescript
2577
+ interface HostInventoryEntry {
2578
+ agentId: string;
2579
+ labels: string[]; // flat-string grouping/tags dimension
2580
+ properties: Record<string, string | number | boolean>; // typed host-vars dimension
2581
+ hostname: string | null;
2582
+ platform: string | null;
2583
+ arch: string | null;
2584
+ lifecycleClass: 'static' | 'ephemeral';
2585
+ status: 'ready' | 'unreachable' | 'stale';
2586
+ lastSeen: string; // ISO timestamp
2587
+ }
2588
+ ```
2589
+
2590
+ Two dimensions describe a host. **Labels** are flat strings used for grouping and targeting (the same labels `runsOn` / `runsOnAll` match). **Properties** are typed host-vars (`string | number | boolean`) — the place for facts like `region`, `cores`, or `gpu`. A host reports its own properties via the agent's `KICI_PROPERTIES` config, and an operator can pre-declare them with `kici-admin host declare --prop key=value`; the two are shallow-merged (agent-reported keys win).
2591
+
2592
+ ```typescript
2593
+ // All hosts:
2594
+ const all = await ctx.kici.inventory.query();
2595
+
2596
+ // Server-side label filter (OR-of-AND include groups, plus exclude):
2597
+ const dbHosts = await ctx.kici.inventory.query({
2598
+ include: [[{ kind: 'exact', value: 'role:db' }]],
2599
+ });
2600
+
2601
+ // Property filtering is client-side — plain JS in the workflow:
2602
+ const euDbHosts = dbHosts.filter((h) => h.properties.region === 'eu');
2603
+
2604
+ // One host by id:
2605
+ const host = await ctx.kici.inventory.get('box-1'); // HostInventoryEntry | null
2606
+ ```
2607
+
2608
+ **The label selector is applied server-side** (reusing the same glob/regex matchers as `runsOnAll`). **Property filtering is client-side** — you filter the returned array in plain JavaScript, so there is no query DSL to learn.
2609
+
2610
+ **Headline use — dynamic-job fan-out.** A dynamic-job generator can query the inventory and return one job per matching host, fanning a workflow out across a fleet:
2611
+
2612
+ ```typescript
2613
+ const migrate = job('migrate', async (ctx) => {
2614
+ const hosts = await ctx.kici.inventory.query({
2615
+ include: [[{ kind: 'exact', value: 'role:db' }]],
2616
+ });
2617
+ return hosts
2618
+ .filter((h) => h.properties.region === 'eu')
2619
+ .map((h) =>
2620
+ job(`migrate-${h.agentId}`, {
2621
+ runsOn: [h.agentId],
2622
+ run: async (c) => {
2623
+ await c.$`./migrate.sh`;
2624
+ },
2625
+ }),
2626
+ );
2627
+ });
2628
+ ```
2629
+
2630
+ A `runsOn` of a single host's `agentId` (as in `runsOn: [h.agentId]` above) **pins the job to that host**: the orchestrator routes it to that agent only, and queues it with the pin if the host is momentarily offline — the same host-pin path `runsOnAll` uses. A `runsOn` with multiple labels or a glob/regex pattern stays ordinary label routing.
2631
+
2632
+ `ctx.kici.inventory` is available to **both** steps and dynamic-job generators (unlike `ctx.kici.oidc.token`, which is job-bound — the inventory is cluster-scoped, not job-bound).
2633
+
2634
+ **Determinism caveat.** The inventory is **live**: it can change between when a dynamic-job generator first runs (at dispatch) and when it re-evaluates (at agent time). Generating jobs from `inventory.query()` therefore inherits the same non-determinism contract as `infrastructure.list()` — KiCI warns when the re-evaluated job set drifts (a sibling job name changed) and hard-errors when a targeted job vanishes. Prefer stable inputs where you can, and treat a fanned-out job set as a snapshot of the roster at generation time.
2635
+
2636
+ ### ctx.attestProvenance({ subject })
2637
+
2638
+ Build, sign, and persist a build-provenance attestation for an artifact your step produced. KiCI assembles an in-toto SLSA v1.0 provenance statement whose build identity (`repository`, `ref`, `sha`, run/job ids) comes from the platform — not from the step — so it cannot be spoofed, signs it, and stores a verifiable bundle that the dashboard surfaces and the `kici verify-attestation` CLI checks.
2639
+
2640
+ The artifact is **caller-supplied**: give it either a precomputed digest or a path (relative to the step working directory) that KiCI digests with SHA-256. For a container image, pass the manifest digest your build tool emitted.
2641
+
2642
+ ```typescript
2643
+ const publish = job('publish', {
2644
+ steps: [
2645
+ step('build', async (ctx) => {
2646
+ await ctx.$`npm pack`;
2647
+ }),
2648
+ step('attest', async (ctx) => {
2649
+ // Digest a file KiCI hashes for you:
2650
+ const result = await ctx.attestProvenance({
2651
+ subject: { name: 'my-pkg-1.2.3.tgz', path: 'my-pkg-1.2.3.tgz' },
2652
+ });
2653
+ ctx.log.info(`Attestation stored at ${result.storageKey}`);
2654
+
2655
+ // Or supply a precomputed digest (e.g. a container manifest digest):
2656
+ await ctx.attestProvenance({
2657
+ subject: { name: 'ghcr.io/acme/app', digest: { sha256: '<manifest-digest>' } },
2658
+ });
2659
+ }),
2660
+ ],
2661
+ });
2662
+ ```
2663
+
2664
+ **Behavior:**
2665
+
2666
+ - The attestation is a signed [DSSE](https://github.com/secure-systems-lab/dsse) envelope over an [in-toto](https://in-toto.io) statement carrying the [SLSA v1.0](https://slsa.dev/spec/v1.0/provenance) provenance predicate.
2667
+ - It is signed with an ephemeral key bound to a platform-minted identity token, so it is **offline-verifiable** against the platform's published signing keys — no online lookup needed at verify time.
2668
+ - The bundle is persisted to object storage and recorded so the dashboard can show it and `kici verify-attestation` can retrieve it.
2669
+ - The returned `{ storageKey, subjectDigest, bundleMediaType }` identifies the stored bundle.
2670
+ - Only available inside a running job step; calling it outside one (for example, during local execution) rejects with a clear error.
2671
+
2672
+ See the [build provenance guide](https://docs.kici.dev/user/provenance/) for the end-to-end attest →
2673
+ verify → view journey, including how to verify a bundle with `kici verify-attestation`.
2674
+
2675
+ ## Secrets
2676
+
2677
+ Workflows access secrets through `ctx.secrets` on `StepContext`. Use `await ctx.secrets.get('KEY')` to retrieve a value (rejects with `SecretNotFoundError` if the key is missing, fail-fast on typos), `ctx.secrets.has('KEY')` for a synchronous existence check, and `await ctx.secrets.expose('KEY')` when you need the value as a `process.env` entry for a child process.
2678
+
2679
+ ### Declaring the secret environment
2680
+
2681
+ Each job picks its secret environment via the `environment` option on `job()`. The orchestrator resolves the environment's scoped-secret store at dispatch time, evaluates access rules, and sends the decrypted secrets to the agent:
2682
+
2683
+ ```typescript
2684
+ const deploy = job('deploy', {
2685
+ runsOn: 'linux',
2686
+ environment: 'production',
2687
+ steps: [
2688
+ /* ... */
2689
+ ],
2690
+ });
2691
+
2692
+ export default workflow('deploy', {
2693
+ on: push({ branches: 'main' }),
2694
+ jobs: [deploy],
2695
+ });
2696
+ ```
2697
+
2698
+ `environment` accepts either a static string or an async function `(event) => string | Promise<string>` for dynamic resolution at trigger-evaluation time. The resolved environment's secrets are flattened into `ctx.secrets`.
2699
+
2700
+ ### Accessing secrets (ctx.secrets)
2701
+
2702
+ `ctx.secrets` provides flat access to the secrets resolved for the job's environment.
2703
+
2704
+ ```typescript
2705
+ step('deploy', async ({ secrets }) => {
2706
+ // get() rejects with SecretNotFoundError if DEPLOY_TOKEN is not found
2707
+ const token = await secrets.get('DEPLOY_TOKEN');
2708
+
2709
+ // Safe check before access (no throw, synchronous)
2710
+ if (secrets.has('OPTIONAL_KEY')) {
2711
+ const optional = await secrets.get('OPTIONAL_KEY');
2712
+ }
2713
+ });
2714
+ ```
2715
+
2716
+ **Throw behavior:** `get()` rejects with `SecretNotFoundError` and the message lists all available keys. This catches typos immediately rather than producing silent `undefined` values.
2717
+
2718
+ ### Complete example
2719
+
2720
+ ```typescript
2721
+ import { workflow, job, step, push } from '@kici-dev/sdk';
2722
+
2723
+ const deploy = job('deploy', {
2724
+ runsOn: 'linux',
2725
+ environment: 'production',
2726
+ steps: [
2727
+ step('deploy', async (ctx) => {
2728
+ const token = await ctx.secrets.get('DEPLOY_TOKEN');
2729
+
2730
+ // Safe check before access
2731
+ if (ctx.secrets.has('OPTIONAL_NOTIFICATION_URL')) {
2732
+ const url = await ctx.secrets.get('OPTIONAL_NOTIFICATION_URL');
2733
+ ctx.log.info('Sending notification...');
2734
+ }
2735
+
2736
+ // Pass to subprocess explicitly (secrets are NOT auto-injected as env vars)
2737
+ await ctx.$`DEPLOY_TOKEN=${token} ./scripts/deploy.sh`;
2738
+ }),
2739
+ ],
2740
+ });
2741
+
2742
+ export default workflow('deploy-production', {
2743
+ on: push({ branches: 'main' }),
2744
+ jobs: [deploy],
2745
+ });
2746
+ ```
2747
+
2748
+ ### Security notes
2749
+
2750
+ - Secrets are **not** automatically injected as environment variables. You must explicitly pass them to subprocesses.
2751
+ - All secret values are automatically **masked** in log output. If a step logs a string containing a secret value, the value is replaced with `***`.
2752
+ - Secrets flow from the orchestrator to the agent via the authenticated WebSocket channel. The Platform tier never handles secret material.
2753
+
2754
+ ### Enumerating available keys (ctx.secrets.list)
2755
+
2756
+ `ctx.secrets.list()` returns every secret key available to the step, sorted alphabetically. Synchronous, never throws, names only — call `getMeta(key)` to inspect backend / scope per key. Useful when the set of provisioned keys isn't known at workflow-author time, for example to pick up every `AGE_KEY_*` the operator has seeded:
2757
+
2758
+ ```typescript
2759
+ step('discover', async (ctx) => {
2760
+ const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
2761
+ ctx.log.info(`Found ${ageKeys.length} age keys`);
2762
+ });
2763
+ ```
2764
+
2765
+ ### File-mounted secrets (ctx.secrets.mountFile / exposeFile)
2766
+
2767
+ Tools that require a file path on disk (sops `SOPS_AGE_KEY_FILE`, kubectl `KUBECONFIG`, gcloud `GOOGLE_APPLICATION_CREDENTIALS`) get a typed step-side API: `ctx.secrets.mountFile(opts)` writes the concatenation of one or more existing secrets to a per-step tmpfile and returns the path; `ctx.secrets.exposeFile(envVar, opts)` additionally sets `process.env[envVar] = path`. Files are removed and env vars are unset automatically when the step completes (success, failure, or timeout) — no manual cleanup. See [Mounting secrets as files](https://docs.kici.dev/user/secrets/#mounting-secrets-as-files) for the full options table, lifecycle details, and the canonical sops example.
2768
+
2769
+ ### Local test mode secrets
2770
+
2771
+ When running `kici test`, you can provide secrets locally without an orchestrator.
2772
+
2773
+ #### .kici/.secrets file
2774
+
2775
+ Create a `.kici/.secrets` file in your project (auto-gitignored by `kici init`):
2776
+
2777
+ ```ini
2778
+ # Flat secrets (before any section)
2779
+ DEPLOY_TOKEN=my-deploy-token
2780
+ API_KEY=my-api-key
2781
+
2782
+ # Context-scoped secrets
2783
+ [production]
2784
+ DB_PASSWORD=prod-secret
2785
+ API_KEY=prod-key
2786
+
2787
+ [npm-publish]
2788
+ NPM_TOKEN=npm-abc123
2789
+ ```
2790
+
2791
+ Lines before any `[section]` header are flat secrets. Lines within a section become context-scoped secrets. Comments start with `#`. Values are everything after the first `=` (so values can contain `=` characters).
2792
+
2793
+ #### CLI flags
2794
+
2795
+ Override or supplement file-based secrets with CLI flags:
2796
+
2797
+ ```bash
2798
+ # Inject flat secrets (repeatable)
2799
+ kici test push --secret DEPLOY_TOKEN=my-token --secret API_KEY=my-key
2800
+
2801
+ # Inject context-scoped secrets (repeatable)
2802
+ kici test push --context production.DB_PASSWORD=prod-secret --context npm-publish.NPM_TOKEN=abc123
2803
+ ```
2804
+
2805
+ **Precedence:** CLI flags override `.kici/.secrets` file values. Context secrets are auto-flattened into `ctx.secrets` using the same merge logic as production (last context wins).
2806
+
2807
+ ## Fixtures
2808
+
2809
+ Test fixtures define event replicas for `kici run remote`. They simulate trigger events without requiring real webhooks.
2810
+
2811
+ ### fixture(id, options)
2812
+
2813
+ ```typescript
2814
+ function fixture(
2815
+ id: string,
2816
+ options: FixtureOptions | (() => FixtureOptions | Promise<FixtureOptions>),
2817
+ ): Fixture;
2818
+ ```
2819
+
2820
+ **Parameters:**
2821
+
2822
+ - `id` — unique fixture name (no whitespace). Used in `kici run remote <id>`.
2823
+ - `options` — a `FixtureOptions` object, or an async factory function returning one.
2824
+
2825
+ ```typescript
2826
+ import { fixture, push } from '@kici-dev/sdk';
2827
+
2828
+ export const pushMain = fixture('push-main', {
2829
+ event: push({ branches: ['main'] }),
2830
+ });
2831
+ ```
2832
+
2833
+ ### FixtureOptions
2834
+
2835
+ | Property | Type | Description |
2836
+ | -------------- | ------------------------ | ---------------------------------------------------------- |
2837
+ | `event` | `TriggerConfig` | The trigger event to simulate (required) |
2838
+ | `branch` | `string` | Override branch name (defaults to git-detected) |
2839
+ | `sha` | `string` | Override commit SHA (defaults to HEAD) |
2840
+ | `repo` | `string` | Override repository (defaults to git-detected) |
2841
+ | `pr` | `number` | For PR events, override PR number |
2842
+ | `secrets` | `Record<string, string>` | Secret context mappings: `{ localName: 'remote-context' }` |
2843
+ | `workflowName` | `string` | Bypass trigger matching and run this workflow directly |
2844
+
2845
+ Options can also be provided as an async factory function for dynamic fixture generation.
2846
+
2847
+ ---
2848
+
2849
+ ## SDK reference: triggers
2850
+
2851
+ Source: https://docs.kici.dev/user/sdk/triggers/
2852
+
2853
+ ## Triggers
2854
+
2855
+ Triggers define when a workflow runs. KiCI provides 22 trigger types: 16 GitHub webhook triggers and 6 internal/generic triggers for event routing, scheduling, and non-GitHub sources. Each trigger returns a frozen config object with a unique `_tag` discriminator.
2856
+
2857
+ All triggers use a config object form -- pass an options object to configure the trigger.
2858
+
2859
+ ### pr()
2860
+
2861
+ Create a pull request trigger. Returns a frozen `PrTriggerConfig` directly.
2862
+
2863
+ ```typescript
2864
+ function pr(config?: PrConfigInput): PrTriggerConfig;
2865
+ ```
2866
+
2867
+ **Config options:**
2868
+
2869
+ ```typescript
2870
+ interface PrConfigInput {
2871
+ events?: PrEvent[];
2872
+ target?: string | RegExp | (string | RegExp)[];
2873
+ source?: string | RegExp | (string | RegExp)[];
2874
+ paths?: string[]; // Use '!' prefix for exclusions (e.g., '!docs/**')
2875
+ repos?: string | RegExp | (string | RegExp)[]; // Cross-repo source patterns -- see global-workflows.md
2876
+ description?: string;
2877
+ }
2878
+ ```
2879
+
2880
+ **PrEvent values:** `'opened'`, `'synchronize'`, `'reopened'`, `'closed'`, `'assigned'`, `'unassigned'`, `'labeled'`, `'unlabeled'`, `'edited'`, `'converted_to_draft'`, `'ready_for_review'`, `'locked'`, `'unlocked'`, `'review_requested'`, `'review_request_removed'`, `'auto_merge_enabled'`, `'auto_merge_disabled'`
2881
+
2882
+ **Default events** (when `events` is not specified): `opened`, `synchronize`, `reopened`, `closed`
2883
+
2884
+ **Examples:**
2885
+
2886
+ ```typescript
2887
+ // All PRs with default events
2888
+ pr();
2889
+
2890
+ // PRs targeting main with path filter
2891
+ pr({ target: 'main', events: ['opened', 'synchronize'], paths: ['src/**'] });
2892
+
2893
+ // Regex branch pattern
2894
+ pr({ target: /^release\/v\d+$/ });
2895
+ ```
2896
+
2897
+ ### push()
2898
+
2899
+ Create a push trigger. Returns a frozen `PushTriggerConfig` directly.
2900
+
2901
+ ```typescript
2902
+ function push(config?: PushConfigInput): PushTriggerConfig;
2903
+ ```
2904
+
2905
+ **Config options:**
2906
+
2907
+ ```typescript
2908
+ interface PushConfigInput {
2909
+ branches?: string | RegExp | (string | RegExp)[];
2910
+ tags?: string | RegExp | (string | RegExp)[];
2911
+ paths?: string[]; // Use '!' prefix for exclusions (e.g., '!docs/**')
2912
+ repos?: string | RegExp | (string | RegExp)[]; // Cross-repo source patterns -- see global-workflows.md
2913
+ description?: string;
2914
+ }
2915
+ ```
2916
+
2917
+ **Examples:**
2918
+
2919
+ ```typescript
2920
+ // Any push
2921
+ push();
2922
+
2923
+ // Push to main only
2924
+ push({ branches: 'main' });
2925
+
2926
+ // Push with branch and path filters
2927
+ push({ branches: ['main', 'develop'], paths: ['src/**'] });
2928
+
2929
+ // Tag pushes
2930
+ push({ tags: ['v*'] });
2931
+ ```
2932
+
2933
+ ### tag()
2934
+
2935
+ Create a tag trigger. Returns a frozen `TagTriggerConfig`.
2936
+
2937
+ ```typescript
2938
+ function tag(config?: TagConfigInput): TagTriggerConfig;
2939
+ ```
2940
+
2941
+ **Config options:** `patterns` (string/RegExp/array), `description`
2942
+
2943
+ ```typescript
2944
+ tag(); // Any tag
2945
+ tag({ patterns: ['v*'] }); // Semver tags
2946
+ tag({ patterns: /^v\d+\.\d+$/ }); // Regex match
2947
+ ```
2948
+
2949
+ ### comment()
2950
+
2951
+ Create an issue/PR comment trigger. Returns a frozen `CommentTriggerConfig`.
2952
+
2953
+ ```typescript
2954
+ function comment(config?: CommentConfigInput): CommentTriggerConfig;
2955
+ ```
2956
+
2957
+ **Config options:** `actions` (created/edited/deleted), `source` (issue/pr), `bodyMatch` (string or RegExp), `description`
2958
+
2959
+ ```typescript
2960
+ comment(); // Any comment
2961
+ comment({ bodyMatch: '/deploy' }); // Glob match on body
2962
+ comment({ bodyMatch: /^\/deploy/i }); // Regex match on body
2963
+ comment({ source: 'pr', actions: ['created'] }); // PR comments only
2964
+ ```
2965
+
2966
+ ### review()
2967
+
2968
+ Create a pull request review trigger. Returns a frozen `ReviewTriggerConfig`.
2969
+
2970
+ ```typescript
2971
+ function review(config?: ReviewConfigInput): ReviewTriggerConfig;
2972
+ ```
2973
+
2974
+ **Config options:** `actions` (submitted/edited/dismissed), `states` (approved/changes_requested/commented/dismissed), `description`
2975
+
2976
+ ```typescript
2977
+ review(); // Any review
2978
+ review({ states: ['approved'] }); // Approvals only
2979
+ review({ actions: ['submitted'], states: ['approved'] }); // Submitted approvals
2980
+ ```
2981
+
2982
+ ### reviewComment()
2983
+
2984
+ Create a PR review comment trigger. Returns a frozen `ReviewCommentTriggerConfig`.
2985
+
2986
+ ```typescript
2987
+ function reviewComment(config?: ReviewCommentConfigInput): ReviewCommentTriggerConfig;
2988
+ ```
2989
+
2990
+ **Config options:** `actions` (created/edited/deleted), `description`
2991
+
2992
+ ```typescript
2993
+ reviewComment(); // Any review comment
2994
+ reviewComment({ actions: ['created'] }); // New review comments only
2995
+ ```
2996
+
2997
+ ### release()
2998
+
2999
+ Create a release trigger. Returns a frozen `ReleaseTriggerConfig`.
3000
+
3001
+ ```typescript
3002
+ function release(config?: ReleaseConfigInput): ReleaseTriggerConfig;
3003
+ ```
3004
+
3005
+ **Config options:** `actions` (published/unpublished/created/edited/deleted/prereleased/released), `description`
3006
+
3007
+ ```typescript
3008
+ release(); // Any release event
3009
+ release({ actions: ['published'] }); // Published releases only
3010
+ ```
3011
+
3012
+ ### dispatch()
3013
+
3014
+ Create a repository_dispatch trigger. Returns a frozen `DispatchTriggerConfig`.
3015
+
3016
+ ```typescript
3017
+ function dispatch(config?: DispatchConfigInput): DispatchTriggerConfig;
3018
+ ```
3019
+
3020
+ **Config options:** `types` (string[]), `description`, `inputs` (typed dispatch inputs map)
3021
+
3022
+ ```typescript
3023
+ dispatch(); // Any dispatch
3024
+ dispatch({ types: ['deploy', 'rollback'] }); // Specific event types
3025
+ ```
3026
+
3027
+ #### Typed dispatch inputs
3028
+
3029
+ A `dispatch()` trigger can declare a typed `inputs` schema. Operators supply
3030
+ values with `kici run --input key=value`; KiCI validates, coerces, defaults, and
3031
+ exposes them to steps and rules as `ctx.dispatchInputs`. The values are validated
3032
+ on the orchestrator from the compiled lock file — a missing required input or a
3033
+ bad value is rejected before any agent runs, without cloning the repository.
3034
+
3035
+ ```typescript
3036
+ import { workflow, job, step, dispatch, defineDispatchInputs, z } from '@kici-dev/sdk';
3037
+
3038
+ const inputs = defineDispatchInputs({
3039
+ target: z.string().optional(),
3040
+ skipCveScan: z.boolean().default(false),
3041
+ skipCveScanReason: z.string().min(1).optional(),
3042
+ mode: z.enum(['full', 'edge-only']).default('full'),
3043
+ retries: z.number().int().min(0).max(10).default(3),
3044
+ });
3045
+
3046
+ export default workflow('deploy-prod', {
3047
+ on: dispatch({ types: ['deploy-prod'], inputs }),
3048
+ jobs: [
3049
+ job('gates', {
3050
+ runsOn: 'kici:group:ops',
3051
+ steps: [
3052
+ step('cve-gate', async (ctx) => {
3053
+ const i = inputs.from(ctx); // fully typed per declared key
3054
+ if (i.skipCveScan) {
3055
+ ctx.log.warn(`CVE gate skipped: ${i.skipCveScanReason ?? '(no reason)'}`);
3056
+ return;
3057
+ }
3058
+ await ctx.$`pnpm scan:cve:gate`;
3059
+ }),
3060
+ ],
3061
+ }),
3062
+ ],
3063
+ });
3064
+ ```
3065
+
3066
+ - **`defineDispatchInputs(map)`** is the single declaration site. It returns a
3067
+ handle that `dispatch({ inputs })` accepts and exposes `inputs.from(ctx)` — a
3068
+ typed reader over `ctx.dispatchInputs`, typed per declared key. `dispatch({ inputs })`
3069
+ also accepts a bare `{ name: schema }` map directly when you don't need the reader.
3070
+ - **`ctx.dispatchInputs`** is always present (a validated map of
3071
+ `string | number | boolean | null`), distinct from `ctx.inputs` (typed outputs
3072
+ from `needs` dependencies). Rules see the same values via `ctx.dispatchInputs`,
3073
+ so `skipUnless(ctx => !ctx.dispatchInputs.skipCveScan)` works.
3074
+ - **Defaults are applied once**, on the orchestrator (the authoritative side); the
3075
+ CLI pre-validates `--input` for fast feedback and forwards the raw operator pairs.
3076
+
3077
+ **Allowed input types (closed subset):** `z.string()`, `z.number()`,
3078
+ `z.boolean()`, `z.enum([...])`, `z.literal(v)`, with the modifiers `.optional()`,
3079
+ `.nullable()`, `.default(v)`, `.min(n)`, `.max(n)`, `.regex(re)`, `.int()`.
3080
+ Anything outside this set (`.refine()`, `.transform()`, `.pipe()`, `z.object()`,
3081
+ `z.array()`, `z.union()`, `z.record()`, `z.coerce.*`) is a **compile error** —
3082
+ the closed set is what guarantees the schema survives the trip to the
3083
+ orchestrator's lock file without silently dropping any validation. CLI strings
3084
+ are coerced for you (`--input retries=3` becomes the number `3`; booleans accept
3085
+ `true`/`false`/`1`/`0`/`yes`/`no`), so author your schema with clean types.
3086
+
3087
+ ### create()
3088
+
3089
+ Create a ref creation trigger (branches/tags). Returns a frozen `CreateTriggerConfig`.
3090
+
3091
+ ```typescript
3092
+ function create(config?: CreateConfigInput): CreateTriggerConfig;
3093
+ ```
3094
+
3095
+ **Config options:** `refTypes` (branch/tag), `patterns` (string/RegExp/array), `description`
3096
+
3097
+ ```typescript
3098
+ create(); // Any ref creation
3099
+ create({ refTypes: ['tag'], patterns: ['v*'] }); // Tag creation only
3100
+ ```
3101
+
3102
+ ### delete()
3103
+
3104
+ Create a ref deletion trigger (branches/tags). Returns a frozen `DeleteTriggerConfig`.
3105
+
3106
+ Note: Since `delete` is a JavaScript reserved word, import as `del`: `import { delete as del } from '@kici-dev/sdk'`
3107
+
3108
+ ```typescript
3109
+ function del(config?: DeleteConfigInput): DeleteTriggerConfig;
3110
+ ```
3111
+
3112
+ **Config options:** `refTypes` (branch/tag), `patterns` (string/RegExp/array), `description`
3113
+
3114
+ ```typescript
3115
+ del(); // Any ref deletion
3116
+ del({ refTypes: ['branch'], patterns: ['temp/*'] }); // Temp branch cleanup
3117
+ ```
3118
+
3119
+ ### status()
3120
+
3121
+ Create a commit status trigger. Returns a frozen `StatusTriggerConfig`.
3122
+
3123
+ ```typescript
3124
+ function status(config?: StatusConfigInput): StatusTriggerConfig;
3125
+ ```
3126
+
3127
+ **Config options:** `contexts` (picomatch strings like 'ci/\*'), `states` (error/failure/pending/success), `description`
3128
+
3129
+ ```typescript
3130
+ status(); // Any status
3131
+ status({ contexts: ['ci/*'], states: ['success'] }); // CI success
3132
+ ```
3133
+
3134
+ ### workflowRun()
3135
+
3136
+ Create a workflow_run trigger. Returns a frozen `WorkflowRunTriggerConfig`.
3137
+
3138
+ ```typescript
3139
+ function workflowRun(config?: WorkflowRunConfigInput): WorkflowRunTriggerConfig;
3140
+ ```
3141
+
3142
+ **Config options:** `actions` (requested/completed/in_progress), `workflows` (name filters), `conclusions` (success/failure/cancelled), `description`
3143
+
3144
+ ```typescript
3145
+ workflowRun(); // Any workflow run
3146
+ workflowRun({ workflows: ['CI'], actions: ['completed'], conclusions: ['success'] });
3147
+ ```
3148
+
3149
+ ### fork()
3150
+
3151
+ Create a fork trigger. No filter fields. Returns a frozen `ForkTriggerConfig`.
3152
+
3153
+ ```typescript
3154
+ function fork(config?: ForkConfigInput): ForkTriggerConfig;
3155
+ ```
3156
+
3157
+ ```typescript
3158
+ fork(); // Any fork event
3159
+ fork({ description: 'Track forks' }); // With description
3160
+ ```
3161
+
3162
+ ### star()
3163
+
3164
+ Create a star trigger. Returns a frozen `StarTriggerConfig`.
3165
+
3166
+ ```typescript
3167
+ function star(config?: StarConfigInput): StarTriggerConfig;
3168
+ ```
3169
+
3170
+ **Config options:** `actions` (created/deleted), `description`
3171
+
3172
+ ```typescript
3173
+ star(); // Any star event
3174
+ star({ actions: ['created'] }); // New stars only
3175
+ ```
3176
+
3177
+ ### watch()
3178
+
3179
+ Create a watch trigger. Returns a frozen `WatchTriggerConfig`.
3180
+
3181
+ ```typescript
3182
+ function watch(config?: WatchConfigInput): WatchTriggerConfig;
3183
+ ```
3184
+
3185
+ **Config options:** `actions` (started), `description`
3186
+
3187
+ ```typescript
3188
+ watch(); // Any watch event
3189
+ watch({ actions: ['started'] }); // Watch started only
3190
+ ```
3191
+
3192
+ ### webhook()
3193
+
3194
+ Create a catch-all webhook trigger for any GitHub event. Returns a frozen `WebhookTriggerConfig`. Unlike other triggers, `events` is **required** -- catch-all must specify what to catch.
3195
+
3196
+ ```typescript
3197
+ function webhook(config: WebhookConfigInput): WebhookTriggerConfig;
3198
+ ```
3199
+
3200
+ **Config options:** `events` (required string[]), `actions` (optional string[]), `repos` (optional cross-repo source patterns -- see [global workflows](https://docs.kici.dev/user/global-workflows/)), `description`
3201
+
3202
+ ```typescript
3203
+ webhook({ events: ['deployment'] }); // Deployment events
3204
+ webhook({ events: ['deployment', 'deployment_status'] }); // Multiple events
3205
+ webhook({ events: ['deployment'], actions: ['created'] }); // With action filter
3206
+ ```
3207
+
3208
+ #### Cross-source delivery
3209
+
3210
+ A `webhook()` trigger fires whenever a matching event arrives via **any inbound webhook source within the same org**, not just the source the workflow's repository is bound to. If your repo is registered through a github source and a separate generic source in the same org POSTs an event with a matching name, the workflow still runs.
3211
+
3212
+ Two important rules govern the cross-source path:
3213
+
3214
+ 1. **The registration's source owns dispatch credentials.** The runtime clone, auth, and check-status posting come from the source the workflow was registered with (via its default-branch push), never from the inbound source. A generic webhook fanning out to a github-registered workflow uses the github bundle's clone token provider — the generic source contributes only the event payload.
3215
+ 2. **Org isolation is structural.** A webhook delivered to org A can never trigger a workflow registered against org B. The lookup index is keyed on `(customerId, eventName)` so cross-org leakage is impossible.
3216
+
3217
+ The orchestrator emits `kici_cross_source_fanout_size` (histogram) per inbound webhook so operators can observe how many workflows each event reaches.
3218
+
3219
+ ### Event triggers
3220
+
3221
+ The following 6 trigger types support internal event routing, scheduling, lifecycle orchestration, and non-GitHub webhook sources.
3222
+
3223
+ ### kiciEvent()
3224
+
3225
+ Create a custom event trigger. Fires when a named internal event is emitted from a workflow step via `ctx.emit()`. Returns a frozen `KiciEventTriggerConfig`.
3226
+
3227
+ ```typescript
3228
+ function kiciEvent(config: KiciEventConfigInput): KiciEventTriggerConfig;
3229
+ ```
3230
+
3231
+ **Config options:**
3232
+
3233
+ ```typescript
3234
+ interface KiciEventConfigInput {
3235
+ name: string; // Required: event name to listen for
3236
+ match?: Record<string, unknown>; // JSONPath payload matching (e.g., { '$.env': 'prod' })
3237
+ not?: Record<string, unknown>; // Negative JSONPath filter
3238
+ source?: string; // Cross-repo source filter (e.g., 'org/infra-repo')
3239
+ description?: string;
3240
+ }
3241
+ ```
3242
+
3243
+ ```typescript
3244
+ kiciEvent({ name: 'deploy-complete' }); // Match by name
3245
+ kiciEvent({ name: 'deploy-complete', match: { '$.env': 'prod' } }); // With payload filter
3246
+ kiciEvent({ name: 'deploy-complete', not: { '$.env': 'staging' } }); // Negative filter
3247
+ kiciEvent({ name: 'deploy-complete', source: 'org/infra-repo' }); // Cross-repo
3248
+ ```
3249
+
3250
+ ### workflowComplete()
3251
+
3252
+ Create a workflow completion trigger. Fires automatically when another workflow finishes execution. Returns a frozen `WorkflowCompleteTriggerConfig`.
3253
+
3254
+ ```typescript
3255
+ function workflowComplete(config?: WorkflowCompleteConfigInput): WorkflowCompleteTriggerConfig;
3256
+ ```
3257
+
3258
+ **Config options:**
3259
+
3260
+ ```typescript
3261
+ interface WorkflowCompleteConfigInput {
3262
+ name?: string; // Filter by workflow name
3263
+ status?: WorkflowCompleteStatus[]; // Filter by completion status
3264
+ source?: string; // Cross-repo source filter
3265
+ description?: string;
3266
+ }
3267
+ type WorkflowCompleteStatus = 'success' | 'failed' | 'cancelled';
3268
+ ```
3269
+
3270
+ ```typescript
3271
+ workflowComplete(); // Any workflow completion
3272
+ workflowComplete({ name: 'CI' }); // Specific workflow
3273
+ workflowComplete({ name: 'CI', status: ['success'] }); // Success only
3274
+ workflowComplete({ name: 'CI', status: ['success'], source: 'org/repo' }); // Cross-repo
3275
+ ```
3276
+
3277
+ ### jobComplete()
3278
+
3279
+ Create a job completion trigger. Fires automatically when a specific job within a workflow finishes. Returns a frozen `JobCompleteTriggerConfig`.
3280
+
3281
+ ```typescript
3282
+ function jobComplete(config?: JobCompleteConfigInput): JobCompleteTriggerConfig;
3283
+ ```
3284
+
3285
+ **Config options:**
3286
+
3287
+ ```typescript
3288
+ interface JobCompleteConfigInput {
3289
+ workflow?: string; // Filter by workflow name
3290
+ job?: string; // Filter by job name
3291
+ status?: JobCompleteStatus[]; // Filter by completion status
3292
+ source?: string; // Cross-repo source filter
3293
+ description?: string;
3294
+ }
3295
+ type JobCompleteStatus = 'success' | 'failed' | 'cancelled' | 'skipped';
3296
+ ```
3297
+
3298
+ ```typescript
3299
+ jobComplete(); // Any job completion
3300
+ jobComplete({ workflow: 'CI', job: 'build' }); // Specific workflow + job
3301
+ jobComplete({ workflow: 'CI', job: 'build', status: ['success'] }); // Success only
3302
+ jobComplete({ workflow: 'CI', job: 'build', source: 'org/repo' }); // Cross-repo
3303
+ ```
3304
+
3305
+ `jobComplete()` starts a **new** workflow run that reacts to another job finishing (gated on the prior job's status). For same-run fan-out — generating follow-up jobs from a prior job's _outputs_ within the same run — use a result-aware [`dynamicJob(group, { needs, generate })`](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/#dynamicjob--result-aware-generation) instead.
3306
+
3307
+ ### genericWebhook()
3308
+
3309
+ Create a generic webhook trigger. Fires when a non-GitHub webhook is received from an external source configured via the admin API. Returns a frozen `GenericWebhookTriggerConfig`.
3310
+
3311
+ ```typescript
3312
+ function genericWebhook(config: GenericWebhookConfigInput): GenericWebhookTriggerConfig;
3313
+ ```
3314
+
3315
+ **Config options:**
3316
+
3317
+ ```typescript
3318
+ interface GenericWebhookConfigInput {
3319
+ source: string; // Required: must match `--name` from `kici-admin source add generic`
3320
+ events?: string[]; // Filter by event types
3321
+ match?: Record<string, unknown>; // JSONPath payload matching
3322
+ not?: Record<string, unknown>; // Negative JSONPath filter
3323
+ auth?: GenericWebhookAuth; // HMAC or API key authentication
3324
+ path?: string; // URL path pattern (replaces source for URL matching)
3325
+ description?: string;
3326
+ }
3327
+ ```
3328
+
3329
+ ```typescript
3330
+ genericWebhook({ source: 'argocd' }); // Any event from ArgoCD
3331
+ genericWebhook({ source: 'argocd', events: ['deploy.success'] }); // Specific events
3332
+ genericWebhook({ source: 'argocd', match: { '$.env': 'prod' } }); // With payload filter
3333
+ genericWebhook({ source: 'argocd', not: { '$.dry_run': true } }); // Negative filter
3334
+ genericWebhook({
3335
+ source: 'stripe',
3336
+ auth: { method: 'hmac-sha256', secret: 'stripe-key', signatureHeader: 'stripe-signature' },
3337
+ }); // HMAC auth
3338
+ genericWebhook({ source: 'slack', auth: { method: 'api-key', secret: 'slack-token' } }); // API key auth
3339
+ genericWebhook({ source: 'stripe', path: 'stripe/payments' }); // URL path pattern
3340
+ ```
3341
+
3342
+ ### schedule()
3343
+
3344
+ Create a cron-based schedule trigger. Returns a frozen `ScheduleTriggerConfig`.
3345
+
3346
+ ```typescript
3347
+ function schedule(config: ScheduleConfigInput): ScheduleTriggerConfig;
3348
+ ```
3349
+
3350
+ **Config options:**
3351
+
3352
+ ```typescript
3353
+ interface ScheduleConfigInput {
3354
+ cron: string; // Required: cron expression (5-field)
3355
+ timezone?: string; // Timezone for cron evaluation (default: 'UTC')
3356
+ description?: string; // Human-readable description of the schedule
3357
+ }
3358
+ ```
3359
+
3360
+ ```typescript
3361
+ schedule({ cron: '0 * * * *' }); // Every hour
3362
+ schedule({ cron: '0 0 * * *' }); // Daily at midnight UTC
3363
+ schedule({ cron: '0 9 * * 1', timezone: 'America/New_York' }); // Monday 9am ET
3364
+ schedule({ cron: '*/15 * * * *', description: 'health check every 15 min' });
3365
+ ```
3366
+
3367
+ ### lifecycle()
3368
+
3369
+ Create a lifecycle trigger for cross-workflow orchestration events. Returns a frozen `LifecycleTriggerConfig`.
3370
+
3371
+ ```typescript
3372
+ function lifecycle(config: LifecycleConfigInput): LifecycleTriggerConfig;
3373
+ ```
3374
+
3375
+ **Config options:**
3376
+
3377
+ ```typescript
3378
+ interface LifecycleConfigInput {
3379
+ events: LifecycleEvent[]; // Required: lifecycle events to listen for
3380
+ sources?: string[]; // Optional: filter by source repo (e.g., 'org/repo')
3381
+ description?: string; // Human-readable description
3382
+ }
3383
+
3384
+ type LifecycleEvent = 'workflow_complete' | 'job_complete' | 'job_failed' | 'registration_updated';
3385
+ ```
3386
+
3387
+ ```typescript
3388
+ lifecycle({ events: ['workflow_complete'] }); // Any workflow completion
3389
+ lifecycle({ events: ['job_failed'], sources: ['org/deploy-repo'] }); // Job failures from specific repo
3390
+ lifecycle({ events: ['registration_updated'] }); // Workflow registration changes
3391
+ ```
3392
+
3393
+ ### Branch patterns
3394
+
3395
+ Both `pr()` and `push()` (as well as `tag()`, `create()`, and `delete()`) accept glob strings and RegExp literals for pattern matching:
3396
+
3397
+ ```typescript
3398
+ // Glob patterns (micromatch syntax)
3399
+ pr({ target: ['main', 'release/*', 'feature/**'] });
3400
+
3401
+ // Regex patterns
3402
+ pr({ target: /^release\/v\d+\.\d+$/ });
3403
+
3404
+ // Mixed
3405
+ push({ branches: ['main', /^hotfix\//] });
3406
+ ```
3407
+
3408
+ Glob patterns use micromatch syntax. Regex patterns use standard JavaScript `RegExp`.
3409
+
3410
+ ---
3411
+
3412
+ ## SDK reference: validation & events
3413
+
3414
+ Source: https://docs.kici.dev/user/sdk/validation-events/
3415
+
3416
+ ## Validation
3417
+
3418
+ ### validateDag(nodes)
3419
+
3420
+ Validate a directed acyclic graph for correctness.
3421
+
3422
+ ```typescript
3423
+ function validateDag(nodes: DagNode[]): DagValidationResult;
3424
+ ```
3425
+
3426
+ **DagNode:**
3427
+
3428
+ ```typescript
3429
+ interface DagNode {
3430
+ id: string;
3431
+ needs: string[];
3432
+ }
3433
+ ```
3434
+
3435
+ **DagValidationResult** (discriminated union):
3436
+
3437
+ ```typescript
3438
+ // Valid graph with topological sort order
3439
+ { valid: true; sortedOrder: string[] }
3440
+
3441
+ // Cycle detected
3442
+ { valid: false; error: 'cycle'; nodesInCycle: string[] }
3443
+
3444
+ // Job depends on itself
3445
+ { valid: false; error: 'self-reference'; nodeId: string }
3446
+
3447
+ // Job depends on non-existent job
3448
+ { valid: false; error: 'missing-dependency'; nodeId: string; missingDep: string }
3449
+ ```
3450
+
3451
+ Checks (in order): self-references, missing dependencies, cycles (Kahn's algorithm).
3452
+
3453
+ ```typescript
3454
+ const result = validateDag([
3455
+ { id: 'lint', needs: [] },
3456
+ { id: 'test', needs: ['lint'] },
3457
+ { id: 'deploy', needs: ['test'] },
3458
+ ]);
3459
+
3460
+ if (result.valid) {
3461
+ console.log(result.sortedOrder); // ['lint', 'test', 'deploy']
3462
+ }
3463
+ ```
3464
+
3465
+ ## Event definitions
3466
+
3467
+ The `defineEvent()` helper creates typed event definitions with Zod validation schemas. Event definitions serve as contracts for custom event payloads used with `ctx.emit()` and `kiciEvent()`.
3468
+
3469
+ ### defineEvent(name, schema)
3470
+
3471
+ ```typescript
3472
+ function defineEvent<T extends z.ZodTypeAny>(name: string, schema: T): EventDefinition<T>;
3473
+ ```
3474
+
3475
+ **Parameters:**
3476
+
3477
+ | Parameter | Type | Required | Description |
3478
+ | --------- | ----------- | -------- | --------------------------------- |
3479
+ | `name` | `string` | yes | Unique event name |
3480
+ | `schema` | `z.ZodType` | yes | Zod schema for payload validation |
3481
+
3482
+ **Returns:** `EventDefinition<T>` -- a frozen event definition with `name` and `schema`.
3483
+
3484
+ ```typescript
3485
+ import { defineEvent, z } from '@kici-dev/sdk';
3486
+
3487
+ const deployComplete = defineEvent(
3488
+ 'deploy-complete',
3489
+ z.object({
3490
+ env: z.string(),
3491
+ version: z.string(),
3492
+ services: z.array(z.string()),
3493
+ }),
3494
+ );
3495
+ ```
3496
+
3497
+ The `z` (Zod) module is re-exported from `@kici-dev/sdk` so you can define event schemas without adding Zod as a direct dependency.
3498
+
3499
+ ## Emitting events
3500
+
3501
+ Workflow steps can emit custom events via `ctx.emit()`. Emitted events are delivered immediately (mid-workflow, not queued until completion) and can trigger other workflows that listen with `kiciEvent()`, `workflowComplete()`, or `jobComplete()` triggers.
3502
+
3503
+ ### ctx.emit(eventName, payload?, options?)
3504
+
3505
+ ```typescript
3506
+ emit(
3507
+ eventName: string,
3508
+ payload?: Record<string, unknown>,
3509
+ options?: EventEmitOptions,
3510
+ ): Promise<{ deliveryId: string }>;
3511
+ ```
3512
+
3513
+ **Parameters:**
3514
+
3515
+ | Parameter | Type | Required | Description |
3516
+ | ---------------- | ------------------------- | -------- | --------------------------------------------- |
3517
+ | `eventName` | `string` | yes | Name of the event to emit |
3518
+ | `payload` | `Record<string, unknown>` | no | Event payload data |
3519
+ | `options.target` | `{ repos?: string[] }` | no | Target specific repos for cross-repo delivery |
3520
+
3521
+ **Returns:** `Promise<{ deliveryId: string }>` -- a delivery receipt after the event is persisted and routed.
3522
+
3523
+ **Examples:**
3524
+
3525
+ ```typescript
3526
+ // Emit a simple event
3527
+ step('notify', async (ctx) => {
3528
+ await ctx.emit('deploy-complete', { env: 'prod', version: '1.2.3' });
3529
+ });
3530
+
3531
+ // Cross-repo targeting
3532
+ step('notify-other-repos', async (ctx) => {
3533
+ await ctx.emit(
3534
+ 'deploy-complete',
3535
+ { env: 'prod' },
3536
+ {
3537
+ target: { repos: ['org/other-repo', 'org/monitoring'] },
3538
+ },
3539
+ );
3540
+ });
3541
+ ```
3542
+
3543
+ ### Cross-repo event delivery
3544
+
3545
+ Events emitted from one repo can trigger workflows in another repo, provided:
3546
+
3547
+ 1. A trust relationship exists between the source and target repos (configured via the admin API)
3548
+ 2. The target workflow uses a trigger with `source` filter matching the emitting repo
3549
+
3550
+ ```typescript
3551
+ // In repo A: emit event
3552
+ step('deploy', async (ctx) => {
3553
+ await ctx.emit(
3554
+ 'deploy-complete',
3555
+ { env: 'prod' },
3556
+ {
3557
+ target: { repos: ['org/repo-B'] },
3558
+ },
3559
+ );
3560
+ });
3561
+
3562
+ // In repo B: listen for event from repo A
3563
+ workflow('post-deploy', {
3564
+ on: kiciEvent({ name: 'deploy-complete', source: 'org/repo-A' }),
3565
+ jobs: [postDeployJob],
3566
+ });
3567
+ ```
3568
+
3569
+ ### System events
3570
+
3571
+ The orchestrator automatically emits system events for workflow and job completions. You do not need to call `ctx.emit()` for these -- they are generated by the orchestrator after execution. Listen for them with `workflowComplete()` and `jobComplete()` triggers.
3572
+
3573
+ ---
3574
+
3575
+ ## SDK reference: waitFor
3576
+
3577
+ Source: https://docs.kici.dev/user/sdk/wait-for/
3578
+
3579
+ The SDK exposes two wait-for helpers — a generic function `waitFor()` and a step factory `waitForStep()` — for the common case where a workflow step should:
3580
+
3581
+ 1. **Poll** a condition on a fixed interval.
3582
+ 2. **Proceed** as soon as the condition is met, optionally running a success action.
3583
+ 3. **Fail or recover** gracefully when the deadline is exceeded, with an optional timeout action.
3584
+
3585
+ Both helpers wrap the same polling loop, so they share semantics and return shape. Pick `waitForStep()` when the wait is the whole job of a step; use `waitFor()` from anywhere — inside a multi-action step, a hook, or a bare async function.
3586
+
3587
+ ## `waitFor(options)`
3588
+
3589
+ Poll `check()` on a fixed interval until it returns a non-null value or the deadline is exceeded. Resolves to a discriminated result describing which outcome occurred.
3590
+
3591
+ ### Parameters
3592
+
3593
+ | Name | Type | Required | Description |
3594
+ | ---------------- | ---------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
3595
+ | `name` | `string` | No | Label that appears in log lines and in the timeout error. Defaults to `'waitFor'`. |
3596
+ | `check` | `() => Promise<TValue \| null>` | Yes | Polled inspection. Return the resolved value when the condition is met, or `null` to keep polling. |
3597
+ | `intervalMs` | `number` | No | Time between successive `check()` invocations. Defaults to `2000` milliseconds. |
3598
+ | `timeoutMs` | `number` | No | Total time budget for the wait. Defaults to `60000` milliseconds. |
3599
+ | `initialDelayMs` | `number` | No | Time to wait before the first `check()` invocation. Defaults to `0`. |
3600
+ | `onSuccess` | `(value: TValue) => Promise<TSuccess>` | No | Runs once after `check()` returns a non-null value. Its return value is surfaced as `result` on success. |
3601
+ | `onTimeout` | `(info: { elapsedMs: number; attempts: number }) => Promise<TTimeout>` | No | Runs when the deadline is exceeded. Its return value is surfaced as `result` on the `'timed-out'` outcome. |
3602
+ | `swallowErrors` | `boolean` | No | When `true` (default), errors thrown by `check()` are logged and polling continues. |
3603
+ | `log` | `(line: string) => void` | No | Sink for status lines. Defaults to `console.log`. |
3604
+
3605
+ ### Result
3606
+
3607
+ `waitFor()` resolves to a discriminated `WaitForResult` union:
3608
+
3609
+ | Outcome | Branch fields |
3610
+ | ------------- | ----------------------------------------------------------------------------------------------------- |
3611
+ | `'succeeded'` | `value: TValue`, `elapsedMs`, `attempts`, `result: TSuccess` (the `onSuccess` return or `undefined`). |
3612
+ | `'timed-out'` | `elapsedMs`, `attempts`, `result: TTimeout` (the `onTimeout` return). |
3613
+
3614
+ Narrow on `result.outcome` before reading the branch-specific fields.
3615
+
3616
+ When `onTimeout` is **not** supplied, the helper throws a `WaitForTimeoutError` instead of returning a `'timed-out'` result. The error exposes `stepName`, `elapsedMs`, and `attempts` as instance fields so a catch block can branch on them.
3617
+
3618
+ ### Cancellation and the deadline check
3619
+
3620
+ The loop inspects the deadline at the top of each iteration. A `check()` that takes longer than `intervalMs` is not aborted mid-flight; the helper has no `AbortSignal` plumbing. The step's own `timeout` field is the hard kill if the step needs to be interrupted unconditionally.
3621
+
3622
+ ### Example
3623
+
3624
+ ```typescript
3625
+ import { waitFor } from '@kici-dev/sdk';
3626
+
3627
+ const result = await waitFor({
3628
+ name: 'await-build-artifact',
3629
+ check: async () => {
3630
+ const artifact = await registry.findArtifact('myapp', 'v1.2.3');
3631
+ return artifact ?? null;
3632
+ },
3633
+ onSuccess: async (artifact) => ({ digest: artifact.digest }),
3634
+ intervalMs: 5000,
3635
+ timeoutMs: 5 * 60 * 1000,
3636
+ });
3637
+
3638
+ if (result.outcome === 'succeeded') {
3639
+ console.log(`Artifact ready: ${result.result.digest} (${result.attempts} polls)`);
3640
+ } else {
3641
+ console.log(`Gave up after ${result.elapsedMs} ms`);
3642
+ }
3643
+ ```
3644
+
3645
+ ## `waitForStep(name, options)`
3646
+
3647
+ A factory returning an SDK `Step` whose `run` body executes `waitFor(...)` and routes status lines through the step's structured logger.
3648
+
3649
+ ### Parameters
3650
+
3651
+ | Name | Type | Required | Description |
3652
+ | --------- | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
3653
+ | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
3654
+ | `options` | `Omit<WaitForOptions, 'name' \| 'log'>` | Yes | Same shape as `waitFor()` minus `name` (already provided) and `log` (provided by the step context). |
3655
+
3656
+ ### Result
3657
+
3658
+ `waitForStep(...)` returns `Step<WaitForResult<TValue, TSuccess, TTimeout>>`. Other steps can consume the result through the standard step output mechanisms.
3659
+
3660
+ ### Example
3661
+
3662
+ ```typescript
3663
+ import { waitForStep, job } from '@kici-dev/sdk';
3664
+
3665
+ const awaitMarker = waitForStep('await-marker', {
3666
+ check: async () => {
3667
+ const stat = await tryStatMarker('/tmp/build-ready');
3668
+ return stat ? { path: '/tmp/build-ready' } : null;
3669
+ },
3670
+ intervalMs: 1000,
3671
+ timeoutMs: 60_000,
3672
+ onTimeout: async ({ attempts }) => ({ aborted: true, attempts }),
3673
+ });
3674
+
3675
+ export const release = job('release', {
3676
+ runsOn: 'linux',
3677
+ steps: [awaitMarker],
3678
+ });
3679
+ ```
3680
+
3681
+ If `check()` throws while polling, the error is logged and polling continues — the default `swallowErrors: true` matches the "poll until healthy" pattern. Pass `swallowErrors: false` to fail fast on the first error instead.
3682
+
3683
+ ## See also
3684
+
3685
+ - [Core SDK reference](https://docs.kici.dev/user/sdk/core/) — the `step()`, `job()`, and `workflow()` factories that `waitForStep()` builds on.
3686
+ - [Idempotent helpers](https://docs.kici.dev/user/sdk/idempotent/) — `idempotent()` and `idempotentStep()` for check / apply patterns.
3687
+ - [Runtime types](https://docs.kici.dev/user/sdk/runtime/) — `StepContext`, `Logger`, and other surface used inside the helpers.
3688
+
3689
+ ---
3690
+
3691
+ ## SDK reference
3692
+
3693
+ Source: https://docs.kici.dev/user/sdk-reference/
3694
+
3695
+ Reference documentation for `@kici-dev/sdk`. The reference is split across five pages by topic.
3696
+
3697
+ | Page | Covers |
3698
+ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3699
+ | [Core](https://docs.kici.dev/user/sdk/core/) | `workflow()`, `job()`, `step()` factory functions and step / job authoring patterns (bare functions, output chaining, `needs`, dynamic groups). |
3700
+ | [Triggers](https://docs.kici.dev/user/sdk/triggers/) | All 22 trigger factories -- GitHub events (`pr`, `push`, `tag`, `comment`, ...), event triggers (`kiciEvent`, `workflowComplete`, `jobComplete`), `genericWebhook`, `schedule`, `lifecycle`, plus branch-pattern semantics. |
3701
+ | [Rules, matrix, dynamic jobs](https://docs.kici.dev/user/sdk/rules-matrix-dynamic/) | `rule()`, `skip()`, matrix builds (static + dynamic), and `dynamicJob()` / `dynamicGroup()`. |
3702
+ | [Caching](https://docs.kici.dev/user/sdk/caching/) | `CacheSpec`, declarative `cache` on jobs/steps, imperative `ctx.cache.restore()` / `ctx.cache.save()`, immutable keys, `restoreKeys` prefix fallback, per-org + per-ref isolation. |
3703
+ | [Validation & events](https://docs.kici.dev/user/sdk/validation-events/) | `validateDag()`, `defineEvent()`, event emission patterns. |
3704
+ | [Runtime](https://docs.kici.dev/user/sdk/runtime/) | Types index, `StepContext`, secrets, and fixtures. |
3705
+ | [Idempotent helpers](https://docs.kici.dev/user/sdk/idempotent/) | `idempotent()`, `idempotentStep()`, and the check-mode-aware `checkStep()` — check / apply pattern with typed results on both the skipped and applied branches. |
3706
+ | [Wait-for helpers](https://docs.kici.dev/user/sdk/wait-for/) | `waitFor()` and `waitForStep()` — poll a condition on an interval, run an optional success action, recover gracefully on timeout. |
3707
+
3708
+ The `@kici-dev/sdk` package re-exports the entire surface from a single entry point. Pick what you need:
3709
+
3710
+ ```typescript
3711
+ import { workflow, job, step, pr, push, rule, defineEvent } from '@kici-dev/sdk';
3712
+ ```
3713
+
3714
+ For the complete list of every named export (factory functions, triggers, rules, validation, hook factories, types), see the per-topic pages above.
3715
+
3716
+ ## See also
3717
+
3718
+ - [Getting started](https://docs.kici.dev/user/getting-started/) -- install the SDK, write your first workflow, test locally
3719
+ - [CLI reference](https://docs.kici.dev/user/cli-reference/) -- compile, test, and manage workflows from the command line
3720
+ - [Workflow patterns](https://docs.kici.dev/user/workflow-patterns/) -- common patterns using the SDK features documented above
3721
+ - [Secrets management (operator)](https://docs.kici.dev/operator/security/secrets/) -- configure encrypted secret storage and admin API
3722
+ - [Secrets architecture](https://docs.kici.dev/architecture/security/secrets/) -- encryption model, multi-backend, and data flow
3723
+ - [State machine](https://docs.kici.dev/architecture/execution/state-machine/) -- how execution states map to the lifecycle of jobs and steps
3724
+
3725
+ ---