@michaelrwalker/stagehand 0.5.3 → 0.6.1

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 (37) hide show
  1. package/README.md +8 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/script.d.ts +40 -20
  6. package/dist/script.d.ts.map +1 -1
  7. package/dist/script.js +4 -1
  8. package/dist/script.js.map +1 -1
  9. package/dist/types.d.ts +54 -0
  10. package/dist/types.d.ts.map +1 -1
  11. package/dist/ui/ansi.d.ts +12 -2
  12. package/dist/ui/ansi.d.ts.map +1 -1
  13. package/dist/ui/ansi.js +14 -2
  14. package/dist/ui/ansi.js.map +1 -1
  15. package/dist/ui/frame.d.ts +22 -2
  16. package/dist/ui/frame.d.ts.map +1 -1
  17. package/dist/ui/frame.js +38 -15
  18. package/dist/ui/frame.js.map +1 -1
  19. package/dist/ui/renderer.d.ts +6 -4
  20. package/dist/ui/renderer.d.ts.map +1 -1
  21. package/dist/ui/renderer.js +17 -9
  22. package/dist/ui/renderer.js.map +1 -1
  23. package/package.json +10 -5
  24. package/skills/building-scripts/getting-started/SKILL.md +210 -0
  25. package/skills/building-scripts/structuring-phases-and-steps/SKILL.md +197 -0
  26. package/skills/building-scripts/typed-context-and-input/SKILL.md +188 -0
  27. package/skills/building-scripts/typed-context-and-input/references/context-type-utilities.md +121 -0
  28. package/skills/caching/caching-expensive-work/SKILL.md +285 -0
  29. package/skills/caching/caching-expensive-work/references/cache-api.md +278 -0
  30. package/skills/compensation-safety/adding-rollbacks/SKILL.md +400 -0
  31. package/skills/compensation-safety/cleaning-context/SKILL.md +165 -0
  32. package/skills/compensation-safety/conditional-steps/SKILL.md +199 -0
  33. package/skills/errors-results/handling-results-and-errors/SKILL.md +281 -0
  34. package/skills/observability/log-placement-and-rendering/SKILL.md +167 -0
  35. package/skills/observability/reporting-progress/SKILL.md +203 -0
  36. package/skills/reuse/reusable-routines-and-mounts/SKILL.md +243 -0
  37. package/skills/reuse/splitting-steps-across-files/SKILL.md +226 -0
