@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,121 @@
1
+ # Context type utilities
2
+
3
+ These are the type-level helpers Stagehand uses internally to compute `Ctx`,
4
+ `RollbackContext`, and `WithStepFor` chains. All of them run automatically as
5
+ part of `addStep`, `defineInput`, and `stepFor` — a script author almost never
6
+ imports or writes them directly. They're documented here for the rare case of
7
+ debugging a confusing inferred type, or hand-writing a step definition outside
8
+ `addStep` (e.g. a shared library file) where the composed context needs to be
9
+ spelled out explicitly.
10
+
11
+ ## `Prettify<T>`
12
+
13
+ ```ts
14
+ export type Prettify<T> = { [K in keyof T]: T[K] } & {};
15
+ ```
16
+
17
+ Flattens an intersection type (`Omit<Ctx, K> & Out`) into a single object type
18
+ so editor hovers show a real shape (`{ a: string; b: number }`) instead of a
19
+ chain of `&` operators. Applied automatically everywhere a context type is
20
+ constructed — `result.ctx`, a step's `ctx` parameter, `RollbackData` — so
21
+ there's nothing to opt into. Reach for it directly only when hand-writing a
22
+ type alias for a shared step's context and the hover in your editor is showing
23
+ an unreadable intersection.
24
+
25
+ ## `Merge<Ctx, Out>`
26
+
27
+ ```ts
28
+ export type Merge<Ctx, Out> = [Out] extends [void]
29
+ ? Ctx
30
+ : [Out] extends [undefined]
31
+ ? Ctx
32
+ : Prettify<Omit<Ctx, keyof Out> & Out>;
33
+ ```
34
+
35
+ Computes the context after a step contributes `Out`: later keys win over
36
+ earlier ones of the same name (`Omit<Ctx, keyof Out> & Out`), and a handler
37
+ that returns `void` or `undefined` leaves `Ctx` completely untouched. This is
38
+ the type-level mirror of the runtime `Object.assign`-based merge in
39
+ `src/script.ts`. It is the core of how every `addStep` call widens `Ctx`, and
40
+ also underlies `WithStepFor` (see below). Almost never written by hand —
41
+ inference through `addStep` produces it automatically.
42
+
43
+ ## `Cleaned<Ctx, Keys>`
44
+
45
+ ```ts
46
+ export type Cleaned<Ctx, Keys extends PropertyKey> = Prettify<Omit<Ctx, Keys>>;
47
+ ```
48
+
49
+ What a step's `clean` field leaves behind for every later step: `Keys` are
50
+ dropped from both the runtime context and the type. This is what makes a
51
+ cleaned key a genuine compile error to reach for afterward, not just a
52
+ runtime `undefined`. Computed automatically wherever `clean` is used; there's
53
+ no reason to reference `Cleaned` directly unless writing tooling that
54
+ inspects a script's declared clean keys.
55
+
56
+ ## `RollbackData<Ctx, Keys>`
57
+
58
+ ```ts
59
+ export type RollbackData<Ctx, Keys extends readonly PropertyKey[]> = Pick<
60
+ Ctx,
61
+ Extract<Keys[number], keyof Ctx>
62
+ >;
63
+ ```
64
+
65
+ The slice of context a `rollback` function actually receives: exactly the
66
+ keys named in that step's `rollbackKeys`, and nothing else. With no keys
67
+ declared, this resolves to `{}` — a rollback has to explicitly ask for what
68
+ it needs via `rollbackKeys`; it cannot see the full `Ctx`. `addStep` uses this
69
+ (wrapped in `Prettify`) to type the `ctx` parameter of `RollbackContext`. You
70
+ would only reference `RollbackData` directly when writing a rollback handler
71
+ outside `addStep` and need to spell out its parameter type by hand.
72
+
73
+ ## `WithStepFor<Step, Rest>`
74
+
75
+ ```ts
76
+ export type WithStepFor<
77
+ Step extends { handler: (context: never) => unknown },
78
+ Rest extends object = {},
79
+ > = Step extends { handler: (context: never) => infer Out }
80
+ ? Merge<Rest, Awaited<Out>>
81
+ : never;
82
+ ```
83
+
84
+ Computes the context a *later* step sees, given an earlier step (declared
85
+ with `stepFor`) and whatever else is already there (`Rest`). This is the one
86
+ utility from this file developers reach for directly on a regular basis — it
87
+ is the mechanism behind [Splitting Steps Across Files](../../../reuse/splitting-steps-across-files/SKILL.md):
88
+
89
+ ```ts
90
+ export const logIntoDb = stepFor<Input>()({
91
+ name: "log into db",
92
+ handler: async () => ({ conn: await connect() }),
93
+ });
94
+
95
+ export const loadRules = stepFor<
96
+ Input,
97
+ WithStepFor<typeof logIntoDb, { conditions: Array<{ name: string }> }>
98
+ >()({
99
+ name: "load rules",
100
+ // ctx.conn — from the step; ctx.conditions — from the rest
101
+ handler: ({ ctx }) => ({ rules: ctx.conn.query(ctx.conditions) }),
102
+ });
103
+ ```
104
+
105
+ It nests for chains of steps, without a separate variadic form:
106
+
107
+ ```ts
108
+ WithStepFor<typeof second, WithStepFor<typeof first, { conditions: string[] }>>
109
+ ```
110
+
111
+ Read inside out: start with `{ conditions: string[] }`, add what `first`
112
+ returns, then what `second` returns. That's also the precedence — on a name
113
+ collision the outermost step wins, matching the way a later step's return
114
+ value shadows an earlier key at runtime. Order the nesting to match the order
115
+ the steps actually run in, or the type and the runtime disagree.
116
+
117
+ `WithStepFor` only reads the step's *output* — its own `Ctx` requirement
118
+ against `Rest` is not checked here; that check still happens when the step
119
+ definition is passed to `addStep` on a real script. A step returning nothing
120
+ leaves `Rest` untouched, and an `async` handler's return is awaited first
121
+ (`Awaited<Out>`), so both fall out without a special case.
@@ -0,0 +1,285 @@
1
+ ---
2
+ name: 'caching-expensive-work'
3
+ description: >
4
+ fileStore/memoryStore, per-slot stale/schema options, per-run CacheMode
5
+ ("on"/"off"/"refresh"/"read-only"), and reaching a cached slot with
6
+ context.cache (read/write/clear/ageOf) from a later step or rollback. Load
7
+ this for "skip re-running an expensive phase," "invalidate a cache entry
8
+ from a later step," "correct a cached value without resetting its TTL," or
9
+ "guard a cache entry against an old script shape."
10
+ metadata:
11
+ type: 'core'
12
+ library: 'stagehand'
13
+ library_version: '0.5.3'
14
+ sources:
15
+ - 'miaklwalker/stagehand:docs/guides/caching.md'
16
+ - 'miaklwalker/stagehand:docs/reference/cache.md'
17
+ - 'miaklwalker/stagehand:src/cache.ts'
18
+ - 'miaklwalker/stagehand:src/script.ts'
19
+ - 'miaklwalker/stagehand:test/cache.test.ts'
20
+ - 'miaklwalker/stagehand:test/context-cache.test.ts'
21
+ ---
22
+
23
+ # Stagehand — Caching Expensive Work
24
+
25
+ A phase or step can point at a store and stop repeating its work on later
26
+ runs. There is no cache key to pass: a slot's name is derived from the
27
+ phase's or step's own name, and whether a hit is still good is entirely
28
+ `stale`'s job.
29
+
30
+ ## Setup
31
+
32
+ ```ts
33
+ import { Script, fileStore } from "@michaelrwalker/stagehand";
34
+
35
+ const cache = fileStore("./.stagehand-cache.json");
36
+
37
+ interface Input {
38
+ ref: string;
39
+ }
40
+
41
+ const deploy = new Script<Input>({ name: "deploy" })
42
+ .addPhase("Build", { cache })
43
+ .addStep({
44
+ name: "compile",
45
+ handler: async ({ input }) => ({ artifact: `dist/${input.ref}.tgz` }),
46
+ })
47
+
48
+ .addPhase("Release")
49
+ .addStep({
50
+ name: "ship",
51
+ handler: async ({ ctx }) => ({ shipped: ctx.artifact }),
52
+ });
53
+
54
+ await deploy.run({ ref: "main" });
55
+ ```
56
+
57
+ On the second run with the same `ref`, `compile` is skipped and `artifact`
58
+ is merged into the context from the store instead of being recomputed.
59
+
60
+ ## Core Patterns
61
+
62
+ ### Caching a step with `stale` and `schema`
63
+
64
+ ```ts
65
+ import { z } from "zod";
66
+
67
+ const BuildSchema = z.object({ artifact: z.string() });
68
+
69
+ .addPhase("Build")
70
+ .addStep({
71
+ name: "compile",
72
+ cache: {
73
+ store: cache,
74
+ stale: ({ value, ctx, ageMs }) => ageMs > 3_600_000,
75
+ schema: BuildSchema,
76
+ },
77
+ handler: async ({ input }) => ({ artifact: `dist/${input.ref}.tgz` }),
78
+ })
79
+ ```
80
+
81
+ `stale` and `schema` both see the live context as of that step; a stored
82
+ value that fails `schema` is treated as a miss, not an error.
83
+
84
+ ### Controlling caching per run without touching the script
85
+
86
+ ```ts
87
+ await deploy.run(
88
+ { ref: "main" },
89
+ { cache: process.argv.includes("--no-cache") ? "off" : "on" },
90
+ );
91
+ ```
92
+
93
+ `"on"` (default) reads and writes, `"off"` ignores caching entirely,
94
+ `"refresh"` runs everything and overwrites every entry, `"read-only"` uses
95
+ hits but never writes.
96
+
97
+ ### Reaching a cached slot from a later step
98
+
99
+ ```ts
100
+ .addPhase("Fetch", { cache: { store: cache } })
101
+ .addStep({
102
+ name: "pull",
103
+ handler: async () => ({ orders: [{ sku: "a", qty: 2 }] }),
104
+ })
105
+
106
+ .addPhase("Adjust")
107
+ .addStep({
108
+ name: "restock",
109
+ handler: async ({ ctx, cache }) => {
110
+ await cache.clear("Fetch");
111
+ await cache.write("Fetch", { orders: ctx.orders }, { keepAge: true });
112
+ const age = await cache.ageOf("Fetch");
113
+ return { restockedAt: age };
114
+ },
115
+ })
116
+ ```
117
+
118
+ `context.cache` is typed to exactly the cached phases and steps declared
119
+ before the current one; a typo or a not-yet-declared slot is a compile
120
+ error, not a silent no-op.
121
+
122
+ ### Opting out of the shape guard before a value enters ctx
123
+
124
+ ```ts
125
+ import { CacheShapeError } from "@michaelrwalker/stagehand";
126
+
127
+ .addStep({
128
+ name: "restock",
129
+ handler: async ({ cache }) => {
130
+ try {
131
+ const stored = await cache.read("Fetch", { raw: true });
132
+ return { orders: stored };
133
+ } catch (error) {
134
+ if (!(error instanceof CacheShapeError)) throw error;
135
+ await cache.clear("Fetch");
136
+ return { orders: [] };
137
+ }
138
+ },
139
+ })
140
+ ```
141
+
142
+ ## Common Mistakes
143
+
144
+ ### HIGH Toggling `cache:` with a ternary instead of `run()`
145
+
146
+ Wrong:
147
+ ```ts
148
+ .addStep({
149
+ name: "build",
150
+ cache: isProd ? store : undefined, // no compile error, but no typed slot either
151
+ handler: async () => ({ artifact: await build() }),
152
+ })
153
+ // later: context.cache.clear("build") — compile error, "build" is not a known slot
154
+ ```
155
+
156
+ Correct:
157
+ ```ts
158
+ .addStep({
159
+ name: "build",
160
+ cache: { store }, // always a concrete CacheSource — gets a typed slot
161
+ handler: async () => ({ artifact: await build() }),
162
+ })
163
+ // toggle it from the call site instead:
164
+ await script.run(input, { cache: isProd ? "on" : "off" });
165
+ ```
166
+
167
+ `addStep` has a separate overload for a cached step requiring `cache` to be a concrete `CacheSource`; a value typed `CacheStore | undefined` doesn't satisfy it, so TypeScript falls back to the plain overload — the step still caches at runtime whenever the value is truthy, but `context.cache` never gets a typed slot for it.
168
+
169
+ Source: src/script.ts addStep() overloads; docs/guides/caching.md ("Per-run cache control")
170
+
171
+ ### MEDIUM Expecting `cache.clear()` to force a re-run mid-run
172
+
173
+ Wrong:
174
+ ```ts
175
+ .addStep({
176
+ name: "restock",
177
+ handler: async ({ ctx, cache }) => {
178
+ await cache.clear("Fetch");
179
+ // assumed: "Fetch" now redoes its work this run — it does not
180
+ },
181
+ })
182
+ ```
183
+
184
+ Correct:
185
+ ```ts
186
+ // clear now, and let the *next* invocation of the script get the miss
187
+ .addStep({
188
+ name: "restock",
189
+ handler: async ({ ctx, cache }) => {
190
+ await cache.clear("Fetch"); // takes effect starting next run
191
+ },
192
+ })
193
+ ```
194
+
195
+ A phase's cache read happens once, before its steps execute; invalidation only affects the next run that reads the slot, not the phase that already ran or hit cache this run.
196
+
197
+ Source: docs/guides/caching.md ("Reaching the cache from a step"); src/script.ts run()
198
+
199
+ ### MEDIUM Letting a shape-guarded value travel into ctx unraw
200
+
201
+ Wrong:
202
+ ```ts
203
+ const stored = await cache.read("Fetch");
204
+ return { orders: stored }; // guard travels into ctx.orders
205
+ // three steps later: ctx.orders[0].newField throws CacheShapeError there, not here
206
+ ```
207
+
208
+ Correct:
209
+ ```ts
210
+ const stored = await cache.read("Fetch", { raw: true }); // opt out before it enters ctx
211
+ return { orders: stored };
212
+ ```
213
+
214
+ The shape guard on `cache.read` wraps the value in a Proxy that throws `CacheShapeError` the moment a missing field is actually touched, including from a step several hops downstream that just reads it off `ctx`, far from the original `cache.read()` call.
215
+
216
+ Source: docs/guides/caching.md ("The shape guard"); src/cache.ts guardShape()
217
+
218
+ ### MEDIUM Trusting cache to guarantee a call never runs twice
219
+
220
+ Wrong:
221
+ ```ts
222
+ // assumes fileStore failure = the run fails loudly, so a "guaranteed once" API call
223
+ // is safe to put behind cache alone
224
+ .addStep({ cache: { store: fileStore("./cache.json") }, handler: () => callRateLimitedApi() })
225
+ ```
226
+
227
+ Correct:
228
+ ```ts
229
+ // add an idempotency key or rate-limit handling in the handler itself; treat cache
230
+ // as an optimization, not a guarantee
231
+ .addStep({
232
+ cache: { store: fileStore("./cache.json") },
233
+ handler: () => callRateLimitedApi({ idempotencyKey }),
234
+ })
235
+ ```
236
+
237
+ A store that throws — an unreadable file, a full disk, a buggy `stale` predicate — logs a warning and lets the work run anyway; caching never decides whether a run passes or fails, so it is not a hard guarantee against re-execution.
238
+
239
+ Source: docs/guides/caching.md ("A store that throws... logs a warning and does the work")
240
+
241
+ ### MEDIUM Patching a value with `cache.write` and losing its age
242
+
243
+ Wrong:
244
+ ```ts
245
+ await cache.write("Fetch", { orders: corrected }); // resets the TTL clock
246
+ ```
247
+
248
+ Correct:
249
+ ```ts
250
+ await cache.write("Fetch", { orders: corrected }, { keepAge: true });
251
+ ```
252
+
253
+ `write` restamps the entry as written now unless `keepAge` is passed, so a correction that resets the clock silently buys another full staleness window for data that is exactly as old as it was a moment ago.
254
+
255
+ Source: docs/guides/caching.md; docs/reference/cache.md
256
+
257
+ ### LOW Renaming a mount with `as` without updating cache calls
258
+
259
+ Wrong:
260
+ ```ts
261
+ .use(pullChannel, { as: "Amazon" })
262
+ // elsewhere: cache.clear("Fetch") — now a compile error, slot is "Amazon / Fetch"
263
+ ```
264
+
265
+ Correct:
266
+ ```ts
267
+ .use(pullChannel, { as: "Amazon" })
268
+ // elsewhere: cache.clear("Amazon / Fetch")
269
+ ```
270
+
271
+ `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.
272
+
273
+ Source: docs/guides/reusable-scripts.md ("Mounting twice"); docs/guides/caching.md ("Reaching the cache from a step")
274
+
275
+ ### HIGH Tension: Caching speed vs. compensation guarantees
276
+
277
+ Caching a phase for speed or rate-limit safety quietly removes it from the saga's compensation guarantee, since cached work never actually runs on a hit and its rollback can never fire during a later unwind. Agents optimizing this skill's goal tend to add `cache: store` to an already-rollback-protected phase as a drive-by performance tweak because they don't account for the other skill's guarantee that every mutating step's side effect gets undone on failure.
278
+
279
+ See also: skills/compensation-safety/adding-rollbacks/SKILL.md § Common Mistakes
280
+
281
+ See also: skills/reuse/reusable-routines-and-mounts/SKILL.md — cache slots travel with a mount and get renamed by `as`, which changes every context.cache call written against the routine.
282
+
283
+ ## References
284
+
285
+ - [Cache API reference](references/cache-api.md)
@@ -0,0 +1,278 @@
1
+ # Cache API Reference
2
+
3
+ Full type surface for Stagehand's caching layer, from `src/cache.ts` and
4
+ `src/types.ts`. Narrative coverage lives in the main `SKILL.md`.
5
+
6
+ ## `fileStore(filePath): CacheStore`
7
+
8
+ ```ts
9
+ function fileStore(filePath: string): CacheStore
10
+ ```
11
+
12
+ Backs every slot passed to it with one JSON file at `filePath`, resolved to
13
+ an absolute path via `node:path`'s `resolve`.
14
+
15
+ - **Missing / unreadable / malformed file** all read as no cache at all — a
16
+ `JSON.parse` failure, a missing file, or a non-object root all fall back to
17
+ `{}`. Deleting the file is always a valid way to reset every slot it held.
18
+ - **Serialization**: values must survive `JSON.stringify`. Anything that
19
+ doesn't (a `Date`, a class instance) comes back as its JSON shape on the
20
+ next read, not as the original type.
21
+ - **Atomic writes**: every write does mkdir (recursive) on the file's parent
22
+ directory, then writes the whole file's data to a temp file named
23
+ `${target}.${process.pid}.${tempCounter}.tmp` (a monotonically increasing
24
+ counter, not just the pid, because two writes in the same process sharing a
25
+ pid-only temp name used to rename each other's files out from under
26
+ themselves), then `rename`s the temp file onto the target path.
27
+ - If the rename fails with `EPERM`, `EACCES`, `EBUSY`, or `EEXIST` (Windows,
28
+ antivirus, an editor, or a file watcher holding the destination open),
29
+ it falls back to an in-place `writeFile` on the target — atomicity is
30
+ given up, the write is not.
31
+ - Any other rename error is rethrown.
32
+ - The temp file is always removed in a `finally` (`rm(temp, { force: true })`).
33
+ - **Read-modify-write queueing**: every `write` and `clear` goes through an
34
+ in-process `Map<string, Promise<unknown>>` keyed by the *resolved* target
35
+ path, so two `fileStore(path)` calls on the same file (even spelled
36
+ differently, e.g. with a redundant `..` segment) share one writer and never
37
+ race. A failed write does not poison writes queued behind it. This
38
+ serialization is in-process only — two separate `node` processes writing
39
+ the same file can still interleave.
40
+ - `read(slot)` re-reads and re-parses the whole file each call and returns
41
+ the entry at `slot` only if it passes `isEntry` (an object with a `value`
42
+ key and a numeric `savedAt`); otherwise `undefined`, including for a
43
+ hand-edited or older-format file.
44
+ - `clear(slot)` is a no-op (skips the write entirely) if the slot isn't
45
+ present via `Object.hasOwn`.
46
+
47
+ ## `memoryStore(): CacheStore`
48
+
49
+ ```ts
50
+ function memoryStore(): CacheStore
51
+ ```
52
+
53
+ Backs every slot with a plain `Map<string, CachedEntry>` that lives for the
54
+ process. `read`/`write`/`clear` are synchronous under the hood (still
55
+ `Awaitable`-compatible). Useful for tests or a long-lived process that wants
56
+ hits to persist across repeated runs without touching disk.
57
+
58
+ ## `CacheStore`
59
+
60
+ ```ts
61
+ interface CacheStore {
62
+ read(slot: string): Awaitable<CachedEntry | undefined>;
63
+ write(slot: string, entry: CachedEntry): Awaitable<void>;
64
+ clear(slot: string): Awaitable<void>;
65
+ }
66
+ ```
67
+
68
+ `Awaitable<T>` is `T | Promise<T>` — each method may be sync or async. `slot`
69
+ is derived from a phase's or step's own name and passed in by the script; a
70
+ store never generates or interprets it. A custom backing store (Redis, S3,
71
+ ...) only needs these three methods.
72
+
73
+ Slot naming, computed internally (never passed by script authors):
74
+
75
+ ```ts
76
+ const phaseSlot = (phase: string): string => phase;
77
+ const stepSlot = (phase: string, step: string): string => `${phase}::${step}`;
78
+ ```
79
+
80
+ ## `CachedEntry`
81
+
82
+ ```ts
83
+ interface CachedEntry {
84
+ value: unknown;
85
+ savedAt: number; // Date.now() at the moment it was written
86
+ }
87
+ ```
88
+
89
+ The exact shape a `CacheStore` reads and writes — one stored result plus its
90
+ write timestamp.
91
+
92
+ ## `CacheSource<In, Ctx, Value>`
93
+
94
+ ```ts
95
+ type CacheSource<In = unknown, Ctx = unknown, Value = unknown> =
96
+ | CacheStore
97
+ | CacheOptions<In, Ctx, Value>;
98
+ ```
99
+
100
+ What `cache` accepts on a phase or a step. `normalizeCache` in `src/cache.ts`
101
+ detects a bare `CacheStore` by duck-typing a `read` function on it and
102
+ expands it to `{ store: source }`; anything else is passed through as
103
+ `CacheOptions` unchanged.
104
+
105
+ ## `CacheOptions<In, Ctx, Value>`
106
+
107
+ ```ts
108
+ interface CacheOptions<In = unknown, Ctx = unknown, Value = unknown> {
109
+ store: CacheStore;
110
+ stale?: (context: StaleContext<In, Ctx>) => Awaitable<boolean>;
111
+ schema?: StandardSchemaV1<unknown, Value>;
112
+ }
113
+ ```
114
+
115
+ - `store` is required; `stale` and `schema` are both optional.
116
+ - `stale` returning `true` treats a stored entry as a miss — the work runs
117
+ again and the entry is overwritten on success.
118
+ - `schema`, when given, validates the stored value on every read via
119
+ `schema["~standard"].validate(value)`; a value with `issues` is also a
120
+ miss, never a thrown error. This is what stops a cache written by an older
121
+ version of the script from feeding the wrong shape into the context.
122
+ - When no `schema` is set, the reads-back type is instead the phase's or
123
+ step's own inferred delta type (see `SlotValue` below).
124
+
125
+ ### `StaleContext<In, Ctx>`
126
+
127
+ ```ts
128
+ interface StaleContext<In, Ctx> {
129
+ value: unknown;
130
+ input: In;
131
+ ctx: Ctx; // the live context as it stands when the phase/step is reached
132
+ savedAt: number;
133
+ ageMs: number;
134
+ }
135
+ ```
136
+
137
+ `value` always arrives as `unknown` inside `stale` — annotate it yourself,
138
+ or supply `schema` to get a checked, narrowed value on the `read` path
139
+ instead. `ageMs` is `Math.max(0, Date.now() - entry.savedAt)`, so it is never
140
+ negative even with clock skew.
141
+
142
+ ## `CacheMode`
143
+
144
+ ```ts
145
+ type CacheMode = "on" | "off" | "refresh" | "read-only";
146
+ ```
147
+
148
+ Passed as `run(input, { cache })`. Default is `"on"`.
149
+
150
+ | Mode | read | write |
151
+ | --- | --- | --- |
152
+ | `"on"` | yes | yes |
153
+ | `"off"` | no (`readCache` short-circuits to a miss) | no |
154
+ | `"refresh"` | no (forces every entry to recompute) | yes (overwrites) |
155
+ | `"read-only"` | yes | no |
156
+
157
+ Implementation detail from `src/cache.ts`:
158
+
159
+ ```ts
160
+ export async function readCache(cache, slot, input, ctx, mode) {
161
+ if (mode === "off" || mode === "refresh") return MISS;
162
+ // ...store.read, schema validate, stale check...
163
+ }
164
+
165
+ export async function writeCache(cache, slot, value, mode) {
166
+ if (mode === "off" || mode === "read-only") return;
167
+ await cache.store.write(slot, { value, savedAt: Date.now() });
168
+ }
169
+ ```
170
+
171
+ A store or `stale`/`schema` call that throws is caught elsewhere in
172
+ `script.ts` and treated as a miss with a logged warning — caching never
173
+ decides whether the run itself passes or fails.
174
+
175
+ ## `CacheHandle<Slots>`
176
+
177
+ ```ts
178
+ interface CacheHandle<Slots> {
179
+ read<K extends keyof Slots & string>(
180
+ slot: K,
181
+ options?: { raw?: boolean },
182
+ ): Promise<Slots[K] | undefined>;
183
+ write<K extends keyof Slots & string>(
184
+ slot: K,
185
+ value: unknown,
186
+ options?: { keepAge?: boolean },
187
+ ): Promise<void>;
188
+ clear(slot: keyof Slots & string): Promise<void>;
189
+ ageOf(slot: keyof Slots & string): Promise<number | undefined>;
190
+ }
191
+ ```
192
+
193
+ This is the `cache` property on both `StepContext` and `RollbackContext`
194
+ (`readonly cache: CacheHandle<Slots>`), typed to exactly the cached phases
195
+ and steps declared *before* the current one in the script. An uncached phase
196
+ contributes no slot at all.
197
+
198
+ Runtime behavior (`createCacheHandle` in `src/cache.ts`), built from a
199
+ `Map<string, SlotBinding>` where `SlotBinding = { store: CacheStore; schema: boolean }`:
200
+
201
+ - **`read(slot, options?)`**: looks up the slot's binding — a slot the script
202
+ never declared throws `StepDefinitionError` at runtime (listing the known
203
+ slot names), though this case is normally already excluded by the types.
204
+ On a store miss, returns `undefined`. On a hit:
205
+ - if `options.raw` is true, or the slot declared a `schema` (already
206
+ checked on the way in), returns `entry.value` as-is.
207
+ - otherwise wraps it with `guardShape(entry.value, slot)` (see below).
208
+ - **`write(slot, value, options?)`**: if `options.keepAge` is true, first
209
+ reads the existing entry and reuses its `savedAt`; otherwise stamps
210
+ `savedAt: Date.now()`. Always writes `{ value, savedAt }` to the slot's
211
+ store — `write` does not merge with the previous value, it replaces it.
212
+ - **`clear(slot)`**: delegates straight to the slot's `store.clear(slot)`.
213
+ - **`ageOf(slot)`**: reads the entry and returns
214
+ `Math.max(0, Date.now() - entry.savedAt)`, or `undefined` if there is no
215
+ entry.
216
+
217
+ Invalidation flows backward only: a later step can drop or correct an
218
+ earlier phase's entry, never the reverse — a phase's slot is only known to
219
+ the type system once that phase has actually been declared earlier in the
220
+ chain.
221
+
222
+ ## `SlotValue<Schema, Delta>`
223
+
224
+ ```ts
225
+ type SlotValue<Schema, Delta> = unknown extends Schema ? Delta : Schema;
226
+ ```
227
+
228
+ Type-level only, not called directly. Drives what `CacheHandle<Slots>`
229
+ infers a slot reads back as: the `schema`'s output type when one was
230
+ declared on that phase/step, otherwise the phase's (or step's) own inferred
231
+ delta type.
232
+
233
+ ## Shape guard: `guardShape` and `CacheShapeError`
234
+
235
+ ```ts
236
+ function guardShape<T>(value: T, slot: string, path?: string): T
237
+ ```
238
+
239
+ Wraps an object or array read back from a store (recursively, on further
240
+ property access) in a `Proxy` so that touching a field the stored object
241
+ does not actually have throws `CacheShapeError` instead of silently
242
+ returning `undefined`. Exists because, without a declared `schema`, the
243
+ type a slot reads back as is just the phase's/step's own inferred delta —
244
+ correct until an entry written by an older version of the script (with a
245
+ different shape) outlives the code that wrote it.
246
+
247
+ Mechanics:
248
+
249
+ - Only the `get` trap is installed — `set` (patching a field before writing
250
+ it back) is left alone, since assignment is how a value gets corrected
251
+ before a `cache.write`.
252
+ - A fixed passthrough set of property names is never guarded, because
253
+ runtime machinery probes them on arbitrary objects: `then` (so `await`
254
+ works), `toJSON`, `constructor`, `valueOf`, `toString`, `inspect`,
255
+ `length`. All symbol keys pass through too.
256
+ - An array index (`/^(0|[1-9]\d*)$/`) is treated as ordinary JS and never
257
+ throws even if out of bounds — `orders[9]` on a 3-item array is a normal
258
+ miss, not a shape violation.
259
+ - Any other key throws `CacheShapeError` unless it is present via
260
+ `Object.hasOwn(target, key)` or found on the prototype chain
261
+ (`key in (Object.getPrototypeOf(target) ?? {})`).
262
+ - On a legal access, the returned value is itself wrapped again with
263
+ `guardShape`, carrying forward a dotted `path` (e.g. `"orders.0.date"`) so
264
+ the eventual `CacheShapeError` names the exact field.
265
+ - A slot with a declared `schema` skips this wrapping entirely on `read`
266
+ (`createCacheHandle`'s `options?.raw || binding.schema` check) — the
267
+ schema validation on the way in already guarantees the shape.
268
+
269
+ Where the throw actually happens matters: because the guard is a live Proxy,
270
+ it travels with the value into `ctx` if you return it from a handler without
271
+ `{ raw: true }`. The `CacheShapeError` then throws wherever a later step
272
+ first touches the missing field — not at the original `cache.read()` call
273
+ site.
274
+
275
+ `CacheShapeError` (from `src/errors.ts`, re-exported from the package root)
276
+ carries the slot name and the offending dotted path; its message names both
277
+ and suggests either `cache.clear(slot)` or adding a `schema` to reject
278
+ mismatches on read instead of throwing downstream.