@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,400 @@
1
+ ---
2
+ name: 'adding-rollbacks'
3
+ description: >
4
+ Covers writing a step's `rollback`, declaring `rollbackKeys` (what it narrows the rollback's `ctx`
5
+ to and permanently reserves from `clean`), `ScriptOptions.rollback` scope (`"all"` | `"phase"` |
6
+ `"none"`), the rollback context object (`input`/`ctx`/`output`/`error`/`signal`/`phase`/`step`),
7
+ `RunResult.rollbacks`, and cancellation via `handleSignals` / `AbortSignal` / `isAbort`. Load this
8
+ for "add a compensation to a mutation step," "why can't my rollback see this key," "scope how far
9
+ a failure unwinds," or "cancel a running script from outside."
10
+ metadata:
11
+ type: 'core'
12
+ library: 'stagehand'
13
+ library_version: '0.5.3'
14
+ sources:
15
+ - 'miaklwalker/stagehand:docs/guides/rollbacks.md'
16
+ - 'miaklwalker/stagehand:docs/reference/errors.md'
17
+ - 'miaklwalker/stagehand:src/errors.ts'
18
+ - 'miaklwalker/stagehand:src/script.ts'
19
+ - 'miaklwalker/stagehand:test/rollback-keys.test.ts'
20
+ ---
21
+
22
+ # Stagehand — Adding Rollbacks
23
+
24
+ When a step fails, every step that already **succeeded** is compensated in reverse order — last
25
+ completed, first undone. The step that actually failed is never compensated: it never completed, so
26
+ there's nothing to undo. A rollback gets no context by default; it has to name every key it needs
27
+ through `rollbackKeys`, which both narrows and permanently reserves them.
28
+
29
+ ## Setup
30
+
31
+ ```ts
32
+ import { Script } from "@michaelrwalker/stagehand";
33
+
34
+ async function reserve(accountId: string, amount: number): Promise<string> {
35
+ return `res_${accountId}_${amount}`;
36
+ }
37
+ async function release(accountId: string, reservationId: string): Promise<void> {
38
+ console.log(`released ${reservationId} for ${accountId}`);
39
+ }
40
+ async function chargeCard(accountId: string, amount: number): Promise<void> {
41
+ throw new Error("payment gateway timeout");
42
+ }
43
+
44
+ const result = await new Script<{ accountId: string; amount: number }>({ name: "charge" })
45
+ .addStep({ name: "seed", handler: ({ input }) => ({ ...input }) })
46
+ .addStep({
47
+ name: "reserve funds",
48
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId, ctx.amount) }),
49
+ rollbackKeys: ["accountId", "reservationId"],
50
+ rollback: async ({ ctx }) => release(ctx.accountId, ctx.reservationId),
51
+ })
52
+ .addStep({
53
+ name: "charge card",
54
+ handler: async ({ ctx }) => {
55
+ await chargeCard(ctx.accountId, ctx.amount);
56
+ return {};
57
+ },
58
+ })
59
+ .run({ accountId: "acct_1", amount: 500 });
60
+
61
+ if (!result.ok) {
62
+ console.error("run failed:", result.error);
63
+ for (const r of result.rollbacks) if (!r.ok) console.error("rollback failed:", r.step, r.error);
64
+ }
65
+ ```
66
+
67
+ ## Core Patterns
68
+
69
+ ### Only completed steps unwind, in reverse order
70
+
71
+ ```ts
72
+ import { Script } from "@michaelrwalker/stagehand";
73
+
74
+ const order: string[] = [];
75
+
76
+ await new Script({ name: "unwind-order" })
77
+ .addStep({ name: "one", handler: () => ({}), rollback: () => { order.push("one"); } })
78
+ .addStep({ name: "two", handler: () => ({}), rollback: () => { order.push("two"); } })
79
+ .addStep({
80
+ name: "three",
81
+ handler: () => { throw new Error("boom"); },
82
+ rollback: () => { order.push("three"); },
83
+ })
84
+ .run();
85
+
86
+ // order === ["two", "one"] — "three" never completed, so its own rollback never runs
87
+ ```
88
+
89
+ ### Scoping the unwind with `rollback: "phase"`
90
+
91
+ ```ts
92
+ import { Script } from "@michaelrwalker/stagehand";
93
+
94
+ const deploy = new Script({ name: "deploy", rollback: "phase" })
95
+ .addPhase("Prepare")
96
+ .addStep({ name: "provision", handler: async () => ({ serverId: "s1" }), rollback: async () => {} })
97
+ .addPhase("Release")
98
+ .addStep({ name: "swap traffic", handler: async () => ({ swapped: true }), rollback: async () => {} })
99
+ .addStep({ name: "notify", handler: async () => { throw new Error("notify failed"); } });
100
+
101
+ // "notify" fails inside "Release" — only "swap traffic"'s rollback fires;
102
+ // "provision" (in "Prepare") is left alone because rollback: "phase" only unwinds the failing phase
103
+ ```
104
+
105
+ `ScriptOptions.rollback` is `"all"` (default, unwinds the whole script), `"phase"` (unwinds only the
106
+ failing phase), or `"none"` (no compensation runs at all).
107
+
108
+ ### Narrowing what a rollback can see with `rollbackKeys`
109
+
110
+ ```ts
111
+ .addStep({
112
+ name: "reserve funds",
113
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId, ctx.amount) }),
114
+ rollbackKeys: ["accountId", "reservationId"],
115
+ rollback: async ({ ctx }) => {
116
+ // ctx is exactly { accountId: string; reservationId: string } — nothing else
117
+ await release(ctx.accountId, ctx.reservationId);
118
+ },
119
+ })
120
+ ```
121
+
122
+ `rollbackKeys` only accepts keys that exist on the step's incoming context or its own output — anything
123
+ else is a compile error. A rollback's `output` argument always carries the full handler return value
124
+ regardless of `rollbackKeys` or later `clean` calls; only `ctx` is narrowed.
125
+
126
+ ### Cancelling a running script from outside
127
+
128
+ ```ts
129
+ import { Script } from "@michaelrwalker/stagehand";
130
+
131
+ const job = new Script({ name: "long-job" })
132
+ .addStep({
133
+ name: "fetch",
134
+ handler: async ({ signal }) => {
135
+ const response = await fetch("https://api.example.com/data", { signal });
136
+ return { data: await response.json() };
137
+ },
138
+ });
139
+
140
+ const controller = new AbortController();
141
+ setTimeout(() => controller.abort(), 5_000);
142
+
143
+ const result = await job.run(undefined, { signal: controller.signal });
144
+
145
+ if (!result.ok && result.status === "aborted") {
146
+ console.log("cancelled:", result.error);
147
+ }
148
+ ```
149
+
150
+ `ScriptOptions.handleSignals` (default `true`) makes Ctrl-C and `SIGTERM` do the same thing; a second
151
+ signal after the first abandons compensation too. Forward a handler's own `signal` into any real I/O so
152
+ a cancelled step actually stops.
153
+
154
+ ## Common Mistakes
155
+
156
+ ### MEDIUM Relying on rollback: "phase" without ever calling addPhase
157
+
158
+ Wrong:
159
+ ```ts
160
+ new Script({ name: "t", rollback: "phase" })
161
+ .addStep(stepA).addStep(stepB).addStep(stepC);
162
+ // rollback: "phase" here behaves exactly like "all" — one phase, "Main"
163
+ ```
164
+
165
+ Correct:
166
+ ```ts
167
+ new Script({ name: "t", rollback: "phase" })
168
+ .addPhase("Prepare").addStep(stepA)
169
+ .addPhase("Commit").addStep(stepB).addStep(stepC);
170
+ ```
171
+
172
+ `addStep` creates an implicit `"Main"` phase the first time it's called if `addPhase` was never used, so with everything in one phase there is nothing for `"phase"` scoping to exclude — it unwinds identically to `"all"`.
173
+
174
+ Source: src/script.ts currentPhase() implicit "Main"; docs/guides/phases-and-steps.md
175
+
176
+ ### MEDIUM Writing a rollback for the step that itself failed
177
+
178
+ Wrong:
179
+ ```ts
180
+ .addStep({
181
+ name: "charge card",
182
+ handler: () => { throw new Error("declined"); },
183
+ rollback: () => refund(), // never runs — this step never succeeded
184
+ })
185
+ ```
186
+
187
+ Correct:
188
+ ```ts
189
+ .addStep({
190
+ name: "reserve funds",
191
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
192
+ rollbackKeys: ["accountId"],
193
+ rollback: async ({ ctx, output }) => release(ctx.accountId, output.reservationId),
194
+ })
195
+ .addStep({ name: "charge card", handler: () => { throw new Error("declined"); } })
196
+ ```
197
+
198
+ The step that throws never completes, so only earlier, already-succeeded steps are unwound — a `rollback` field on the failing step itself is dead code.
199
+
200
+ Source: docs/guides/rollbacks.md
201
+
202
+ ### CRITICAL Expecting a rollback to see the full context without declaring rollbackKeys
203
+
204
+ Wrong:
205
+ ```ts
206
+ .addStep({
207
+ name: "reserve funds",
208
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
209
+ rollback: async ({ ctx }) => release(ctx.accountId, ctx.reservationId),
210
+ // ^ both are `undefined` — ctx is {}
211
+ })
212
+ ```
213
+
214
+ Correct:
215
+ ```ts
216
+ .addStep({
217
+ name: "reserve funds",
218
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
219
+ rollbackKeys: ["accountId", "reservationId"],
220
+ rollback: async ({ ctx }) => release(ctx.accountId, ctx.reservationId),
221
+ })
222
+ ```
223
+
224
+ A rollback gets `{}` by default — every key it touches has to be named in `rollbackKeys`, which both narrows `ctx` and reserves those keys against `clean`.
225
+
226
+ Source: docs/guides/rollbacks.md; test/rollback-keys.test.ts
227
+
228
+ ### MEDIUM Assuming a throwing rollback replaces or masks result.error
229
+
230
+ Wrong:
231
+ ```ts
232
+ const result = await script.run(input);
233
+ if (!result.ok) console.error(result.error); // assumed to be the rollback's error if one failed
234
+ ```
235
+
236
+ Correct:
237
+ ```ts
238
+ const result = await script.run(input);
239
+ if (!result.ok) {
240
+ console.error("run failed:", result.error);
241
+ for (const r of result.rollbacks) if (!r.ok) console.error("rollback failed:", r.step, r.error);
242
+ }
243
+ ```
244
+
245
+ 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.
246
+
247
+ Source: docs/guides/rollbacks.md; docs/reference/errors.md (RollbackFailedError)
248
+
249
+ ### MEDIUM Any Error named "AbortError" reads as a script cancellation
250
+
251
+ Wrong:
252
+ ```ts
253
+ handler: async ({ signal }) => {
254
+ const localController = new AbortController();
255
+ setTimeout(() => localController.abort(), 200); // unrelated to `signal`
256
+ return fetch(url, { signal: localController.signal }); // throws AbortError → retry silently skipped
257
+ },
258
+ retry: { attempts: 3 },
259
+ ```
260
+
261
+ Correct:
262
+ ```ts
263
+ handler: async ({ signal }) => fetch(url, { signal }), // forward the step's own signal
264
+ retry: { attempts: 3, retryIf: (error) => !isAbort(error) },
265
+ ```
266
+
267
+ Wrong:
268
+ ```ts
269
+ if (!result.ok && isAbort(result.error)) {
270
+ console.log("user pressed Ctrl-C"); // could actually be an unrelated internal AbortError
271
+ }
272
+ ```
273
+
274
+ Correct:
275
+ ```ts
276
+ if (!result.ok && isAbort(result.error) && result.status === "aborted") {
277
+ console.log("cancelled");
278
+ }
279
+ ```
280
+
281
+ `isAbort(error)` returns `true` for a `ScriptAbortedError` or any `Error` whose `name` is exactly `"AbortError"` — not just one tied to the script's own signal — so a hand-rolled `AbortController` inside a handler silently skips `retry`, and checking `isAbort(result.error)` alone can misreport an unrelated internal abort as a real cancellation.
282
+
283
+ Source: src/errors.ts isAbort(); src/script.ts executeStep() (isAbort check precedes the retry-budget check); docs/guides/rollbacks.md ("Signals and cancellation")
284
+
285
+ ### MEDIUM Writing a rollback that reaches beyond its own step's side effects
286
+
287
+ Wrong:
288
+ ```ts
289
+ .addStep({
290
+ name: "reserve funds",
291
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
292
+ rollback: async ({ ctx }) => {
293
+ await release(ctx.reservationId);
294
+ await notifyUser(ctx.accountId); // "charge card"'s concern, not this step's
295
+ },
296
+ })
297
+ .addStep({ name: "charge card", handler: chargeCard, rollback: async () => notifyUser(ctx.accountId) })
298
+ ```
299
+
300
+ Correct:
301
+ ```ts
302
+ .addStep({
303
+ name: "reserve funds",
304
+ handler: async ({ ctx }) => ({ reservationId: await reserve(ctx.accountId) }),
305
+ rollbackKeys: ["reservationId"],
306
+ rollback: async ({ ctx }) => release(ctx.reservationId), // only what this step did
307
+ })
308
+ .addStep({
309
+ name: "charge card",
310
+ handler: chargeCard,
311
+ rollback: async () => notifyUser(), // its own concern, compensated independently
312
+ })
313
+ ```
314
+
315
+ Each completed step's own rollback already fires for its own side effects during the reverse unwind, so a rollback that also cleans up another step's work either double-undoes it or does so with less context than the responsible step actually had.
316
+
317
+ Source: maintainer interview ("rollback steps should be written when you understand everything a step must do to properly rollback... scoped to the specific step it is supposed to rollback")
318
+
319
+ ### CRITICAL Cleaning a key reserved by rollbackKeys, even from the step that reserved it
320
+
321
+ Wrong:
322
+ ```ts
323
+ .addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
324
+ .addStep({ name: "two", handler: () => {}, clean: ["a"] });
325
+ // StepDefinitionError: reserved by rollbackKeys
326
+ ```
327
+
328
+ Correct:
329
+ ```ts
330
+ .addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
331
+ .addStep({ name: "two", handler: () => {} }) // leave "a" alone; it stays reserved
332
+ ```
333
+
334
+ Once a key is reserved by any step's `rollbackKeys`, no step — including that one — may list it in `clean`, for the rest of the script, across phase boundaries; `addStep` throws `StepDefinitionError` at build time.
335
+
336
+ Source: src/script.ts addStep() conflicts check; docs/guides/cleaning-context.md; test/rollback-keys.test.ts
337
+
338
+ ### MEDIUM Giving a routine its own ScriptOptions and expecting them to hold once mounted
339
+
340
+ Wrong:
341
+ ```ts
342
+ // routines/import.ts
343
+ const pipeline = new Script({ name: "import", rollback: "none" })
344
+ .addPhase("Fetch").addStep({ name: "pull", handler, rollback: undoPull });
345
+ // mounted into a host with rollback: "all" — the host's "all" wins; "pull"'s rollback CAN fire
346
+ ```
347
+
348
+ Correct:
349
+ ```ts
350
+ // a mountable fragment's own ScriptOptions are dead weight once mounted — fine to leave them off,
351
+ // and expect whichever script mounts it to own rollback/logPlacement/silent for the whole run
352
+ ```
353
+
354
+ A mounted `Script`'s own `ScriptOptions` are discarded in favor of the host's; only its phases and steps come across.
355
+
356
+ Source: docs/guides/reusable-scripts.md ("A mounted Script's own ScriptOptions... are ignored in favor of the host's")
357
+
358
+ ### MEDIUM Catching RollbackFailedError around run()
359
+
360
+ Wrong:
361
+ ```ts
362
+ try {
363
+ await script.run(input);
364
+ } catch (e) {
365
+ if (e instanceof RollbackFailedError) { /* never matches */ }
366
+ }
367
+ ```
368
+
369
+ Correct:
370
+ ```ts
371
+ const result = await script.run(input);
372
+ if (!result.ok) {
373
+ const failedRollbacks = result.rollbacks.filter((r) => !r.ok);
374
+ }
375
+ ```
376
+
377
+ `RollbackFailedError` is exported for a caller's own reporting, but the library itself never throws it — a failing rollback is instead recorded as `{ ok: false, error }` inside `result.rollbacks`.
378
+
379
+ Source: docs/reference/errors.md ("a failing rollback does not propagate as this error type through RunResult")
380
+
381
+ ### HIGH Tension: Caching speed vs. compensation guarantees
382
+
383
+ Cached work never actually runs on a hit, so its rollback can never fire during a later unwind. Agents optimizing for this skill's goal tend to add `cache: store` to an already-rollback-protected phase as a drive-by performance or rate-limit tweak because they don't account for caching-expensive-work's constraint that a cache hit skips the handler entirely, quietly removing that phase from the saga's compensation guarantee.
384
+
385
+ See also: skills/caching/caching-expensive-work/SKILL.md § Common Mistakes
386
+
387
+ ### HIGH Tension: Simple first draft vs. rollbackKeys reservation permanence
388
+
389
+ The simplest script has no `rollbackKeys` and no `clean` conflicts. Agents optimizing for this skill's goal tend to add a `rollback` to "harden" an already-working script and get blindsided by `StepDefinitionError` from unrelated `clean` calls elsewhere because they don't account for the fact that any `rollbackKeys` the new rollback declares permanently locks those keys from `clean` everywhere else in the script, for the rest of its life.
390
+
391
+ See also: skills/building-scripts/getting-started/SKILL.md § Common Mistakes, skills/compensation-safety/cleaning-context/SKILL.md § Common Mistakes
392
+
393
+ ### HIGH Tension: Routine authoring instincts vs. host-controlled ScriptOptions
394
+
395
+ 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. Agents optimizing for this skill's goal tend to set `rollback: "none"` on a routine's own `Script` to bake in "this fragment is always non-destructive" because they don't account for reusable-routines-and-mounts' constraint that every one of those options is discarded the moment the routine is mounted — only the host's own `ScriptOptions` ever apply.
396
+
397
+ See also: skills/reuse/reusable-routines-and-mounts/SKILL.md § Common Mistakes, skills/observability/log-placement-and-rendering/SKILL.md § Common Mistakes
398
+
399
+ See also: skills/compensation-safety/cleaning-context/SKILL.md — rollbackKeys and clean share one reservation mechanism; writing a rollback without knowing this leads to confusing StepDefinitionErrors elsewhere in the script.
400
+ See also: skills/caching/caching-expensive-work/SKILL.md — cached work never rolls back; anyone reasoning about compensation coverage needs to know which phases are cache-exempt from it.
@@ -0,0 +1,165 @@
1
+ ---
2
+ name: 'cleaning-context'
3
+ description: >
4
+ Covers the `clean` field on `addStep` — dropping keys from both the runtime context and the type every later step sees — and its hard interaction with `rollbackKeys` reservation, which permanently blocks a key from `clean` for the rest of the script. Load this for "drop a secret from context once it's no longer needed," "why does clean reject this key," or any `StepDefinitionError` mentioning "reserved by rollbackKeys."
5
+ metadata:
6
+ type: 'core'
7
+ library: 'stagehand'
8
+ library_version: '0.5.3'
9
+ sources:
10
+ - 'miaklwalker/stagehand:docs/guides/cleaning-context.md'
11
+ - 'miaklwalker/stagehand:src/script.ts'
12
+ - 'miaklwalker/stagehand:test/rollback-keys.test.ts'
13
+ - 'miaklwalker/stagehand:test/clean.test.ts'
14
+ ---
15
+
16
+ # Stagehand — Cleaning Context Keys
17
+
18
+ A handler's return value stays in the context for the rest of the script by default. Listing keys in a step's `clean` deletes them from the runtime context **and** removes them from the type every later step sees.
19
+
20
+ ## Setup
21
+
22
+ ```ts
23
+ import { Script } from "@michaelrwalker/stagehand";
24
+
25
+ const result = await new Script<{ password: string; email: string }>({ name: "signup" })
26
+ .addStep({
27
+ name: "read input",
28
+ handler: ({ input }) => ({ ...input }),
29
+ })
30
+ .addStep({
31
+ name: "hash",
32
+ handler: async ({ ctx }) => ({ hash: await hashIt(ctx.password) }),
33
+ clean: ["password"], // not needed past this point
34
+ })
35
+ .addStep({
36
+ name: "persist",
37
+ handler: ({ ctx }) => {
38
+ ctx.hash; // string
39
+ ctx.email; // string
40
+ // ctx.password; // would be a compile error — cleaned away
41
+ return {};
42
+ },
43
+ })
44
+ .run({ password: "hunter2", email: "a@b.com" });
45
+
46
+ async function hashIt(s: string) {
47
+ return s;
48
+ }
49
+ ```
50
+
51
+ ## Core Patterns
52
+
53
+ ### Drop a key immediately after the step that consumes it
54
+
55
+ ```ts
56
+ .addStep({
57
+ name: "derive",
58
+ handler: ({ ctx }) => ({ temp: ctx.a * 10, b: ctx.a + 1 }),
59
+ clean: ["a"], // "a" is gone; "temp" and "b" remain
60
+ })
61
+ ```
62
+
63
+ A step can clean any key from its *incoming* context while adding new keys of its own in the same handler.
64
+
65
+ ### Clean under a skipped step or a cache hit
66
+
67
+ ```ts
68
+ new Script<{ secret: string }>({ name: "t" })
69
+ .addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
70
+ .addStep({
71
+ name: "optional",
72
+ when: () => false,
73
+ handler: () => ({ used: true }),
74
+ clean: ["secret"],
75
+ })
76
+ .run({ secret: "hush" });
77
+ // result.ctx is {} — "secret" is gone even though "optional" never ran
78
+ ```
79
+
80
+ `clean` describes the declared shape of the context, not the work performed, so it applies whether the step actually ran, was skipped by `when`, or was served from cache.
81
+
82
+ ### A step's own `output` survives clean, for its rollback
83
+
84
+ ```ts
85
+ .addStep({
86
+ name: "one",
87
+ handler: () => ({ token: "tok_1", scratch: "temp" }),
88
+ rollbackKeys: ["a"],
89
+ rollback: ({ ctx, output }) => {
90
+ // output is still { token: "tok_1", scratch: "temp" } in full,
91
+ // regardless of what a later step cleans from the live context
92
+ },
93
+ })
94
+ .addStep({ name: "drop it", handler: () => ({}), clean: ["scratch"] })
95
+ ```
96
+
97
+ ## Common Mistakes
98
+
99
+ ### MEDIUM Trying to clean a key the step itself just produced
100
+
101
+ Wrong:
102
+ ```ts
103
+ .addStep({
104
+ name: "s",
105
+ handler: ({ ctx }) => ({ temp: ctx.a * 10 }),
106
+ clean: ["temp"], // compile error — "temp" is this step's output, not its input
107
+ })
108
+ ```
109
+
110
+ Correct:
111
+ ```ts
112
+ .addStep({ name: "s", handler: ({ ctx }) => ({ temp: ctx.a * 10 }) })
113
+ .addStep({ name: "next", handler: () => ({}), clean: ["temp"] })
114
+ ```
115
+
116
+ `clean` is checked against the context as it stands entering the step, before the handler's own output is merged, so a step's own output does not exist yet at the point `clean` is evaluated — it's a compile error to list it.
117
+
118
+ Source: docs/guides/cleaning-context.md
119
+
120
+ ### CRITICAL Cleaning a key reserved by rollbackKeys, even from the reserving step
121
+
122
+ Wrong:
123
+ ```ts
124
+ .addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
125
+ .addStep({ name: "two", handler: () => {}, clean: ["a"] });
126
+ // StepDefinitionError: reserved by rollbackKeys
127
+ ```
128
+
129
+ Correct:
130
+ ```ts
131
+ .addStep({ name: "one", handler: () => {}, rollbackKeys: ["a"], rollback: async () => {} })
132
+ .addStep({ name: "two", handler: () => {} }); // leave "a" alone; it stays reserved
133
+ ```
134
+
135
+ Once a key is reserved by any step's `rollbackKeys`, no step — including that one — may list it in `clean`, for the rest of the script, across phase boundaries; `addStep` throws `StepDefinitionError` at build time, before anything runs.
136
+
137
+ Source: src/script.ts addStep() conflicts check; docs/guides/cleaning-context.md; test/rollback-keys.test.ts
138
+
139
+ ### MEDIUM Assuming a skipped step (when: false) does not run its clean
140
+
141
+ Wrong:
142
+ ```ts
143
+ // developer expects `secret` to survive because "optional" never ran
144
+ .addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
145
+ .addStep({ name: "optional", when: () => false, handler: () => ({}), clean: ["secret"] })
146
+ .addStep({ name: "later", handler: ({ ctx }) => { ctx.secret; /* compile error — gone */ } });
147
+ ```
148
+
149
+ Correct:
150
+ ```ts
151
+ // if "secret" should only be dropped when "optional" actually ran, clean elsewhere
152
+ .addStep({ name: "seed", handler: ({ input }) => ({ secret: input.secret }) })
153
+ .addStep({ name: "optional", when: () => false, handler: () => ({}) })
154
+ .addStep({ name: "use it then drop it", handler: ({ ctx }) => ({}), clean: ["secret"] });
155
+ ```
156
+
157
+ `clean` is part of the declared shape of the context, not of the work performed, so it applies even when the step is skipped by `when` — runtime and types never disagree about what a skipped step removed.
158
+
159
+ Source: docs/guides/cleaning-context.md; src/script.ts phaseLoop skip branch (drop(ctx, item.def.clean))
160
+
161
+ ### HIGH Tension: Simple first draft vs. rollbackKeys reservation permanence
162
+
163
+ Adding a rollback to a working script pulls in a permanent constraint that a plain script never had to deal with. Agents optimizing for this skill's goal (a clean, minimal context) tend to leave existing `clean` calls untouched when a rollback is added elsewhere, then hit an unrelated `StepDefinitionError` because they don't account for `rollbackKeys` permanently reserving that key across the whole script, including earlier or later phases.
164
+
165
+ See also: skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes