@michaelrwalker/stagehand 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +8 -2
  2. package/dist/ui/ansi.d.ts +20 -0
  3. package/dist/ui/ansi.d.ts.map +1 -1
  4. package/dist/ui/ansi.js +22 -0
  5. package/dist/ui/ansi.js.map +1 -1
  6. package/dist/ui/frame.d.ts +22 -2
  7. package/dist/ui/frame.d.ts.map +1 -1
  8. package/dist/ui/frame.js +38 -15
  9. package/dist/ui/frame.js.map +1 -1
  10. package/dist/ui/renderer.d.ts +12 -1
  11. package/dist/ui/renderer.d.ts.map +1 -1
  12. package/dist/ui/renderer.js +44 -14
  13. package/dist/ui/renderer.js.map +1 -1
  14. package/package.json +10 -5
  15. package/skills/building-scripts/getting-started/SKILL.md +210 -0
  16. package/skills/building-scripts/structuring-phases-and-steps/SKILL.md +197 -0
  17. package/skills/building-scripts/typed-context-and-input/SKILL.md +188 -0
  18. package/skills/building-scripts/typed-context-and-input/references/context-type-utilities.md +121 -0
  19. package/skills/caching/caching-expensive-work/SKILL.md +285 -0
  20. package/skills/caching/caching-expensive-work/references/cache-api.md +278 -0
  21. package/skills/compensation-safety/adding-rollbacks/SKILL.md +400 -0
  22. package/skills/compensation-safety/cleaning-context/SKILL.md +165 -0
  23. package/skills/compensation-safety/conditional-steps/SKILL.md +199 -0
  24. package/skills/errors-results/handling-results-and-errors/SKILL.md +281 -0
  25. package/skills/observability/log-placement-and-rendering/SKILL.md +167 -0
  26. package/skills/observability/reporting-progress/SKILL.md +203 -0
  27. package/skills/reuse/reusable-routines-and-mounts/SKILL.md +243 -0
  28. package/skills/reuse/splitting-steps-across-files/SKILL.md +226 -0
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: 'getting-started'
3
+ description: >
4
+ Build a complete Stagehand script end to end — Script constructor, addPhase/addStep, a handler
5
+ that widens the typed context, a rollback, and reading the RunResult. Load this first for any
6
+ "write my first script" or "turn this async function chain into Stagehand" request.
7
+ metadata:
8
+ type: 'lifecycle'
9
+ library: 'stagehand'
10
+ library_version: '0.5.3'
11
+ sources:
12
+ - 'miaklwalker/stagehand:README.md'
13
+ - 'miaklwalker/stagehand:docs/quick-start.md'
14
+ - 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
15
+ - 'miaklwalker/stagehand:docs/guides/cleaning-context.md'
16
+ - 'miaklwalker/stagehand:src/script.ts'
17
+ - 'miaklwalker/stagehand:src/types.ts'
18
+ ---
19
+
20
+ # Stagehand — Getting Started
21
+
22
+ A script is a `Script<Input>` built by chaining `addPhase`/`addStep`. Each step's handler reads
23
+ `input` and the typed `ctx` accumulated so far, and whatever it returns is merged into `ctx` for
24
+ every later step. `run()` never throws by default — it resolves to a discriminated `RunResult`.
25
+
26
+ ## Setup
27
+
28
+ ```ts
29
+ import { Script } from "@michaelrwalker/stagehand";
30
+
31
+ interface Input {
32
+ service: string;
33
+ }
34
+
35
+ const deploy = new Script<Input>({ name: "deploy" })
36
+ .addPhase("Validation")
37
+ .addStep({
38
+ name: "resolve commit",
39
+ handler: async ({ input, status }) => {
40
+ status("querying git");
41
+ return { sha: await resolveSha(input.service) };
42
+ },
43
+ })
44
+ .addPhase("Release")
45
+ .addStep({
46
+ name: "upload artifact",
47
+ handler: async ({ ctx }) => {
48
+ // ctx.sha is typed here — produced by the previous step
49
+ return { uploadId: await upload(ctx.sha) };
50
+ },
51
+ rollbackKeys: ["sha"],
52
+ rollback: async ({ ctx, output }) => {
53
+ await cdn.delete(output.uploadId, ctx.sha);
54
+ },
55
+ });
56
+
57
+ const result = await deploy.run({ service: "api" });
58
+
59
+ if (result.ok) {
60
+ console.log(`released ${result.ctx.uploadId} from ${result.ctx.sha}`);
61
+ } else {
62
+ console.error(`failed at ${result.failedAt?.phase} › ${result.failedAt?.step}`);
63
+ console.error(result.error);
64
+ process.exitCode = 1;
65
+ }
66
+ ```
67
+
68
+ ## Core Patterns
69
+
70
+ ### Grouping steps into phases
71
+
72
+ ```ts
73
+ new Script<{ id: string }>({ name: "provision" })
74
+ .addPhase("Fetch")
75
+ .addStep({
76
+ name: "load user",
77
+ handler: async ({ input }) => ({ user: await db.user(input.id) }),
78
+ })
79
+ .addPhase("Provision")
80
+ .addStep({
81
+ name: "create tenant",
82
+ handler: async ({ ctx }) => ({ tenantId: await api.create(ctx.user.org) }),
83
+ });
84
+ ```
85
+
86
+ `addPhase` opens a new phase; every `addStep` that follows lands in it until the next `addPhase`.
87
+
88
+ ### Context accumulates purely from what a handler returns
89
+
90
+ ```ts
91
+ new Script<{ id: string }>()
92
+ .addStep({ handler: () => ({ token: "x" }) }) // Ctx = { token: string }
93
+ .addStep({ handler: ({ ctx }) => ({ n: ctx.token.length }) });
94
+ // Ctx = { token: string; n: number }
95
+ ```
96
+
97
+ No generic is written by hand — each handler's return value widens `Ctx` for every step after it,
98
+ and a handler that returns nothing leaves it unchanged.
99
+
100
+ ### Compensating a mutation with rollback and rollbackKeys
101
+
102
+ ```ts
103
+ new Script<{ accountId: string; amount: number }>({ name: "charge" })
104
+ .addStep({ name: "read input", handler: ({ input }) => ({ ...input }) })
105
+ .addStep({
106
+ name: "reserve funds",
107
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId, ctx.amount) }),
108
+ rollbackKeys: ["accountId", "reservationId"],
109
+ rollback: async ({ ctx }) => {
110
+ // ctx is { accountId: string; reservationId: string } — and nothing else
111
+ await release(ctx.accountId, ctx.reservationId);
112
+ },
113
+ })
114
+ .addStep({
115
+ name: "charge card",
116
+ handler: () => {
117
+ throw new Error("payment gateway timeout");
118
+ },
119
+ });
120
+ ```
121
+
122
+ Only steps that already succeeded get compensated, in reverse order, when a later step throws;
123
+ `rollbackKeys` narrows what the rollback's `ctx` contains to exactly those keys.
124
+
125
+ ### Naming a script with the bare-string shorthand
126
+
127
+ ```ts
128
+ const validate = new Script("validate") // shorthand for { name: "validate" }
129
+ .addStep({ name: "check input", handler: ({ input }) => ({ ok: true }) });
130
+ ```
131
+
132
+ Reach for the object form (`{ name, description, rollback, throwOnError, ... }`) once you need
133
+ anything beyond the name.
134
+
135
+ ## Common Mistakes
136
+
137
+ ### CRITICAL Wrapping run() in try/catch expecting it to throw
138
+
139
+ Wrong:
140
+ ```ts
141
+ try {
142
+ const result = await deploy.run(input);
143
+ console.log("deployed", result.ctx.releaseId);
144
+ } catch (e) {
145
+ console.error(e);
146
+ }
147
+ ```
148
+
149
+ Correct:
150
+ ```ts
151
+ const result = await deploy.run(input);
152
+ if (!result.ok) {
153
+ console.error(result.error);
154
+ process.exitCode = 1;
155
+ return;
156
+ }
157
+ console.log("deployed", result.ctx.releaseId);
158
+ ```
159
+
160
+ `run()` resolves to `{ ok: false, ... }` by default; nothing throws unless `throwOnError` is set, so the try/catch never fires and a failed run is silently treated as if it succeeded.
161
+
162
+ Source: docs/guides/script-options-and-result.md; src/script.ts run()
163
+
164
+ ### MEDIUM Mutating ctx directly instead of returning new keys
165
+
166
+ Wrong:
167
+ ```ts
168
+ handler: ({ ctx }) => {
169
+ ctx.total = ctx.total + 1; // "works" locally, breaks caching/rollback
170
+ };
171
+ ```
172
+
173
+ Correct:
174
+ ```ts
175
+ handler: ({ ctx }) => ({ total: ctx.total + 1 });
176
+ ```
177
+
178
+ `ctx` is the same live object every handler shares, 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 never gets cached and is invisible to any rollback that asks for it.
179
+
180
+ Source: src/script.ts (const ctx = {} as Ctx & Record<string, unknown>; merge() only applied to handler output); docs/guides/cleaning-context.md
181
+
182
+ ### HIGH Returning a bare value from a handler instead of an object
183
+
184
+ Wrong:
185
+ ```ts
186
+ handler: () => {
187
+ const value = "me";
188
+ return value; // compile error — Out must be object | void
189
+ };
190
+ ```
191
+
192
+ Correct:
193
+ ```ts
194
+ handler: () => {
195
+ const value = "me";
196
+ return { name: value };
197
+ };
198
+ ```
199
+
200
+ A handler must resolve to `object | void` because whatever it returns is merged into `ctx` by key; TypeScript rejects a bare return at compile time (`Out extends object | void`), but reaching for `return value` out of habit still costs an extra iteration.
201
+
202
+ Source: src/types.ts StepDef.handler / src/script.ts addStep<Out extends object | void>(...)
203
+
204
+ ### HIGH Tension: Simple first draft vs. rollbackKeys reservation permanence
205
+
206
+ The simplest script has no `rollbackKeys` and no `clean` conflicts, but adding a rollback later to harden a working script permanently reserves whatever keys it names via `rollbackKeys`. Agents optimizing for this skill's goal tend to bolt a rollback onto a working script without revisiting existing `clean` calls elsewhere because they don't account for cleaning-context's key-reservation rule, which then throws `StepDefinitionError` at build time and reads as an unrelated regression.
207
+
208
+ See also: skills/compensation-safety/adding-rollbacks/SKILL.md, skills/compensation-safety/cleaning-context/SKILL.md § Common Mistakes
209
+
210
+ See also: skills/errors-results/handling-results-and-errors/SKILL.md — the very first script already needs correct RunResult handling; run() resolving instead of throwing is the single most consequential thing to get right early.
@@ -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)