@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,203 @@
1
+ ---
2
+ name: 'reporting-progress'
3
+ description: >
4
+ Covers the handler UI surface — `status(text)` vs `note(text)`, `progress({ total, label })` and its `ProgressHandle` (`update`/`increment`/`setTotal`/`setLabel`/`done`), `task(label)`/`tasks([...] as const)` and `TaskHandle`/`TaskListHandle`, and the `log`/`info`/`warn`/`error`/`success` severity lines. Load this for "show a progress bar," "put a row count on the finished step line," "run a typed checklist inside a step," or any handler-level UI call that isn't about where the output physically renders.
5
+ metadata:
6
+ type: 'core'
7
+ library: 'stagehand'
8
+ library_version: '0.5.3'
9
+ sources:
10
+ - 'miaklwalker/stagehand:docs/guides/handler-surface.md'
11
+ - 'miaklwalker/stagehand:src/context.ts'
12
+ - 'miaklwalker/stagehand:src/script.ts'
13
+ ---
14
+
15
+ # Stagehand — Reporting Progress From a Step
16
+
17
+ Every handler receives `status`, `note`, `progress`, `task`, `tasks`, and the five log methods as part of its single context object. `rollback` gets a smaller subset — `log` (no `info`/`warn`/`error`/`success`), `status`, `note`, and `progress`, but not `task`/`tasks`.
18
+
19
+ ## Setup
20
+
21
+ ```ts
22
+ import { Script } from "@michaelrwalker/stagehand";
23
+
24
+ const result = await new Script({ name: "sync" })
25
+ .addStep({
26
+ name: "fetch records",
27
+ handler: async ({ status, note, progress, log }) => {
28
+ status("connecting");
29
+ log("opened connection");
30
+
31
+ const total = 428;
32
+ const bar = progress({ total, label: "fetching" });
33
+ const rows: number[] = [];
34
+ for (let i = 0; i < total; i++) {
35
+ rows.push(i);
36
+ bar.update(i + 1);
37
+ }
38
+ bar.done();
39
+
40
+ note(`${rows.length} rows found`);
41
+ return { rows };
42
+ },
43
+ })
44
+ .run({});
45
+ ```
46
+
47
+ ## Core Patterns
48
+
49
+ ### `status` for scratch space, `note` for what should survive on the finished line
50
+
51
+ ```ts
52
+ .addStep({
53
+ name: "query database",
54
+ handler: async ({ status, note }) => {
55
+ status("scanning"); // ⠸ query database › scanning
56
+ const rows = await db.all(sql);
57
+ note(`${rows.length} rows found`);
58
+ return { rows };
59
+ },
60
+ })
61
+ // ✔ query database (400 rows found) 1.2s
62
+ ```
63
+
64
+ `status` is wiped the instant the step settles; `note` is written into the step's title and stays. Calling either again replaces the previous text — neither is cumulative — and `note("")` removes the annotation entirely.
65
+
66
+ ### A progress bar with a mid-flight relabel
67
+
68
+ ```ts
69
+ handler: async ({ progress }) => {
70
+ const bar = progress({ total: 428, label: "resolving" });
71
+ for (let i = 0; i <= 428; i += 17) {
72
+ bar.update(i, i < 200 ? "resolving" : "linking");
73
+ await doWork();
74
+ }
75
+ bar.done();
76
+ return { packages: 428 };
77
+ }
78
+ ```
79
+
80
+ `progress` returns a `ProgressHandle` with `update(value, label?)`, `increment(by = 1)`, `setTotal(total)`, `setLabel(label)`, `done()`, and readonly `.value`/`.total`; every value it accepts is clamped to `[0, total]`.
81
+
82
+ ### A typed checklist with `tasks()`
83
+
84
+ ```ts
85
+ handler: async ({ tasks }) => {
86
+ const list = tasks(["typecheck", "transform", "minify", "write manifest"] as const);
87
+
88
+ for (const key of ["typecheck", "transform", "minify", "write manifest"] as const) {
89
+ const item = list.get(key);
90
+ item.start();
91
+ await run(key);
92
+ item.succeed();
93
+ }
94
+ }
95
+
96
+ async function run(step: string) {}
97
+ ```
98
+
99
+ `tasks(labels)` takes the label list as a `const` tuple, so `list.get(key)` is typed to exactly those labels — a single ad hoc item can also be created with `task(label)`, returning one `TaskHandle` (`start`/`label`/`succeed`/`fail`/`skip`, each taking an optional replacement text).
100
+
101
+ ### Severity-tagged lines with `log`/`info`/`warn`/`error`/`success`
102
+
103
+ ```ts
104
+ handler: async ({ log, info, warn, error, success }) => {
105
+ log("starting migration");
106
+ info("using batch size 500");
107
+ warn("retrying batch 3");
108
+ error("batch 7 failed, continuing");
109
+ success("migration complete");
110
+ return {};
111
+ }
112
+ ```
113
+
114
+ All five just differ in icon/style; where the line lands is controlled by the script's `logPlacement` option, covered in the log-placement-and-rendering skill.
115
+
116
+ ## Common Mistakes
117
+
118
+ ### HIGH Using status() for a value meant to persist on the finished step line
119
+
120
+ Wrong:
121
+ ```ts
122
+ handler: async ({ status }) => {
123
+ const rows = await db.all(sql);
124
+ status(`${rows.length} rows found`); // gone the moment this step finishes
125
+ return { rows };
126
+ }
127
+ ```
128
+
129
+ Correct:
130
+ ```ts
131
+ handler: async ({ status, note }) => {
132
+ status("scanning");
133
+ const rows = await db.all(sql);
134
+ note(`${rows.length} rows found`); // stays on the finished line
135
+ return { rows };
136
+ }
137
+ ```
138
+
139
+ `status` is scratch space for a step in flight — it is deleted (`delete item.state.statusText`) the instant the step settles, whether it succeeds or fails; only `note` survives onto the finished line.
140
+
141
+ Source: docs/guides/handler-surface.md; src/script.ts (delete item.state.statusText on success/failure)
142
+
143
+ ### LOW Calling note() repeatedly expecting it to append, like a log
144
+
145
+ Wrong:
146
+ ```ts
147
+ note("400 rows");
148
+ note("2 skipped"); // "400 rows" is gone — the title now reads "2 skipped"
149
+ ```
150
+
151
+ Correct:
152
+ ```ts
153
+ note(`${rowCount} rows, ${skipped} skipped`);
154
+ ```
155
+
156
+ `note` replaces whatever it previously set rather than accumulating; `note("")` clears it.
157
+
158
+ Source: docs/guides/handler-surface.md
159
+
160
+ ### LOW Passing a plain string[] (not `as const`) to tasks()
161
+
162
+ Wrong:
163
+ ```ts
164
+ const labels = ["typecheck", "transform", "minify"];
165
+ const list = tasks(labels);
166
+ list.get("tpyecheck"); // typo compiles — labels was string[], not a literal tuple
167
+ ```
168
+
169
+ Correct:
170
+ ```ts
171
+ const list = tasks(["typecheck", "transform", "minify"] as const);
172
+ list.get("tpyecheck"); // compile error
173
+ ```
174
+
175
+ `tasks(labels)` needs the label list as a const tuple for `list.get(key)` to be typed to exactly those labels; a widened `string[]` makes `get()` accept any string, including a typo, with no compile error.
176
+
177
+ Source: docs/guides/handler-surface.md
178
+
179
+ ### LOW Passing a value outside [0, total] to progress and expecting an error or warning
180
+
181
+ Wrong:
182
+ ```ts
183
+ const bar = progress({ total: 100 });
184
+ bar.update(bytesUploaded); // bytesUploaded is actually in KB — silently clamps to 100
185
+ ```
186
+
187
+ Correct:
188
+ ```ts
189
+ const bar = progress({ total: totalBytes });
190
+ bar.update(bytesUploaded); // units matched to `total`
191
+ ```
192
+
193
+ `update`/`increment`/`setTotal` all clamp silently to the valid range rather than throwing, so an off-by-one or unit mismatch in the caller's own math shows up only as a bar stuck at 0% or 100% with no diagnostic.
194
+
195
+ Source: src/context.ts createProgress() (clamp(...))
196
+
197
+ ### HIGH Tension: Rich live observability vs. complete log record
198
+
199
+ `logPlacement` "step"/"bottom" produce the most pleasant live-TTY experience by capping what they retain, while "scrollback" is the only placement that is both complete and the default, and one choice applies to the whole script. Agents optimizing for this skill's goal (a beautifully-nested live view of status/note/progress/tasks next to the step they belong to) tend to leave `logPlacement` at "step" or "bottom" for a script that also needs a guaranteed-complete CI/audit trail, because they don't account for those two placements silently discarding older lines beyond a handful.
200
+
201
+ See also: skills/observability/log-placement-and-rendering/SKILL.md § Common Mistakes
202
+
203
+ See also: skills/observability/log-placement-and-rendering/SKILL.md — where status/note/log actually render depends entirely on logPlacement and which renderer is active; the two skills describe one mechanism from two ends.
@@ -0,0 +1,243 @@
1
+ ---
2
+ name: 'reusable-routines-and-mounts'
3
+ description: >
4
+ Covers routineFor<In, Ctx>() for declaring a mountable group of phases, and Script.use(routine | script, { as?, input? })
5
+ for splicing it into a host with optional renaming and per-mount input mapping. Load this for "share a fetch-and-normalize
6
+ pipeline across scripts," "mount the same routine twice for two tenants," or "feed a mounted fragment its own input."
7
+ metadata:
8
+ type: 'core'
9
+ library: 'stagehand'
10
+ library_version: '0.5.3'
11
+ sources:
12
+ - 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
13
+ - 'miaklwalker/stagehand:docs/guides/caching.md'
14
+ - 'miaklwalker/stagehand:docs/reference/errors.md'
15
+ - 'miaklwalker/stagehand:docs/reference/script.md'
16
+ - 'miaklwalker/stagehand:src/script.ts'
17
+ - 'miaklwalker/stagehand:test/routine.test.ts'
18
+ ---
19
+
20
+ # Stagehand — Reusable Routines and Mounts
21
+
22
+ `routineFor` declares a group of phases away from any particular script; `use()` splices that routine (or a plain `Script`) into a host, in order, merging everything it produces into the host's typed context.
23
+
24
+ ## Setup
25
+
26
+ ```ts
27
+ // routines/channel.ts
28
+ import { routineFor } from "@michaelrwalker/stagehand";
29
+
30
+ export const fetchChannel = routineFor<{ channel: string }>()("fetch channel", (script) =>
31
+ script
32
+ .addPhase("Fetch")
33
+ .addStep({ name: "authenticate", handler: ({ input }) => ({ token: `t_${input.channel}` }) })
34
+ .addStep({ name: "pull orders", handler: ({ ctx }) => ({ orders: [{ id: `${ctx.token}_o1`, total: 10 }] }) })
35
+ .addPhase("Normalize")
36
+ .addStep({ name: "dedupe", handler: ({ ctx }) => ({ orderCount: ctx.orders.length }) }),
37
+ );
38
+ ```
39
+
40
+ ```ts
41
+ // somewhere-else.ts
42
+ import { Script } from "@michaelrwalker/stagehand";
43
+ import { fetchChannel } from "./routines/channel";
44
+
45
+ const report = await new Script<{ channel: string }>({ name: "report" })
46
+ .use(fetchChannel)
47
+ .addPhase("Report")
48
+ .addStep({ name: "aggregate", handler: ({ ctx }) => ({ total: ctx.orderCount }) })
49
+ .run({ channel: "amazon" });
50
+ ```
51
+
52
+ `routineFor<In, Ctx>()` takes the **minimum** the fragment needs: the input it reads (`In`) and the context it expects to already exist (`Ctx`). Both are checked at the `use()` call site — a host missing `channel` on its input, or missing an upstream key the routine needs in `ctx`, is a compile error there, not an `undefined` at run time.
53
+
54
+ ## Core Patterns
55
+
56
+ ### Mount the same routine twice for two tenants
57
+
58
+ ```ts
59
+ new Script<{ since: string; amazonKey: string; shopifyKey: string }>({ name: "all channels" })
60
+ .use(fetchChannel, {
61
+ as: "Amazon",
62
+ input: ({ input }) => ({ channel: "amazon" }),
63
+ })
64
+ .use(fetchChannel, {
65
+ as: "Shopify",
66
+ input: ({ input }) => ({ channel: "shopify" }),
67
+ });
68
+ ```
69
+
70
+ `as` prefixes every phase name the mount brings in (`"Amazon / Fetch"`, `"Amazon / Normalize"`), keeping them unique so a second mount of the same routine doesn't collide.
71
+
72
+ ### Mount a plain `Script`, still runnable on its own
73
+
74
+ ```ts
75
+ const pipeline = new Script<{ channel: string }>({ name: "pull" })
76
+ .addPhase("Fetch")
77
+ .addStep({ name: "pull", handler: ({ input }) => ({ rows: [input.channel] }) });
78
+
79
+ await pipeline.run({ channel: "etsy" }); // runs standalone today
80
+
81
+ const mounted = await new Script<{ channel: string }>({ name: "report" })
82
+ .use(pipeline) // mounts into something bigger tomorrow
83
+ .addPhase("Report")
84
+ .addStep({ name: "count", handler: ({ ctx }) => ({ n: ctx.rows.length }) })
85
+ .run({ channel: "etsy" });
86
+ ```
87
+
88
+ `use()` accepts anything built with `routineFor` or an ordinary `Script` — the same module works alone or spliced into a host.
89
+
90
+ ### Give a mount its own input, decoupled from the host's shape
91
+
92
+ ```ts
93
+ export const pullChannel = routineFor<{ channel: string; apiKey: string }>()("pull channel", (script) =>
94
+ script.addPhase("Fetch").addStep({
95
+ name: "pull",
96
+ handler: ({ input }) => ({ pulled: `${input.channel}:${input.apiKey}` }),
97
+ }),
98
+ );
99
+
100
+ const result = await new Script<{ region: string }>({ name: "t" })
101
+ .use(pullChannel, {
102
+ input: ({ input }) => ({ channel: input.region, apiKey: "k" }),
103
+ })
104
+ .run({ region: "eu" });
105
+ ```
106
+
107
+ `input` takes a fixed value or an `{ input, ctx } => SubIn` mapper, may be async, and is resolved once per mount at the moment the mount is reached — the host no longer has to match the routine's input shape, only produce it.
108
+
109
+ ## Common Mistakes
110
+
111
+ ### HIGH Chaining addStep() directly after use() without a new phase
112
+
113
+ Wrong:
114
+ ```ts
115
+ new Script({ name: "t" })
116
+ .use(pullChannel)
117
+ .addStep({ name: "sneaky", handler: () => ({}) });
118
+ // StepDefinitionError: Cannot add a step after use()
119
+ ```
120
+
121
+ Correct:
122
+ ```ts
123
+ new Script({ name: "t" })
124
+ .use(pullChannel)
125
+ .addPhase("Report")
126
+ .addStep({ name: "summarize", handler: ({ ctx }) => ({}) });
127
+ ```
128
+
129
+ A routine owns whole phases, so the last phase spliced in by `use()` belongs to the fragment — a bare `addStep` right after it throws `StepDefinitionError` at build time even though the fluent chain reads naturally.
130
+
131
+ Source: src/script.ts currentPhase() StepDefinitionError; docs/guides/reusable-scripts.md
132
+
133
+ ### MEDIUM Assuming a mounted routine's when() sees the host's own input
134
+
135
+ Wrong:
136
+ ```ts
137
+ // inside routines/channel.ts, built with routineFor<{ channel: string }>()
138
+ .addStep({ name: "gate", when: ({ input }) => input.hostOnlyFlag, handler: async () => {} })
139
+ // TypeScript already blocks referencing hostOnlyFlag if routineFor's In doesn't include it —
140
+ // but this is exactly the trap when In is loosely typed or the mapper over-forwards fields
141
+ ```
142
+
143
+ Correct:
144
+ ```ts
145
+ export const pullChannel = routineFor<{ channel: string; since: string }>()(
146
+ "pull channel",
147
+ (script) => script.addStep({
148
+ name: "gate",
149
+ when: ({ input }) => input.channel === "amazon",
150
+ handler: async () => {},
151
+ }),
152
+ );
153
+ ```
154
+
155
+ A mount's input is resolved once, when the mount is reached, from either a fixed value or an `{ input, ctx } => SubIn` mapper — every `when` inside that fragment (phase- or step-level) is evaluated against the mount's resolved input, never the host's own.
156
+
157
+ Source: docs/guides/reusable-scripts.md ("Giving a mount its own input"); src/script.ts use()/run() mount input resolution
158
+
159
+ ### HIGH Mounting the same routine twice without as
160
+
161
+ Wrong:
162
+ ```ts
163
+ new Script({ name: "t" })
164
+ .use(pullChannel)
165
+ .use(pullChannel); // DuplicateNameError: "Fetch" already defined
166
+ ```
167
+
168
+ Correct:
169
+ ```ts
170
+ new Script({ name: "t" })
171
+ .use(pullChannel, { as: "Amazon", input: ({ input }) => ({ ...input, channel: "amazon" }) })
172
+ .use(pullChannel, { as: "Shopify", input: ({ input }) => ({ ...input, channel: "shopify" }) });
173
+ ```
174
+
175
+ Phase names must stay unique across the whole script; mounting the same routine a second time without a distinguishing `as` prefix throws `DuplicateNameError` at build time.
176
+
177
+ Source: docs/guides/reusable-scripts.md; docs/reference/errors.md (DuplicateNameError)
178
+
179
+ ### MEDIUM Giving a routine its own ScriptOptions and expecting them to hold once mounted
180
+
181
+ Wrong:
182
+ ```ts
183
+ // routines/import.ts
184
+ const pipeline = new Script({ name: "import", rollback: "none" })
185
+ .addPhase("Fetch")
186
+ .addStep({ name: "pull", handler, rollback: undoPull });
187
+ // mounted into a host with rollback: "all" — the host's "all" wins; "pull"'s rollback CAN fire
188
+ ```
189
+
190
+ Correct:
191
+ ```ts
192
+ // a mountable fragment's own ScriptOptions are dead weight once mounted — fine to leave them off,
193
+ // and expect whichever script mounts it to own rollback/logPlacement/silent for the whole run
194
+ ```
195
+
196
+ A mounted `Script`'s own `ScriptOptions` are ignored in favor of the host's; only its phases and steps come across. This is by design, not a bug — a standalone `Script` is meant to be runnable on its own today and mountable into something bigger tomorrow, and the host has to govern the whole run.
197
+
198
+ Source: docs/guides/reusable-scripts.md ("A mounted Script's own ScriptOptions... are ignored in favor of the host's")
199
+
200
+ ### LOW Renaming a mount with as and forgetting to update cache calls
201
+
202
+ Wrong:
203
+ ```ts
204
+ .use(pullChannel, { as: "Amazon" })
205
+ // elsewhere: cache.clear("Fetch") — now a compile error, slot is "Amazon / Fetch"
206
+ ```
207
+
208
+ Correct:
209
+ ```ts
210
+ .use(pullChannel, { as: "Amazon" })
211
+ // elsewhere: cache.clear("Amazon / Fetch")
212
+ ```
213
+
214
+ `as` renames a mounted fragment's cache slots along with its phases, so every `context.cache` call written against the unprefixed name goes red at compile time and needs the `"As / PhaseName"` form instead.
215
+
216
+ Source: docs/guides/reusable-scripts.md ("Mounting twice"); docs/guides/caching.md ("Reaching the cache from a step")
217
+
218
+ ### MEDIUM Assuming a mount's input mapper re-evaluates per step
219
+
220
+ Wrong:
221
+ ```ts
222
+ .use(pullChannel, { input: ({ input }) => ({ apiKey: getFreshToken() }) })
223
+ // assumed: getFreshToken() called again for each step in the fragment — it is called exactly once
224
+ ```
225
+
226
+ Correct:
227
+ ```ts
228
+ // if freshness matters per step, resolve it inside the fragment's own step handlers
229
+ // instead of the mount mapper
230
+ ```
231
+
232
+ Input is resolved once per mount, at the moment the mount is reached in execution order, and every phase/step of that fragment — including `when` and `rollback` — sees that single resolved value, not a fresh evaluation each time.
233
+
234
+ Source: docs/guides/reusable-scripts.md ("resolved once per mount, at the moment the mount is reached")
235
+
236
+ ### HIGH Tension: Routine authoring instincts vs. host-controlled ScriptOptions
237
+
238
+ A routine is built as an ordinary `Script`, which makes it natural to set `rollback`/`logPlacement`/`silent` on it as if authoring a standalone safety policy — but every one of those is discarded the moment it is mounted. Agents optimizing for a reusable, "always non-destructive" fragment tend to bake `rollback: "none"` into the sub-script because they don't account for the host's `ScriptOptions` overriding it entirely; that requirement has to be documented for whoever mounts the routine instead.
239
+
240
+ See also: skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes
241
+ See also: skills/observability/log-placement-and-rendering/SKILL.md § Common Mistakes
242
+
243
+ See also: skills/building-scripts/structuring-phases-and-steps/SKILL.md — a routine owns whole phases; addStep directly after use() is refused, which only makes sense once phase ownership is understood.
@@ -0,0 +1,226 @@
1
+ ---
2
+ name: 'splitting-steps-across-files'
3
+ description: >
4
+ stepFor<In, Ctx>() binds the input and context a step declared outside any script expects, so the step can be exported from its own module and passed to addStep; WithStepFor<Step, Rest> computes a chain's Ctx from an earlier step's output instead of restating it by hand. Load this for "move a step into its own module," "share a step across two scripts," or "build a reusable steps file for a recurring workflow."
5
+ metadata:
6
+ type: 'core'
7
+ library: 'stagehand'
8
+ library_version: '0.5.3'
9
+ sources:
10
+ - 'miaklwalker/stagehand:docs/guides/splitting-steps.md'
11
+ - 'miaklwalker/stagehand:src/script.ts'
12
+ - 'miaklwalker/stagehand:test/with-step-for.test.ts'
13
+ ---
14
+
15
+ # Stagehand — Splitting Steps Across Files
16
+
17
+ A step defined inline with `addStep` infers its `In` and `Ctx` from the script it's attached to. A step declared in its own module has no script to infer from, so `stepFor<In, Ctx>()` supplies that binding explicitly while keeping full inference on the handler's return type.
18
+
19
+ ## Setup
20
+
21
+ ```ts
22
+ // steps/tenancy.ts
23
+ import { stepFor } from "@michaelrwalker/stagehand";
24
+
25
+ interface Input {
26
+ accountId: string;
27
+ }
28
+
29
+ export const loadAccount = stepFor<Input>()({
30
+ name: "load account",
31
+ handler: async ({ input }) => ({ account: await fetchAccount(input.accountId) }),
32
+ });
33
+
34
+ export const createTenant = stepFor<Input, { account: Account }>()({
35
+ name: "create tenant",
36
+ handler: async ({ ctx }) => ({ tenantId: await api.create(ctx.account) }),
37
+ rollbackKeys: ["tenantId"],
38
+ rollback: async ({ ctx }) => api.destroy(ctx.tenantId),
39
+ });
40
+ ```
41
+
42
+ ```ts
43
+ // scripts/signup.ts
44
+ import { Script } from "@michaelrwalker/stagehand";
45
+ import { loadAccount, createTenant } from "../steps/tenancy.js";
46
+
47
+ const signup = new Script<{ accountId: string }>({ name: "signup" })
48
+ .addPhase("Account")
49
+ .addStep(loadAccount)
50
+ .addPhase("Provision")
51
+ .addStep(createTenant);
52
+ ```
53
+
54
+ ## Core Patterns
55
+
56
+ ### Chaining `stepFor` steps with `WithStepFor` instead of restating `Ctx`
57
+
58
+ ```ts
59
+ import { stepFor, type WithStepFor } from "@michaelrwalker/stagehand";
60
+
61
+ interface Input {
62
+ channel: string;
63
+ }
64
+
65
+ export const logIntoDb = stepFor<Input>()({
66
+ name: "log into db",
67
+ handler: async () => ({ conn: await connect() }),
68
+ });
69
+
70
+ export const loadRules = stepFor<
71
+ Input,
72
+ WithStepFor<typeof logIntoDb, { conditions: Array<{ name: string }> }>
73
+ >()({
74
+ name: "load rules",
75
+ // ctx.conn comes from logIntoDb's output, ctx.conditions from Rest
76
+ handler: ({ ctx }) => ({ rules: ctx.conn.query(ctx.conditions) }),
77
+ });
78
+ ```
79
+
80
+ `WithStepFor<Step, Rest>` merges `Step`'s output over `Rest` (which defaults to `{}`), so the second step's `Ctx` doesn't have to be hand-typed against the first step's return shape.
81
+
82
+ ### Nesting `WithStepFor` for a chain three or more steps deep
83
+
84
+ ```ts
85
+ type Ctx = WithStepFor<typeof third, WithStepFor<typeof second, WithStepFor<typeof first, { seed: boolean }>>>;
86
+ ```
87
+
88
+ Read inside out: start with `{ seed: boolean }`, add what `first` returns, then `second`, then `third` — the same order the steps actually run in, and on a name collision the outermost (`third`) wins, matching how a later step's return shadows an earlier key at runtime.
89
+
90
+ ### A shared step library reused across two unrelated scripts
91
+
92
+ ```ts
93
+ // steps/crm.ts — written once, small and generic
94
+ import { stepFor } from "@michaelrwalker/stagehand";
95
+
96
+ export const fetchContacts = stepFor<{ emails: string[] }>()({
97
+ name: "fetch contacts",
98
+ handler: async ({ input }) => ({ contacts: await crm.fetch(input.emails) }),
99
+ });
100
+
101
+ export const dedupeContacts = stepFor<{ emails: string[] }, { contacts: Contact[] }>()({
102
+ name: "dedupe contacts",
103
+ handler: ({ ctx }) => ({ contacts: dedupe(ctx.contacts) }),
104
+ });
105
+ ```
106
+
107
+ ```ts
108
+ // scripts/sync-a.ts
109
+ import { Script } from "@michaelrwalker/stagehand";
110
+ import { fetchContacts, dedupeContacts } from "../steps/crm.js";
111
+
112
+ new Script<{ emails: string[] }>({ name: "sync-a" })
113
+ .addPhase("Contacts")
114
+ .addStep(fetchContacts)
115
+ .addStep(dedupeContacts);
116
+ ```
117
+
118
+ ```ts
119
+ // scripts/sync-b.ts — same steps, same import, no logic re-typed
120
+ import { Script } from "@michaelrwalker/stagehand";
121
+ import { fetchContacts, dedupeContacts } from "../steps/crm.js";
122
+
123
+ new Script<{ emails: string[]; region: string }>({ name: "sync-b" })
124
+ .addPhase("Contacts")
125
+ .addStep(fetchContacts)
126
+ .addStep(dedupeContacts)
127
+ .addPhase("Region")
128
+ .addStep({
129
+ name: "tag region",
130
+ handler: async ({ input, ctx }) => ({ tagged: ctx.contacts.map((c) => ({ ...c, region: input.region })) }),
131
+ });
132
+ ```
133
+
134
+ Each script mixes shared and inline steps freely; `addStep` checks each shared step's `Ctx` requirement against whatever the host script has produced so far, at the mount site.
135
+
136
+ ### Reusing a step with a rollback across two scripts
137
+
138
+ ```ts
139
+ // Script one: normal signup
140
+ new Script<{ accountId: string }>({ name: "signup" })
141
+ .addPhase("Account")
142
+ .addStep(loadAccount)
143
+ .addPhase("Provision")
144
+ .addStep(createTenant); // rollback compensates here if a later step fails
145
+
146
+ // Script two: written later, reusing the same step and its rollback
147
+ new Script<{ accountId: string; fromRegion: string }>({ name: "migrate" })
148
+ .addPhase("Prepare")
149
+ .addStep(loadAccount)
150
+ .addPhase("Provision")
151
+ .addStep(createTenant); // same import, same compensation logic
152
+ ```
153
+
154
+ `createTenant`'s rollback is identical wherever it's mounted because it's the exact same function reference each time.
155
+
156
+ ## Common Mistakes
157
+
158
+ ### HIGH Nesting WithStepFor in the reverse of execution order
159
+
160
+ Wrong:
161
+ ```ts
162
+ // "second" actually runs after "first" in the real script, but is nested as if it ran before it
163
+ type Ctx = WithStepFor<typeof first, WithStepFor<typeof second, { conditions: string[] }>>;
164
+ ```
165
+
166
+ Correct:
167
+ ```ts
168
+ // nest in the order the steps actually run — innermost first
169
+ type Ctx = WithStepFor<typeof second, WithStepFor<typeof first, { conditions: string[] }>>;
170
+ ```
171
+
172
+ On a name collision the outermost `WithStepFor` wins, matching how a later step's return value shadows an earlier key at runtime — but nothing checks that the nesting order matches the order the steps are actually added to a script, so a reversed nest silently produces a `Ctx` type that disagrees with what the script really does at runtime.
173
+
174
+ Source: docs/guides/splitting-steps.md ("nothing checks that the steps are added to a script in the order the nesting implies")
175
+
176
+ ### LOW Assuming a reused stepFor step gets independent state per script
177
+
178
+ Wrong:
179
+ ```ts
180
+ // steps/tenancy.ts
181
+ let callCount = 0;
182
+ export const createTenant = stepFor<Input, { account: Account }>()({
183
+ name: "create tenant",
184
+ handler: async () => { callCount++; return { tenantId: await api.create() }; },
185
+ });
186
+ // callCount keeps incrementing across every script that imports createTenant
187
+ ```
188
+
189
+ Correct:
190
+ ```ts
191
+ // keep per-run state inside ctx/output, not module-level closures, unless sharing is intended
192
+ export const createTenant = stepFor<Input, { account: Account }>()({
193
+ name: "create tenant",
194
+ handler: async ({ attempt }) => ({ tenantId: await api.create(), attempt }),
195
+ });
196
+ ```
197
+
198
+ A step imported into two scripts is the exact same function/object reference in both, so any module-scoped mutable state its handler closes over is shared across every script that mounts it, not reset per mount.
199
+
200
+ Source: docs/guides/splitting-steps.md ("Reusing one step in two scripts" — same function reference each time)
201
+
202
+ ### MEDIUM Writing a bespoke inline step instead of extracting a shared one
203
+
204
+ Wrong:
205
+ ```ts
206
+ // scripts/sync-a.ts
207
+ .addStep({ name: "fetch contacts", handler: async ({ input }) => ({ contacts: await crm.fetch(input.emails) }) })
208
+
209
+ // scripts/sync-b.ts — same logic, retyped, now two places to fix a bug
210
+ .addStep({ name: "fetch contacts", handler: async ({ input }) => ({ contacts: await crm.fetchContacts(input.emails) }) })
211
+ ```
212
+
213
+ Correct:
214
+ ```ts
215
+ // steps/crm.ts — written once, with a small, generic input
216
+ export const fetchContacts = stepFor<{ emails: string[] }>()({
217
+ name: "fetch contacts",
218
+ handler: async ({ input }) => ({ contacts: await crm.fetch(input.emails) }),
219
+ });
220
+ // any script that needs it:
221
+ .addStep(fetchContacts)
222
+ ```
223
+
224
+ For a workflow that recurs across scripts, a step written once with a small, generic input composes into every future script at zero type-safety cost (`addStep` still checks it against whatever mounts it), while a fresh inline closure per script forfeits that — the same logic, and any bug in it, has to be rewritten and re-verified in every script separately.
225
+
226
+ Source: maintainer interview ("keep a file of common steps, with simple inputs so you can easily compose scripts... this will save developers a ton of time and costs nothing as far as type safety is concerned")