@@ -0,0 +1,197 @@
1
+ ---
2
+ name: 'structuring-phases-and-steps'
3
+ description: >
4
+ addPhase/addStep mechanics (flat and callback form), the implicit "Main" phase created on the first addStep, step and phase naming/uniqueness rules and DuplicateNameError, per-step retry/timeoutMs, and outline() for inspecting a script's declared structure without running it. Load this for "group these steps into phases," "retry a flaky step," "add a timeout to a step," or "inspect a script's structure without running it."
5
+ metadata:
6
+ type: 'core'
7
+ library: 'stagehand'
8
+ library_version: '0.5.3'
9
+ sources:
10
+ - 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
11
+ - 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
12
+ - 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
13
+ - 'miaklwalker/stagehand:docs/reference/errors.md'
14
+ - 'miaklwalker/stagehand:docs/reference/script.md'
15
+ - 'miaklwalker/stagehand:src/script.ts'
16
+ ---
17
+
18
+ # Stagehand — Structuring Phases and Steps
19
+
20
+ A script is built by chaining `addPhase` and `addStep` on a `Script` instance; each call returns a re-typed `Script`, so the chain itself is the script's outline. Phase boundaries are not cosmetic — they scope rollback (`rollback: "phase"`) and identify cache slots (`"phase"` for a cached phase, `"phase::step"` for a cached step).
21
+
22
+ ## Setup
23
+
24
+ ```ts
25
+ import { Script } from "@michaelrwalker/stagehand";
26
+
27
+ interface Input {
28
+ service: string;
29
+ }
30
+
31
+ const deploy = new Script<Input>({ name: "deploy" })
32
+ .addPhase("Validation")
33
+ .addStep({
34
+ name: "resolve commit",
35
+ handler: async ({ input }) => ({ sha: await Promise.resolve(`${input.service}-sha`) }),
36
+ })
37
+ .addPhase("Release")
38
+ .addStep({
39
+ name: "shift traffic",
40
+ handler: async ({ ctx }) => ({ released: ctx.sha }),
41
+ });
42
+
43
+ const result = await deploy.run({ service: "checkout" });
44
+ ```
45
+
46
+ ## Core Patterns
47
+
48
+ ### Group steps into phases (flat and callback form)
49
+
50
+ ```ts
51
+ new Script<{ n: number }>({ name: "t" })
52
+ .addPhase("A", (script) =>
53
+ script
54
+ .addStep({ name: "double", handler: ({ input }) => ({ doubled: input.n * 2 }) })
55
+ .addStep({ name: "label", handler: ({ ctx }) => ({ label: `n=${ctx.doubled}` }) }),
56
+ )
57
+ .addPhase("B")
58
+ .addStep({ name: "use", handler: ({ ctx }) => ({ echo: ctx.label }) });
59
+ ```
60
+
61
+ The callback form groups a phase's steps visually while preserving the same type flow as the flat form; the two are interchangeable.
62
+
63
+ ### Retry with backoff and veto specific errors
64
+
65
+ ```ts
66
+ .addStep({
67
+ name: "publish",
68
+ retry: {
69
+ attempts: 4,
70
+ delayMs: (attempt) => attempt * 200,
71
+ retryIf: (error) => !/^4\d\d/.test((error as Error).message),
72
+ },
73
+ handler: async ({ attempt, warn }) => {
74
+ if (attempt > 1) warn(`retrying (attempt ${attempt})`);
75
+ await publish();
76
+ },
77
+ })
78
+ ```
79
+
80
+ `attempts` is the total number of tries including the first; `delayMs` runs between failed attempts, and `retryIf` can veto a retry for an error that will never succeed by retrying (a 401, for instance).
81
+
82
+ ### Time out a step
83
+
84
+ ```ts
85
+ .addStep({
86
+ name: "publish",
87
+ timeoutMs: 30_000,
88
+ handler: async ({ signal }) => publishWithSignal(signal),
89
+ })
90
+ ```
91
+
92
+ `timeoutMs` aborts the step's `signal` once elapsed, surfacing as `StepTimeoutError`; a timeout counts as a failed attempt, subject to the same `retryIf` if `retry` is also set.
93
+
94
+ ### Inspect a script's structure without running it
95
+
96
+ ```ts
97
+ deploy.outline();
98
+ // [
99
+ // { phase: "Validation", steps: ["resolve commit"] },
100
+ // { phase: "Release", steps: ["shift traffic"] },
101
+ // ]
102
+ ```
103
+
104
+ `outline()` reports every declared phase and its step names, in order, without executing any handler.
105
+
106
+ ## Common Mistakes
107
+
108
+ ### MEDIUM Reusing a step name within the same phase
109
+
110
+ Wrong:
111
+ ```ts
112
+ .addStep({ name: "validate", handler: checkA })
113
+ .addStep({ name: "validate", handler: checkB }) // DuplicateNameError
114
+ ```
115
+
116
+ Correct:
117
+ ```ts
118
+ .addStep({ name: "validate input", handler: checkA })
119
+ .addStep({ name: "validate permissions", handler: checkB })
120
+ ```
121
+
122
+ A step name must be unique within its own phase, since a step's cache slot is `"phase::step"`; the same name in two different phases is fine, but a duplicate inside one phase throws `DuplicateNameError` at build time, before `run()` is ever called.
123
+
124
+ Source: src/script.ts addStep() DuplicateNameError check; docs/reference/errors.md
125
+
126
+ ### MEDIUM Never calling addPhase, then relying on rollback: "phase"
127
+
128
+ Wrong:
129
+ ```ts
130
+ new Script({ name: "t", rollback: "phase" })
131
+ .addStep(stepA).addStep(stepB).addStep(stepC);
132
+ // rollback: "phase" here behaves exactly like "all" — one phase, "Main"
133
+ ```
134
+
135
+ Correct:
136
+ ```ts
137
+ new Script({ name: "t", rollback: "phase" })
138
+ .addPhase("Prepare").addStep(stepA)
139
+ .addPhase("Commit").addStep(stepB).addStep(stepC);
140
+ ```
141
+
142
+ `addStep` creates an implicit `"Main"` phase the first time it is called if `addPhase` was never used, so with every step in that one phase, `rollback: "phase"` and `rollback: "all"` unwind identically.
143
+
144
+ Source: src/script.ts currentPhase() implicit "Main"; docs/guides/phases-and-steps.md
145
+
146
+ ### HIGH Chaining .addStep() directly after .use() without opening a new phase
147
+
148
+ Wrong:
149
+ ```ts
150
+ new Script({ name: "t" })
151
+ .use(pullChannel)
152
+ .addStep({ name: "sneaky", handler: () => ({}) });
153
+ // StepDefinitionError: Cannot add a step after use()
154
+ ```
155
+
156
+ Correct:
157
+ ```ts
158
+ new Script({ name: "t" })
159
+ .use(pullChannel)
160
+ .addPhase("Report")
161
+ .addStep({ name: "summarize", handler: ({ ctx }) => ({}) });
162
+ ```
163
+
164
+ A routine owns whole phases, so the last phase spliced in by `use()` belongs to the fragment, and a bare `addStep` right after it is refused at build time even though the fluent chain reads naturally.
165
+
166
+ Source: src/script.ts currentPhase() StepDefinitionError; docs/guides/reusable-scripts.md
167
+
168
+ ### MEDIUM Wrapping the whole script in one giant step or phase instead of decomposing the work
169
+
170
+ Wrong:
171
+ ```ts
172
+ new Script<Input>({ name: "deploy" })
173
+ .addStep({
174
+ name: "deploy",
175
+ handler: async ({ input }) => {
176
+ const sha = await resolveSha(input.ref);
177
+ const artifact = await compile();
178
+ const uploadId = await upload(artifact);
179
+ await shiftTraffic(uploadId);
180
+ return { uploadId };
181
+ },
182
+ });
183
+ ```
184
+
185
+ Correct:
186
+ ```ts
187
+ new Script<Input>({ name: "deploy" })
188
+ .addPhase("Validation").addStep({ name: "resolve commit", handler: async ({ input }) => ({ sha: await resolveSha(input.ref) }) })
189
+ .addPhase("Build").addStep({ name: "compile bundle", handler: async () => ({ artifact: await compile() }) })
190
+ .addPhase("Release")
191
+ .addStep({ name: "upload artifact", handler: async ({ ctx }) => ({ uploadId: await upload(ctx.artifact) }), rollbackKeys: ["uploadId"], rollback: async ({ output }) => cleanup(output.uploadId) })
192
+ .addStep({ name: "shift traffic", handler: async ({ ctx }) => { await shiftTraffic(ctx.uploadId); return {}; } });
193
+ ```
194
+
195
+ The value of phases and steps — incremental typed context, phase-scoped rollback, per-step retry/caching/observability — only shows up when the work is actually split along its natural boundaries; one opaque step gets none of it: no per-stage rollback, no per-stage cache, and a phase tree that renders as a single spinner instead of legible progress.
196
+
197
+ Source: maintainer interview ("not breaking things into distinct enough stages... writing a whole script around the framework instead of using the framework to write the whole script")
@@ -0,0 +1,188 @@
1
+ ---
2
+ name: 'typed-context-and-input'
3
+ description: >
4
+ Covers how a Script's Ctx type parameter accumulates purely from addStep handler
5
+ return types (no hand-written generics), the input-vs-ctx distinction (input is
6
+ fixed for the run, ctx grows step by step), and defineInput for validating run()
7
+ input against any Standard Schema library (Zod, Valibot, ArkType, Effect Schema).
8
+ Load this for "validate my script's input," "why is this key missing from ctx,"
9
+ or "should I read input.x or ctx.x here."
10
+ metadata:
11
+ type: 'core'
12
+ library: 'stagehand'
13
+ library_version: '0.5.3'
14
+ sources:
15
+ - 'miaklwalker/stagehand:docs/guides/typed-context.md'
16
+ - 'miaklwalker/stagehand:docs/reference/errors.md'
17
+ - 'miaklwalker/stagehand:docs/reference/script.md'
18
+ - 'miaklwalker/stagehand:src/script.ts'
19
+ - 'miaklwalker/stagehand:src/types.ts'
20
+ ---
21
+
22
+ # Stagehand — The Typed Context and Validated Input
23
+
24
+ `Ctx`, the second type parameter on `Script`, starts as `{}` and widens every
25
+ time `addStep` sees a handler that returns an object. Nothing about `Ctx` is
26
+ ever written by hand — it is inferred purely from handler return types.
27
+
28
+ ## Setup
29
+
30
+ ```ts
31
+ import { Script } from "@michaelrwalker/stagehand";
32
+ import { z } from "zod";
33
+
34
+ const deploy = new Script({ name: "deploy" })
35
+ .defineInput(z.object({ service: z.string() }))
36
+ .addStep({
37
+ name: "resolve commit",
38
+ handler: async ({ input }) => ({ sha: await git.head(input.service) }),
39
+ // ^ input.service: string
40
+ })
41
+ .addStep({
42
+ name: "build",
43
+ handler: ({ ctx }) => ({ artifact: `${ctx.sha}.tar.gz` }),
44
+ // ^ ctx.sha: string, from the previous step
45
+ });
46
+
47
+ const result = await deploy.run({ service: "api" });
48
+ ```
49
+
50
+ ## Core Patterns
51
+
52
+ ### Context widens automatically from return values
53
+
54
+ ```ts
55
+ new Script({ name: "pipeline" })
56
+ .addStep({ name: "one", handler: () => ({ token: "x" }) })
57
+ // Ctx = { token: string }
58
+ .addStep({ name: "two", handler: ({ ctx }) => ({ n: ctx.token.length }) });
59
+ // Ctx = { token: string; n: number }
60
+ ```
61
+
62
+ Each `ctx` parameter is typed as everything every earlier step produced (minus
63
+ anything cleaned). A handler that returns nothing (`void`/`undefined`) leaves
64
+ `Ctx` unchanged; returning a key that already exists overwrites it, both at
65
+ the type level and at runtime, using `Object.assign` semantics.
66
+
67
+ ### Reading `input` vs `ctx`
68
+
69
+ ```ts
70
+ new Script({ name: "sync" })
71
+ .defineInput(z.object({ tenantId: z.string() }))
72
+ .addStep({
73
+ name: "fetch",
74
+ // input.tenantId is fixed for the whole run
75
+ // ctx starts empty here, since no earlier step has run
76
+ handler: async ({ input }) => ({ rows: await fetchRows(input.tenantId) }),
77
+ })
78
+ .addStep({
79
+ name: "summarize",
80
+ // ctx.rows is available now; input.tenantId still is too
81
+ handler: ({ input, ctx }) => ({
82
+ summary: `${input.tenantId}: ${ctx.rows.length} rows`,
83
+ }),
84
+ });
85
+ ```
86
+
87
+ `input` never changes shape mid-script (unless the step belongs to a mounted
88
+ routine with its own mapped input). `ctx` grows step by step — that is the
89
+ whole reason it carries the accumulating type parameter and `input` does not.
90
+
91
+ ### Validating `run()` input with any Standard Schema library
92
+
93
+ ```ts
94
+ import { z } from "zod";
95
+ import { SchemaValidationError } from "@michaelrwalker/stagehand";
96
+
97
+ const deploy = new Script({ name: "deploy" })
98
+ .defineInput(z.object({ service: z.string() }));
99
+
100
+ try {
101
+ const result = await deploy.run({ service: "api" });
102
+ } catch (error) {
103
+ if (error instanceof SchemaValidationError) {
104
+ // error.issues: ReadonlyArray<StandardSchemaV1.Issue>
105
+ console.error(error.message); // "service: Required" etc., joined with "; "
106
+ } else {
107
+ throw error;
108
+ }
109
+ }
110
+ ```
111
+
112
+ `defineInput` accepts any [Standard Schema](https://standardschema.dev)
113
+ validator (Zod, Valibot, ArkType, Effect Schema, or hand-rolled) and infers
114
+ `In` from its output type. Validation runs before any phase executes.
115
+
116
+ ## Common Mistakes
117
+
118
+ ### HIGH Mutating ctx directly instead of returning new keys
119
+
120
+ Wrong:
121
+ ```ts
122
+ .addStep({
123
+ name: "increment",
124
+ handler: ({ ctx }) => {
125
+ ctx.total = ctx.total + 1; // "works" locally, breaks caching/rollback
126
+ },
127
+ })
128
+ ```
129
+
130
+ Correct:
131
+ ```ts
132
+ .addStep({
133
+ name: "increment",
134
+ handler: ({ ctx }) => ({ total: ctx.total + 1 }),
135
+ })
136
+ ```
137
+
138
+ `ctx` is the same live object every handler shares, so a stray assignment does not throw — but only a handler's *return value* is folded into a cached phase's delta and into a rollback's `rollbackKeys` pick, so a directly-mutated key is silently never cached and invisible to any rollback that asks for it.
139
+
140
+ Source: src/script.ts (merge() applied only to handler output); docs/guides/cleaning-context.md
141
+
142
+ ### MEDIUM Calling defineInput after addStep/addPhase, expecting it to retype earlier steps
143
+
144
+ Wrong:
145
+ ```ts
146
+ new Script({ name: "t" })
147
+ .addStep({ name: "one", handler: ({ input }) => ({}) }) // sees old In
148
+ .defineInput(z.object({ service: z.string() }));
149
+ ```
150
+
151
+ Correct:
152
+ ```ts
153
+ new Script({ name: "t" })
154
+ .defineInput(z.object({ service: z.string() }))
155
+ .addStep({ name: "one", handler: ({ input }) => ({}) }); // input.service: string
156
+ ```
157
+
158
+ `defineInput` returns a re-typed `Script`; it does not retroactively change steps already added, which still see the old `In` — it must be called first, immediately after construction.
159
+
160
+ Source: docs/guides/typed-context.md; src/script.ts defineInput()
161
+
162
+ ### HIGH Assuming a failed defineInput validation shows up in RunResult
163
+
164
+ Wrong:
165
+ ```ts
166
+ const result = await deploy.run(rawInput);
167
+ if (!result.ok) { /* never reached on bad input — it throws instead */ }
168
+ ```
169
+
170
+ Correct:
171
+ ```ts
172
+ try {
173
+ const result = await deploy.run(rawInput);
174
+ } catch (error) {
175
+ if (error instanceof SchemaValidationError) console.error(error.issues);
176
+ else throw error;
177
+ }
178
+ ```
179
+
180
+ `SchemaValidationError` is always thrown directly by `run()`, never returned as `{ ok: false }`, and is unaffected by `throwOnError` — there is no partial result to produce since no phase executed.
181
+
182
+ Source: docs/guides/typed-context.md; docs/reference/errors.md
183
+
184
+ See also: skills/compensation-safety/cleaning-context/SKILL.md — mutating ctx directly instead of returning it from a handler is the same "context is not just a plain mutable object" mental model both skills depend on.
185
+
186
+ ## References
187
+
188
+ - [Context type utilities reference](references/context-type-utilities.md)
@@ -0,0 +1,121 @@
1
+ # Context type utilities
2
+
3
+ These are the type-level helpers Stagehand uses internally to compute `Ctx`,
4
+ `RollbackContext`, and `WithStepFor` chains. All of them run automatically as
5
+ part of `addStep`, `defineInput`, and `stepFor` — a script author almost never
6
+ imports or writes them directly. They're documented here for the rare case of
7
+ debugging a confusing inferred type, or hand-writing a step definition outside
8
+ `addStep` (e.g. a shared library file) where the composed context needs to be
9
+ spelled out explicitly.
10
+
11
+ ## `Prettify<T>`
12
+
13
+ ```ts
14
+ export type Prettify<T> = { [K in keyof T]: T[K] } & {};
15
+ ```
16
+
17
+ Flattens an intersection type (`Omit<Ctx, K> & Out`) into a single object type
18
+ so editor hovers show a real shape (`{ a: string; b: number }`) instead of a
19
+ chain of `&` operators. Applied automatically everywhere a context type is
20
+ constructed — `result.ctx`, a step's `ctx` parameter, `RollbackData` — so
21
+ there's nothing to opt into. Reach for it directly only when hand-writing a
22
+ type alias for a shared step's context and the hover in your editor is showing
23
+ an unreadable intersection.
24
+
25
+ ## `Merge<Ctx, Out>`
26
+
27
+ ```ts
28
+ export type Merge<Ctx, Out> = [Out] extends [void]
29
+ ? Ctx
30
+ : [Out] extends [undefined]
31
+ ? Ctx
32
+ : Prettify<Omit<Ctx, keyof Out> & Out>;
33
+ ```
34
+
35
+ Computes the context after a step contributes `Out`: later keys win over
36
+ earlier ones of the same name (`Omit<Ctx, keyof Out> & Out`), and a handler
37
+ that returns `void` or `undefined` leaves `Ctx` completely untouched. This is
38
+ the type-level mirror of the runtime `Object.assign`-based merge in
39
+ `src/script.ts`. It is the core of how every `addStep` call widens `Ctx`, and
40
+ also underlies `WithStepFor` (see below). Almost never written by hand —
41
+ inference through `addStep` produces it automatically.
42
+
43
+ ## `Cleaned<Ctx, Keys>`
44
+
45
+ ```ts
46
+ export type Cleaned<Ctx, Keys extends PropertyKey> = Prettify<Omit<Ctx, Keys>>;
47
+ ```
48
+
49
+ What a step's `clean` field leaves behind for every later step: `Keys` are
50
+ dropped from both the runtime context and the type. This is what makes a
51
+ cleaned key a genuine compile error to reach for afterward, not just a
52
+ runtime `undefined`. Computed automatically wherever `clean` is used; there's
53
+ no reason to reference `Cleaned` directly unless writing tooling that
54
+ inspects a script's declared clean keys.
55
+
56
+ ## `RollbackData<Ctx, Keys>`
57
+
58
+ ```ts
59
+ export type RollbackData<Ctx, Keys extends readonly PropertyKey[]> = Pick<
60
+ Ctx,
61
+ Extract<Keys[number], keyof Ctx>
62
+ >;
63
+ ```
64
+
65
+ The slice of context a `rollback` function actually receives: exactly the
66
+ keys named in that step's `rollbackKeys`, and nothing else. With no keys
67
+ declared, this resolves to `{}` — a rollback has to explicitly ask for what
68
+ it needs via `rollbackKeys`; it cannot see the full `Ctx`. `addStep` uses this
69
+ (wrapped in `Prettify`) to type the `ctx` parameter of `RollbackContext`. You
70
+ would only reference `RollbackData` directly when writing a rollback handler
71
+ outside `addStep` and need to spell out its parameter type by hand.
72
+
73
+ ## `WithStepFor<Step, Rest>`
74
+
75
+ ```ts
76
+ export type WithStepFor<
77
+ Step extends { handler: (context: never) => unknown },
78
+ Rest extends object = {},
79
+ > = Step extends { handler: (context: never) => infer Out }
80
+ ? Merge<Rest, Awaited<Out>>
81
+ : never;
82
+ ```
83
+
84
+ Computes the context a *later* step sees, given an earlier step (declared
85
+ with `stepFor`) and whatever else is already there (`Rest`). This is the one
86
+ utility from this file developers reach for directly on a regular basis — it
87
+ is the mechanism behind [Splitting Steps Across Files](../../../reuse/splitting-steps-across-files/SKILL.md):
88
+
89
+ ```ts
90
+ export const logIntoDb = stepFor<Input>()({
91
+ name: "log into db",
92
+ handler: async () => ({ conn: await connect() }),
93
+ });
94
+
95
+ export const loadRules = stepFor<
96
+ Input,
97
+ WithStepFor<typeof logIntoDb, { conditions: Array<{ name: string }> }>
98
+ >()({
99
+ name: "load rules",
100
+ // ctx.conn — from the step; ctx.conditions — from the rest
101
+ handler: ({ ctx }) => ({ rules: ctx.conn.query(ctx.conditions) }),
102
+ });
103
+ ```
104
+
105
+ It nests for chains of steps, without a separate variadic form:
106
+
107
+ ```ts
108
+ WithStepFor<typeof second, WithStepFor<typeof first, { conditions: string[] }>>
109
+ ```
110
+
111
+ Read inside out: start with `{ conditions: string[] }`, add what `first`
112
+ returns, then what `second` returns. That's also the precedence — on a name
113
+ collision the outermost step wins, matching the way a later step's return
114
+ value shadows an earlier key at runtime. Order the nesting to match the order
115
+ the steps actually run in, or the type and the runtime disagree.
116
+
117
+ `WithStepFor` only reads the step's *output* — its own `Ctx` requirement
118
+ against `Rest` is not checked here; that check still happens when the step
119
+ definition is passed to `addStep` on a real script. A step returning nothing
120
+ leaves `Rest` untouched, and an `async` handler's return is awaited first
121
+ (`Awaited<Out>`), so both fall out without a special case.