@michaelrwalker/stagehand 0.5.3 → 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 +12 -2
  3. package/dist/ui/ansi.d.ts.map +1 -1
  4. package/dist/ui/ansi.js +14 -2
  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 +6 -4
  11. package/dist/ui/renderer.d.ts.map +1 -1
  12. package/dist/ui/renderer.js +17 -9
  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,199 @@
1
+ ---
2
+ name: 'conditional-steps'
3
+ description: >
4
+ Covers `when` on `addStep` and `addPhase` for skipping work, the `"skipped"` StepStatus,
5
+ and how a skip interacts with `clean`, `rollback`, and a mounted routine's own resolved
6
+ input. Load this for "run this step only in production," "skip a whole phase based on
7
+ input," or when a rollback isn't firing and the step turns out to have been skipped.
8
+ metadata:
9
+ type: 'core'
10
+ library: 'stagehand'
11
+ library_version: '0.5.3'
12
+ sources:
13
+ - 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
14
+ - 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
15
+ - 'miaklwalker/stagehand:src/script.ts'
16
+ ---
17
+
18
+ # Stagehand — Conditional Steps and Phases
19
+
20
+ `when` on `addStep` or `addPhase` is a function of `{ input, ctx }` (may be async) that gates whether the step, or every step in the phase, runs at all. A falsy result marks the step `"skipped"` — it never runs its handler and never runs its `rollback`.
21
+
22
+ ## Setup
23
+
24
+ ```ts
25
+ import { Script } from "@michaelrwalker/stagehand";
26
+
27
+ interface Input {
28
+ environment: "development" | "production";
29
+ }
30
+
31
+ const script = new Script<Input>({ name: "deploy" })
32
+ .addPhase("Release")
33
+ .addStep({
34
+ name: "notify slack",
35
+ when: ({ input }) => input.environment === "production",
36
+ handler: async () => {
37
+ // only runs when input.environment === "production"
38
+ },
39
+ });
40
+
41
+ await script.run({ environment: "production" });
42
+ ```
43
+
44
+ ## Core Patterns
45
+
46
+ ### Gate a single step on input or ctx
47
+
48
+ ```ts
49
+ new Script<{ environment: "development" | "production" }>({ name: "deploy" })
50
+ .addPhase("Release")
51
+ .addStep({ name: "resolve commit", handler: async () => ({ sha: "abc123" }) })
52
+ .addStep({
53
+ name: "notify slack",
54
+ when: ({ input, ctx }) => input.environment === "production" && ctx.sha.length > 0,
55
+ handler: async () => {
56
+ /* notify */
57
+ },
58
+ });
59
+ ```
60
+
61
+ `when` sees the same `{ input, ctx }` shape a handler does, so it can read anything an earlier step already put in context.
62
+
63
+ ### Gate an entire phase
64
+
65
+ ```ts
66
+ new Script<{ environment: "development" | "production" }>({ name: "deploy" })
67
+ .addPhase("Validation")
68
+ .addStep({ name: "check permissions", handler: async () => ({}) })
69
+ .addPhase("Release", {
70
+ when: ({ input }) => input.environment === "production",
71
+ })
72
+ .addStep({ name: "notify slack", handler: async () => {} })
73
+ .addStep({ name: "publish", handler: async () => {} });
74
+ ```
75
+
76
+ A falsy phase-level `when` marks every step in `"Release"` `"skipped"` in one shot, before any of those steps' own `when` (if they had one) is ever evaluated.
77
+
78
+ ### Async `when`
79
+
80
+ ```ts
81
+ .addStep({
82
+ name: "run migration",
83
+ when: async ({ ctx }) => await isMigrationPending(ctx.dbUrl),
84
+ handler: async () => runMigration(),
85
+ })
86
+ ```
87
+
88
+ `when` may return a promise; the script awaits it before deciding whether to run or skip the step (or, at phase level, the whole phase).
89
+
90
+ ## Common Mistakes
91
+
92
+ ### MEDIUM Skipped step's `clean` still runs
93
+
94
+ Wrong:
95
+ ```ts
96
+ // developer expects `secret` to survive because "optional" never ran
97
+ new Script<{ secret: string }>({ name: "t" })
98
+ .addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
99
+ .addStep({ name: "optional", when: () => false, handler: () => ({}), clean: ["secret"] })
100
+ .addStep({ name: "later", handler: ({ ctx }) => {
101
+ ctx.secret; // compile error — gone
102
+ return {};
103
+ } });
104
+ ```
105
+
106
+ Correct:
107
+ ```ts
108
+ // if "secret" should only be dropped when "optional" actually ran, clean elsewhere
109
+ new Script<{ secret: string }>({ name: "t" })
110
+ .addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
111
+ .addStep({ name: "optional", when: () => false, handler: () => ({}) })
112
+ .addStep({ name: "use it then drop it", handler: ({ ctx }) => ({}), clean: ["secret"] });
113
+ ```
114
+
115
+ `clean` is part of a step's declared shape, not of the work it performs, so `drop(ctx, item.def.clean)` runs in the skip branch exactly as it does after a normal run — runtime and types never disagree about what a skipped step removed.
116
+
117
+ Source: docs/guides/cleaning-context.md; src/script.ts phaseLoop skip branch (`drop(ctx, item.def.clean)`)
118
+
119
+ ### LOW Expecting a skipped step's rollback to fire
120
+
121
+ Wrong:
122
+ ```ts
123
+ .addStep({
124
+ name: "notify slack",
125
+ when: ({ input }) => input.environment === "production",
126
+ handler: async () => notify(),
127
+ rollback: async () => undoNotify(), // assumed to fire if a later step fails and this was "run"
128
+ })
129
+ ```
130
+
131
+ Correct:
132
+ ```ts
133
+ // rely on `when` purely as a gate; don't design a rollback around a skip firing it
134
+ .addStep({
135
+ name: "notify slack",
136
+ when: ({ input }) => input.environment === "production",
137
+ handler: async () => notify(),
138
+ rollback: async () => undoNotify(), // correct: only fires if this step actually ran and succeeded
139
+ })
140
+ ```
141
+
142
+ A step marked `"skipped"` never enters the `completed` list the unwind walks, so its `rollback` never runs — there is nothing to compensate for.
143
+
144
+ Source: docs/guides/phases-and-steps.md
145
+
146
+ ### HIGH Step-level `when` can't re-enable a step inside a skipped phase
147
+
148
+ Wrong:
149
+ ```ts
150
+ new Script<{ environment: string }>({ name: "t" })
151
+ .addPhase("Release", { when: ({ input }) => input.environment === "production" })
152
+ .addStep({ name: "always run this", when: () => true, handler: async () => ({}) });
153
+ // still skipped — the phase gate short-circuits before step.when is ever checked
154
+ ```
155
+
156
+ Correct:
157
+ ```ts
158
+ // move a step that must always run outside the conditional phase
159
+ new Script<{ environment: string }>({ name: "t" })
160
+ .addPhase("Always")
161
+ .addStep({ name: "always run this", handler: async () => ({}) })
162
+ .addPhase("Release", { when: ({ input }) => input.environment === "production" })
163
+ .addStep({ name: "notify slack", handler: async () => ({}) });
164
+ ```
165
+
166
+ Phase-level `when` is evaluated once per phase; when it resolves falsy, every step in that phase is marked skipped in a loop that never touches `item.def.when` at all.
167
+
168
+ Source: src/script.ts run() phaseLoop (`phase.options.when` checked before any `step.when`)
169
+
170
+ ### MEDIUM `when` inside a mounted routine sees the mount's input, not the host's
171
+
172
+ Wrong:
173
+ ```ts
174
+ // inside routines/channel.ts, built with routineFor<{ channel: string }>()
175
+ .addStep({ name: "gate", when: ({ input }) => input.hostOnlyFlag, handler: async () => ({}) })
176
+ // TypeScript already prevents referencing hostOnlyFlag if routineFor's In doesn't include it —
177
+ // but this is exactly the trap when In is loosely typed or the mapper over-forwards fields
178
+ ```
179
+
180
+ Correct:
181
+ ```ts
182
+ import { routineFor } from "@michaelrwalker/stagehand";
183
+
184
+ export const pullChannel = routineFor<{ channel: string; since: string }>()(
185
+ "pull channel",
186
+ (script) =>
187
+ script.addStep({
188
+ name: "gate",
189
+ when: ({ input }) => input.channel === "amazon",
190
+ handler: async () => ({}),
191
+ }),
192
+ );
193
+ ```
194
+
195
+ A mount's input is resolved once, from a fixed value or an `{ input, ctx } => SubIn` mapper passed to `use()`, and every `when` inside that fragment — phase- or step-level — is evaluated against that resolved input, never the host script's own `input`.
196
+
197
+ Source: docs/guides/reusable-scripts.md ("Giving a mount its own input"); src/script.ts `use()`/`run()` mount input resolution
198
+
199
+ See also: skills/reuse/reusable-routines-and-mounts/SKILL.md — `when` inside a mounted fragment evaluates against the mount's resolved input, not the host's — easy to miss without reading both.
@@ -0,0 +1,281 @@
1
+ ---
2
+ name: 'handling-results-and-errors'
3
+ description: >
4
+ Covers the RunResult discriminated union returned by run(), every exported error class
5
+ (StepFailedError, StepTimeoutError, ScriptAbortedError, RollbackFailedError, CacheShapeError,
6
+ SchemaValidationError), the isAbort(error) helper, and the throwOnError script option. Load this
7
+ for "branch correctly on a failed run," "tell a Ctrl-C cancellation apart from a real failure,"
8
+ or "get the phase/step name a failure happened at."
9
+ metadata:
10
+ type: 'core'
11
+ library: 'stagehand'
12
+ library_version: '0.5.3'
13
+ sources:
14
+ - 'miaklwalker/stagehand:docs/guides/rollbacks.md'
15
+ - 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
16
+ - 'miaklwalker/stagehand:docs/reference/errors.md'
17
+ - 'miaklwalker/stagehand:src/errors.ts'
18
+ - 'miaklwalker/stagehand:src/script.ts'
19
+ ---
20
+
21
+ # Stagehand — Handling Results and Errors
22
+
23
+ `run()` never throws for an ordinary step failure — it resolves to a discriminated union on `ok`. A handful of exported error classes exist for callers building their own reporting, but most of them never propagate through `run()` itself; knowing which ones do (and which don't) is the whole game.
24
+
25
+ ## Setup
26
+
27
+ ```ts
28
+ import { Script, isAbort } from "@michaelrwalker/stagehand";
29
+
30
+ const deploy = new Script<{ env: string }>({ name: "deploy" })
31
+ .addStep({
32
+ name: "build",
33
+ handler: async ({ ctx }) => ({ releaseId: "r-123" }),
34
+ })
35
+ .addStep({
36
+ name: "publish",
37
+ handler: async ({ ctx }) => {
38
+ if (ctx.releaseId === undefined) throw new Error("no release to publish");
39
+ return { published: true };
40
+ },
41
+ });
42
+
43
+ const result = await deploy.run({ env: "production" });
44
+
45
+ if (result.ok) {
46
+ console.log("deployed", result.ctx.releaseId);
47
+ } else if (isAbort(result.error) && result.status === "aborted") {
48
+ console.log("cancelled");
49
+ } else {
50
+ console.error(`failed at ${result.failedAt?.phase} › ${result.failedAt?.step}`, result.error);
51
+ process.exitCode = 1;
52
+ }
53
+ ```
54
+
55
+ ## Core Patterns
56
+
57
+ ### Branch on the full RunResult shape
58
+
59
+ ```ts
60
+ const result = await deploy.run(input);
61
+
62
+ if (result.ok) {
63
+ // result.status === "success"; result.ctx is fully typed
64
+ result.ctx.releaseId;
65
+ } else {
66
+ // result.status is "failed" | "aborted"
67
+ console.error(`failed at ${result.failedAt?.phase} › ${result.failedAt?.step}`);
68
+ console.error(result.error); // the original thrown value, unwrapped
69
+ console.error(result.ctx); // Partial<Ctx> — whatever was produced before failure
70
+ for (const r of result.rollbacks) {
71
+ console.error(`rollback for "${r.step}": ${r.ok ? "ok" : "failed"}`);
72
+ }
73
+ }
74
+ ```
75
+
76
+ `result.ctx` on failure is not reverted by compensation — a rollback undoes external side effects, it does not erase what was already recorded in `ctx`.
77
+
78
+ ### Tell cancellation apart from an ordinary failure
79
+
80
+ ```ts
81
+ const result = await deploy.run(input, { signal: controller.signal });
82
+
83
+ if (!result.ok) {
84
+ if (isAbort(result.error) && result.status === "aborted") {
85
+ console.log("canceled from outside (Ctrl-C or an external signal)");
86
+ } else {
87
+ console.error("handler failure:", result.error);
88
+ }
89
+ }
90
+ ```
91
+
92
+ `isAbort(error)` returns `true` for a `ScriptAbortedError` or any `Error` named `"AbortError"` — checking `result.status === "aborted"` alongside it confirms the abort was the script's own, not an unrelated `AbortError` a handler happened to throw.
93
+
94
+ ### Use `throwOnError` when the script is one stage of a larger flow
95
+
96
+ ```ts
97
+ import { SchemaValidationError } from "@michaelrwalker/stagehand";
98
+
99
+ const stage = new Script<{ env: string }>({ name: "stage", throwOnError: true })
100
+ .addStep({ name: "one", handler: () => ({ done: true }) });
101
+
102
+ try {
103
+ const result = await stage.run({ env: "production" });
104
+ console.log(result.ctx.done); // reached only on success — a failure rejects instead
105
+ } catch (error) {
106
+ if (error instanceof SchemaValidationError) console.error(error.issues);
107
+ else throw error; // the original error a step threw, or a ScriptAbortedError
108
+ }
109
+ ```
110
+
111
+ Compensation still runs to completion before the rejection; `throwOnError` only changes how the outcome is reported.
112
+
113
+ ## Common Mistakes
114
+
115
+ ### CRITICAL wrapping run() in try/catch expecting it to throw
116
+
117
+ Wrong:
118
+ ```ts
119
+ try {
120
+ const result = await deploy.run(input);
121
+ console.log("deployed", result.ctx.releaseId);
122
+ } catch (e) {
123
+ console.error(e);
124
+ }
125
+ ```
126
+
127
+ Correct:
128
+ ```ts
129
+ const result = await deploy.run(input);
130
+ if (!result.ok) {
131
+ console.error(result.error);
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+ console.log("deployed", result.ctx.releaseId);
136
+ ```
137
+
138
+ `run()` resolves to `{ ok: false, ... }` by default; nothing throws unless `throwOnError` is set, so the `catch` block never fires and a failed run is silently treated as if nothing happened.
139
+
140
+ Source: docs/guides/script-options-and-result.md; src/script.ts `run()`
141
+
142
+ ### HIGH assuming a bad `defineInput` schema shows up as `{ ok: false }`
143
+
144
+ Wrong:
145
+ ```ts
146
+ const result = await deploy.run(rawInput);
147
+ if (!result.ok) { /* never reached on bad input — it throws instead */ }
148
+ ```
149
+
150
+ Correct:
151
+ ```ts
152
+ try {
153
+ const result = await deploy.run(rawInput);
154
+ } catch (error) {
155
+ if (error instanceof SchemaValidationError) console.error(error.issues);
156
+ else throw error;
157
+ }
158
+ ```
159
+
160
+ `SchemaValidationError` is always thrown directly by `run()`, before any phase executes, and is unaffected by `throwOnError` — there is no partial result to produce since nothing ran yet.
161
+
162
+ Source: docs/guides/typed-context.md; docs/reference/errors.md
163
+
164
+ ### MEDIUM assuming a failing rollback replaces `result.error`
165
+
166
+ Wrong:
167
+ ```ts
168
+ const result = await script.run(input);
169
+ if (!result.ok) console.error(result.error); // assumed to be the rollback's error if one failed
170
+ ```
171
+
172
+ Correct:
173
+ ```ts
174
+ const result = await script.run(input);
175
+ if (!result.ok) {
176
+ console.error("run failed:", result.error);
177
+ for (const r of result.rollbacks) if (!r.ok) console.error("rollback failed:", r.step, r.error);
178
+ }
179
+ ```
180
+
181
+ A failing rollback is recorded as `{ ok: false, error }` inside `result.rollbacks`; `result.error` always stays the original failure that triggered the unwind, and the remaining rollbacks still run.
182
+
183
+ Source: docs/guides/rollbacks.md; docs/reference/errors.md (`RollbackFailedError`)
184
+
185
+ ### MEDIUM trusting `AbortError`'s name to mean the script itself was cancelled
186
+
187
+ Wrong (inside a handler, defeats retry):
188
+ ```ts
189
+ handler: async ({ signal }) => {
190
+ const localController = new AbortController();
191
+ setTimeout(() => localController.abort(), 200); // unrelated to `signal`
192
+ return fetch(url, { signal: localController.signal }); // throws AbortError → retry silently skipped
193
+ },
194
+ retry: { attempts: 3 },
195
+ ```
196
+
197
+ ```ts
198
+ handler: async ({ signal }) => fetch(url, { signal }), // forward the step's own signal
199
+ retry: { attempts: 3, retryIf: (error) => !isAbort(error) },
200
+ ```
201
+
202
+ Wrong (after `run()`, misreads an unrelated AbortError as user cancellation):
203
+ ```ts
204
+ if (!result.ok && isAbort(result.error)) {
205
+ console.log("user pressed Ctrl-C"); // could actually be an unrelated internal AbortError
206
+ }
207
+ ```
208
+
209
+ Correct:
210
+ ```ts
211
+ if (!result.ok && isAbort(result.error) && result.status === "aborted") {
212
+ console.log("cancelled");
213
+ }
214
+ ```
215
+
216
+ `isAbort()` returns `true` for a `ScriptAbortedError` *or* any `Error` whose `name` is exactly `"AbortError"` — not just ones tied to the script's own signal. Inside `executeStep`'s retry loop, that check runs before the retry-budget check, so a handler's own unrelated `AbortController` (e.g. a hand-rolled per-call timeout) silently skips `retry`/`retryIf` entirely; after `run()` returns, the same permissiveness means `isAbort(result.error)` alone doesn't prove the script itself was aborted — pair it with `result.status === "aborted"`.
217
+
218
+ Source: src/errors.ts `isAbort()`; src/script.ts `executeStep()` (isAbort check precedes the retry-budget check); docs/guides/rollbacks.md ("Signals and cancellation")
219
+
220
+ ### HIGH checking `result.error instanceof StepFailedError`
221
+
222
+ Wrong:
223
+ ```ts
224
+ if (!result.ok && result.error instanceof StepFailedError) {
225
+ console.error(result.error.step, result.error.attempts); // never true — error is the raw throw
226
+ }
227
+ ```
228
+
229
+ Correct:
230
+ ```ts
231
+ if (!result.ok) {
232
+ console.error(result.failedAt?.phase, result.failedAt?.step, result.error);
233
+ }
234
+ ```
235
+
236
+ `result.error` on a failed run is the original value the handler threw, not automatically wrapped in `StepFailedError` — that class exists for callers building their own reporting elsewhere; the phase/step is available on `result.failedAt` instead.
237
+
238
+ Source: docs/reference/errors.md ("result.error on a failed run is the original thrown value, not automatically wrapped in this type")
239
+
240
+ ### MEDIUM assuming `throwOnError` skips compensation and fails fast
241
+
242
+ Wrong:
243
+ ```ts
244
+ // assumed: throwOnError means "don't bother rolling back, just blow up immediately"
245
+ new Script({ name: "t", throwOnError: true })
246
+ ```
247
+
248
+ Correct:
249
+ ```ts
250
+ // rollbacks still run to completion; throwOnError only affects how the caller is told about the outcome
251
+ new Script({ name: "t", throwOnError: true }) // reach for this when the script is one stage inside a larger error-handling flow
252
+ ```
253
+
254
+ `throwOnError` only changes how the outcome is reported — a failing run rejects with the original error instead of resolving `{ ok: false }`, but compensation still runs first, in full, before the rejection.
255
+
256
+ Source: docs/guides/script-options-and-result.md ("Compensation still runs before the rejection")
257
+
258
+ ### MEDIUM catching `RollbackFailedError` from `run()` or its result
259
+
260
+ Wrong:
261
+ ```ts
262
+ try {
263
+ await script.run(input);
264
+ } catch (e) {
265
+ if (e instanceof RollbackFailedError) { /* never matches */ }
266
+ }
267
+ ```
268
+
269
+ Correct:
270
+ ```ts
271
+ const result = await script.run(input);
272
+ if (!result.ok) {
273
+ const failedRollbacks = result.rollbacks.filter((r) => !r.ok);
274
+ }
275
+ ```
276
+
277
+ `RollbackFailedError` is exported for a caller's own reporting, but the library itself never throws it or puts it in `RunResult` — a failing rollback is instead recorded as `{ ok: false, error }` inside `result.rollbacks`.
278
+
279
+ Source: docs/reference/errors.md ("a failing rollback does not propagate as this error type through RunResult")
280
+
281
+ See also: skills/compensation-safety/adding-rollbacks/SKILL.md — isAbort and the retry loop's abort check directly govern whether — and how fast — a rollback's unwind gets triggered.
@@ -0,0 +1,167 @@
1
+ ---
2
+ name: 'log-placement-and-rendering'
3
+ description: >
4
+ logPlacement modes (scrollback/step/bottom) and how the live TTY frame differs from the plain/CI renderer, including which behaviors exist only in one of the two. Load this for "nest a rollback's log under the step it's undoing," "force plain output for CI," or "match my own console output to the script's styling."
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:docs/guides/terminal-rendering.md'
12
+ - 'miaklwalker/stagehand:test/log-placement.test.ts'
13
+ - 'miaklwalker/stagehand:test/note.test.ts'
14
+ ---
15
+
16
+ # Stagehand — Log Placement and Terminal Rendering
17
+
18
+ `log`/`info`/`warn`/`error`/`success` all write a line; `logPlacement` (a `ScriptOptions` field) controls where that line lands in the live TTY frame. Which renderer is even active — live, in-place frame vs. plain sequential output — is chosen automatically per run, and only the live one honors `logPlacement` at all.
19
+
20
+ ## Setup
21
+
22
+ ```ts
23
+ import { Script } from "@michaelrwalker/stagehand";
24
+
25
+ const script = new Script({ name: "checkout", logPlacement: "step" }).addStep({
26
+ name: "reserve inventory",
27
+ handler: async ({ log }) => {
28
+ log("checked warehouse A");
29
+ return { reservationId: "res_1" };
30
+ },
31
+ });
32
+
33
+ await script.run();
34
+ ```
35
+
36
+ ## Core Patterns
37
+
38
+ ### Nest a rollback's log under the step it's undoing
39
+
40
+ ```ts
41
+ import { Script } from "@michaelrwalker/stagehand";
42
+
43
+ await new Script({ name: "checkout", logPlacement: "step" })
44
+ .addStep({
45
+ name: "reserve inventory",
46
+ handler: () => ({ reservationId: "res_1" }),
47
+ rollback: ({ log }) => {
48
+ log("released reservation res_1");
49
+ },
50
+ })
51
+ .addStep({
52
+ name: "boom",
53
+ handler: () => {
54
+ throw new Error("boom");
55
+ },
56
+ })
57
+ .run();
58
+ ```
59
+
60
+ With `logPlacement: "step"`, a rollback's `log()` calls render nested under the step being undone instead of scrolling past at the top of the frame, keeping compensation output next to the failure that caused it.
61
+
62
+ ### Force plain, deterministic output for CI
63
+
64
+ ```ts
65
+ import { Script } from "@michaelrwalker/stagehand";
66
+
67
+ new Script({ name: "deploy", plain: process.env.CI === "true" });
68
+ ```
69
+
70
+ `plain: true` forces the sequential, non-repainting renderer even on a real TTY; `plain: false` forces the live one even when piped. Leaving `plain` unset (the default) auto-detects: live only when `process.stdout.isTTY` is true and color is enabled (color is off under `NO_COLOR` or `TERM=dumb`, on under `FORCE_COLOR`, otherwise on exactly when stdout is a TTY).
71
+
72
+ ### Roll a live tail of recent lines instead of nesting per step
73
+
74
+ ```ts
75
+ import { Script } from "@michaelrwalker/stagehand";
76
+
77
+ new Script({ name: "sync", logPlacement: "bottom" }).addStep({
78
+ name: "sync records",
79
+ handler: async ({ log }) => {
80
+ log("synced batch 1");
81
+ log("synced batch 2");
82
+ return { batches: 2 };
83
+ },
84
+ });
85
+ ```
86
+
87
+ `"bottom"` renders a single rolling tail of the most recent lines below the whole phase tree (not per step), which is cheaper to scan when many steps are logging concurrently. Like `"step"`, it keeps only the most recent handful of entries.
88
+
89
+ ### Match custom console output to the script's own styling
90
+
91
+ ```ts
92
+ import { formatDuration, isColorEnabled, palette, setColorEnabled, symbols } from "@michaelrwalker/stagehand";
93
+
94
+ console.log(`${symbols.success} deploy finished in ${formatDuration(4084)}`);
95
+ console.log(palette.warning("cache stale, rebuilding"));
96
+
97
+ setColorEnabled(false); // override auto-detection directly, rather than via NO_COLOR/FORCE_COLOR
98
+ isColorEnabled(); // false
99
+ ```
100
+
101
+ `symbols` degrades automatically on terminals without solid Unicode support (`✔` becomes `√`, and so on) and `palette` exposes the same 256-color stylers (`accent`, `success`, `warning`, `error`, `info`, `muted`, `faint`, `bold`, `dim`) the built-in renderer uses.
102
+
103
+ ## Common Mistakes
104
+
105
+ ### MEDIUM Treating step/bottom placement as a complete run record
106
+
107
+ Wrong:
108
+ ```ts
109
+ new Script({ name: "audit", logPlacement: "bottom" });
110
+ // assumed every log() call is retrievable after the run for compliance review
111
+ ```
112
+
113
+ Correct:
114
+ ```ts
115
+ new Script({ name: "audit", logPlacement: "scrollback" });
116
+ ```
117
+
118
+ Both `"step"` and `"bottom"` keep only the most recent handful of lines per step or per run — only `"scrollback"` (the default) is guaranteed to retain every line, in order, for the whole run.
119
+
120
+ Source: docs/guides/handler-surface.md; docs/guides/terminal-rendering.md
121
+
122
+ ### LOW Assuming logPlacement behaves the same in CI as it did locally
123
+
124
+ Wrong:
125
+ ```ts
126
+ // developer tunes `logPlacement: "step"` by eye in a local terminal,
127
+ // then is confused that CI logs show every line in plain top-level order instead
128
+ ```
129
+
130
+ Correct:
131
+ ```ts
132
+ // treat logPlacement as a live-TTY-only concern; CI output is always flat and complete
133
+ ```
134
+
135
+ Only the live renderer honors `logPlacement` — the plain/CI renderer (chosen automatically off a TTY, under `NO_COLOR`, or `TERM=dumb`) is already sequential and prints every line immediately in order regardless of the setting.
136
+
137
+ Source: docs/guides/terminal-rendering.md; docs/guides/handler-surface.md
138
+
139
+ ### LOW Hardcoding Stagehand's glyphs into a script's own console output
140
+
141
+ Wrong:
142
+ ```ts
143
+ console.log(`✔ done`); // renders as mojibake on the same terminals Stagehand degrades for
144
+ ```
145
+
146
+ Correct:
147
+ ```ts
148
+ import { symbols, palette } from "@michaelrwalker/stagehand";
149
+
150
+ console.log(`${symbols.success} done`);
151
+ ```
152
+
153
+ Stagehand degrades its own symbols automatically on terminals without solid Unicode support, but that degradation is internal to the renderer and does not apply to anything a script prints itself unless it goes through the exported `symbols`/`palette` helpers.
154
+
155
+ Source: docs/guides/terminal-rendering.md
156
+
157
+ ### HIGH Tension: Rich live observability vs. complete log record
158
+
159
+ `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 tend to pick a locally-nice-looking placement like `"step"` as a script-wide default because they don't account for the need — covered by progress reporting — for a guaranteed-complete CI/audit trail from the same run.
160
+
161
+ See also: skills/observability/reporting-progress/SKILL.md § Common Mistakes
162
+
163
+ ### HIGH Tension: Routine authoring instincts vs. host-controlled ScriptOptions
164
+
165
+ 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 since only the host's own `ScriptOptions` ever apply. Agents optimizing for this skill's goal tend to bake `logPlacement` choices into a reusable sub-script and assume they travel with it because they don't account for mounting's constraint that a routine's own `ScriptOptions` are overridden entirely by the host script.
166
+
167
+ See also: skills/reuse/reusable-routines-and-mounts/SKILL.md § Common Mistakes, skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes