@michaelrwalker/stagehand 0.8.0 → 0.10.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 (41) hide show
  1. package/README.md +74 -7
  2. package/dist/cache.d.ts +11 -2
  3. package/dist/cache.d.ts.map +1 -1
  4. package/dist/cache.js +37 -5
  5. package/dist/cache.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/script.d.ts +27 -1
  10. package/dist/script.d.ts.map +1 -1
  11. package/dist/script.js +58 -9
  12. package/dist/script.js.map +1 -1
  13. package/dist/state.d.ts +7 -1
  14. package/dist/state.d.ts.map +1 -1
  15. package/dist/state.js.map +1 -1
  16. package/dist/types.d.ts +54 -3
  17. package/dist/types.d.ts.map +1 -1
  18. package/dist/ui/frame.d.ts.map +1 -1
  19. package/dist/ui/frame.js +7 -3
  20. package/dist/ui/frame.js.map +1 -1
  21. package/dist/ui/renderer.d.ts +2 -0
  22. package/dist/ui/renderer.d.ts.map +1 -1
  23. package/dist/ui/renderer.js +11 -1
  24. package/dist/ui/renderer.js.map +1 -1
  25. package/package.json +1 -1
  26. package/skills/building-scripts/branching-steps/SKILL.md +144 -0
  27. package/skills/building-scripts/command-line-flags/SKILL.md +115 -0
  28. package/skills/building-scripts/getting-started/SKILL.md +1 -1
  29. package/skills/building-scripts/prompting-the-user/SKILL.md +91 -0
  30. package/skills/building-scripts/structuring-phases-and-steps/SKILL.md +5 -1
  31. package/skills/building-scripts/typed-context-and-input/SKILL.md +1 -1
  32. package/skills/caching/caching-expensive-work/SKILL.md +66 -8
  33. package/skills/caching/caching-expensive-work/references/cache-api.md +17 -7
  34. package/skills/compensation-safety/adding-rollbacks/SKILL.md +3 -1
  35. package/skills/compensation-safety/cleaning-context/SKILL.md +1 -1
  36. package/skills/compensation-safety/conditional-steps/SKILL.md +19 -3
  37. package/skills/errors-results/handling-results-and-errors/SKILL.md +19 -4
  38. package/skills/observability/log-placement-and-rendering/SKILL.md +1 -1
  39. package/skills/observability/reporting-progress/SKILL.md +3 -1
  40. package/skills/reuse/reusable-routines-and-mounts/SKILL.md +3 -3
  41. package/skills/reuse/splitting-steps-across-files/SKILL.md +1 -1
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: 'branching-steps'
3
+ description: >
4
+ Covers `addBranch` — a step that runs `onTrue` or `onFalse` depending on `condition`, with both
5
+ arms required to return the same shape so `ctx` stays accurately typed whichever one ran. Load this
6
+ for "an override flag changes how a value is produced but later steps need it either way," "do X or
7
+ Y and keep the context typed," or when `when` skipped a step and a later step crashed on a missing
8
+ `ctx` key.
9
+ metadata:
10
+ type: 'core'
11
+ library: 'stagehand'
12
+ library_version: '0.8.0'
13
+ sources:
14
+ - 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
15
+ - 'miaklwalker/stagehand:docs/reference/script.md'
16
+ - 'miaklwalker/stagehand:src/script.ts'
17
+ - 'miaklwalker/stagehand:test/branch.test.ts'
18
+ ---
19
+
20
+ # Stagehand — Branching Steps
21
+
22
+ `addBranch` is a step with an if/else inside it. `condition` picks the arm, `onTrue` / `onFalse` do the work, and both return the same shape — so every later step sees one `ctx` type, no matter which arm ran. Use it instead of `when` whenever later steps depend on what the step produces.
23
+
24
+ ## Setup
25
+
26
+ ```ts
27
+ import { Script } from "@michaelrwalker/stagehand";
28
+
29
+ const result = await new Script<{ service: string }>({ name: "deploy" })
30
+ .defineFlag({ name: "sha" })
31
+ .addPhase("Resolve")
32
+ .addBranch({
33
+ name: "resolve commit",
34
+ condition: ({ flags }) => Boolean(flags.sha),
35
+ onTrue: ({ flags }) => ({ sha: flags.sha as string }),
36
+ onFalse: async ({ input }) => ({ sha: await git.head(input.service) }),
37
+ })
38
+ .addStep({
39
+ name: "build",
40
+ handler: ({ ctx }) => ({ image: `app:${ctx.sha}` }), // ctx.sha: string
41
+ })
42
+ .run({ service: "checkout-api" }, { argv: ["--sha", "abc1234"] });
43
+ ```
44
+
45
+ ## Core Patterns
46
+
47
+ ### Everything a handler gets, in all three functions
48
+
49
+ `condition`, `onTrue` and `onFalse` each receive the normal step context — `input`, `ctx`, `flags`, `status`, `note`, `log`, `prompt`, `signal`. `condition` may be async and runs once per attempt; only the chosen arm executes.
50
+
51
+ ```ts
52
+ .addBranch({
53
+ name: "pick target",
54
+ condition: async ({ ctx }) => await isReachable(ctx.host),
55
+ onTrue: ({ ctx }) => ({ target: ctx.host }),
56
+ onFalse: async ({ prompt }) => ({ target: await prompt.text({ message: "fallback host?", default: "localhost" }) }),
57
+ })
58
+ ```
59
+
60
+ ### Retry and timeout cover the whole branch
61
+
62
+ ```ts
63
+ .addBranch({
64
+ name: "resolve commit",
65
+ retry: { attempts: 3, delayMs: 250 },
66
+ timeoutMs: 10_000,
67
+ condition: ({ flags }) => Boolean(flags.sha),
68
+ onTrue: ({ flags }) => ({ sha: flags.sha as string }),
69
+ onFalse: async ({ input }) => ({ sha: await git.head(input.service) }),
70
+ })
71
+ ```
72
+
73
+ A retry re-evaluates `condition` and re-runs the arm. Those two options, plus `name` and `description`, are the whole surface.
74
+
75
+ ### Caching a branch: cache the phase
76
+
77
+ ```ts
78
+ .addPhase("Resolve", { cache: store })
79
+ .addBranch({ name: "resolve commit", /* ... */ })
80
+ ```
81
+
82
+ A branch has no `cache` of its own. A cached phase stores the merged output of every step in it, branch included.
83
+
84
+ ## Common Mistakes
85
+
86
+ ### HIGH Using `when` where later steps need the output
87
+
88
+ Wrong:
89
+ ```ts
90
+ .addStep({ name: "head sha", when: ({ flags }) => !flags.sha, handler: async () => ({ sha: await git.head() }) })
91
+ .addStep({ name: "build", handler: ({ ctx }) => ctx.sha.slice(0, 7) }) // compiles; crashes with --sha set
92
+ ```
93
+
94
+ Correct:
95
+ ```ts
96
+ .addBranch({
97
+ name: "resolve commit",
98
+ condition: ({ flags }) => Boolean(flags.sha),
99
+ onTrue: ({ flags }) => ({ sha: flags.sha as string }),
100
+ onFalse: async () => ({ sha: await git.head() }),
101
+ })
102
+ ```
103
+
104
+ `when` skips the step but `ctx` is still typed as though it ran. A branch always produces the keys.
105
+
106
+ ### MEDIUM Arms returning different shapes
107
+
108
+ Wrong:
109
+ ```ts
110
+ onTrue: () => ({ sha: "abc" }),
111
+ onFalse: () => ({ commit: "abc" }), // compile error on onFalse
112
+ ```
113
+
114
+ Correct:
115
+ ```ts
116
+ onTrue: () => ({ sha: "abc" }),
117
+ onFalse: () => ({ sha: "abc" }),
118
+ ```
119
+
120
+ `onTrue`'s return type fixes `Out`; `onFalse` must be assignable to it. If one path has no value, return an explicit default (`{ sha: undefined }` on both arms, or `sha: string | undefined` in `onTrue`) rather than omitting the key.
121
+
122
+ ### MEDIUM Expecting `rollback`, `cache`, `clean` or `when` on a branch
123
+
124
+ Wrong:
125
+ ```ts
126
+ .addBranch({ name: "b", condition, onTrue, onFalse, rollback: async () => {} }) // not accepted
127
+ ```
128
+
129
+ Correct:
130
+ ```ts
131
+ // If the work needs compensation, make it a plain addStep with its own rollback
132
+ // and branch inside the handler; cache the enclosing phase; clean in a later step.
133
+ .addStep({
134
+ name: "provision",
135
+ handler: async ({ input }) => ({ id: input.existingId ?? (await create()) }),
136
+ rollback: async ({ output }) => destroy(output.id),
137
+ })
138
+ ```
139
+
140
+ A branch is intentionally minimal: no compensation, no step-level cache, no `clean`, no `when`.
141
+
142
+ Source: docs/guides/phases-and-steps.md; src/types.ts `BranchDef`
143
+
144
+ See also: skills/compensation-safety/conditional-steps/SKILL.md — `when` is for work that may simply not happen; `addBranch` is for work that always happens in one of two ways.
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: 'command-line-flags'
3
+ description: >
4
+ Covers `defineFlag` and `context.flags` — typed string and boolean flags parsed from argv once before
5
+ any phase runs, `long`/`short` aliases, defaults, `UnknownFlagError` / `MissingFlagValueError`,
6
+ `run(input, { argv })` for tests, and mapping a flag onto a mounted routine's input. Load this for
7
+ "add a --force or --env flag," "gate a step on a CLI flag," or "test a script with flags."
8
+ metadata:
9
+ type: 'core'
10
+ library: 'stagehand'
11
+ library_version: '0.8.0'
12
+ sources:
13
+ - 'miaklwalker/stagehand:docs/guides/flags.md'
14
+ - 'miaklwalker/stagehand:docs/reference/script.md'
15
+ - 'miaklwalker/stagehand:src/flags.ts'
16
+ - 'miaklwalker/stagehand:src/script.ts'
17
+ - 'miaklwalker/stagehand:test/flags.test.ts'
18
+ ---
19
+
20
+ # Stagehand — Command-Line Flags
21
+
22
+ `defineFlag` declares a flag. `run()` parses `process.argv` once, before any phase, and every step, `when`, `stale` and rollback receives the result as `flags`, typed from the declarations.
23
+
24
+ ## Setup
25
+
26
+ ```ts
27
+ import { Script } from "@michaelrwalker/stagehand";
28
+
29
+ await new Script({ name: "deploy" })
30
+ .defineFlag({ name: "environment", long: "env", short: "e", default: "staging" })
31
+ .defineFlag({ name: "force", short: "f", boolean: true })
32
+ .addStep({
33
+ name: "ship",
34
+ handler: ({ flags }) => {
35
+ // flags.environment: string flags.force: boolean
36
+ return { env: flags.environment };
37
+ },
38
+ })
39
+ .run();
40
+ // myScript --env production -f
41
+ ```
42
+
43
+ ## Core Patterns
44
+
45
+ ### String vs boolean
46
+
47
+ A string flag with a `default` is `string`; without one it is `string | undefined`. A boolean flag (`boolean: true`) is presence-based, never consumes the next token, and resolves to `default ?? false`. `--flag=value` and `--flag value` both work; `--force=false` is honoured.
48
+
49
+ ### Gate work on a flag
50
+
51
+ ```ts
52
+ .addPhase("Publish", { when: ({ flags }) => flags.production })
53
+ .addStep({ name: "notify", when: ({ flags }) => !flags.quiet, handler: async () => {} })
54
+ .addPhase("Build", { cache: { store, stale: ({ flags }) => flags.fresh } })
55
+ ```
56
+
57
+ When the flag changes *how* a value is produced rather than *whether* a step runs, use `addBranch` (see skills/building-scripts/branching-steps/SKILL.md).
58
+
59
+ ### Test with `argv`
60
+
61
+ ```ts
62
+ const result = await script.run({ service: "api" }, { argv: ["--env", "production"] });
63
+ ```
64
+
65
+ `argv` replaces `process.argv.slice(2)`; use it in tests and when the script is one subcommand of a bigger CLI.
66
+
67
+ ### Hand a flag to a routine
68
+
69
+ ```ts
70
+ new Script({ name: "sync" })
71
+ .defineFlag({ name: "environment", long: "env", default: "staging" })
72
+ .use(pullChannel, { input: ({ flags }) => ({ env: flags.environment }) })
73
+ ```
74
+
75
+ ## Common Mistakes
76
+
77
+ ### HIGH Declaring flags on a routine
78
+
79
+ Wrong:
80
+ ```ts
81
+ routineFor<{ env: string }>()("pull", (script) => script.defineFlag({ name: "env" })) // flags are top-level only
82
+ ```
83
+
84
+ Correct:
85
+ ```ts
86
+ // declare on the script you run; pass it down through use()'s input mapper
87
+ ```
88
+
89
+ `defineFlag` belongs to the entry script. A mounted script's flags are not merged into the host, and a routine's `flags` are typed loosely (`Record<string, unknown>`).
90
+
91
+ ### MEDIUM Expecting `run()` to return `{ ok: false }` for a bad flag
92
+
93
+ Wrong:
94
+ ```ts
95
+ const result = await script.run(undefined, { argv: ["--nope"] });
96
+ if (!result.ok) { /* never reached */ }
97
+ ```
98
+
99
+ Correct:
100
+ ```ts
101
+ try { await script.run(undefined, { argv }); }
102
+ catch (error) { if (error instanceof UnknownFlagError) console.error(error.flag); }
103
+ ```
104
+
105
+ `UnknownFlagError` and `MissingFlagValueError` are thrown directly, before any phase, and ignore `throwOnError`.
106
+
107
+ ### MEDIUM Assuming undeclared flags are always rejected
108
+
109
+ A script with **no** `defineFlag` call ignores argv entirely; the unknown-flag check starts at the first declaration. Adding a first flag can therefore break a script that read `process.argv` by hand.
110
+
111
+ ### LOW Duplicate names
112
+
113
+ `defineFlag` throws `DuplicateNameError` (`kind: "flag"`) if `name`, the resolved long form, or `short` is already used.
114
+
115
+ Source: docs/guides/flags.md; src/flags.ts
@@ -7,7 +7,7 @@ description: >
7
7
  metadata:
8
8
  type: 'lifecycle'
9
9
  library: 'stagehand'
10
- library_version: '0.5.3'
10
+ library_version: '0.8.0'
11
11
  sources:
12
12
  - 'miaklwalker/stagehand:README.md'
13
13
  - 'miaklwalker/stagehand:docs/quick-start.md'
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: 'prompting-the-user'
3
+ description: >
4
+ Covers `context.prompt` — `text`, `confirm`, `select` and `multiselect` asked mid-step, the
5
+ non-interactive `default` fallback, `PromptUnavailableError`, and Ctrl-C cancellation via
6
+ `PromptCancelledError`. Load this for "ask for confirmation before a destructive step," "let the
7
+ user pick an environment," or "make an interactive script runnable in CI."
8
+ metadata:
9
+ type: 'core'
10
+ library: 'stagehand'
11
+ library_version: '0.8.0'
12
+ sources:
13
+ - 'miaklwalker/stagehand:docs/guides/prompts.md'
14
+ - 'miaklwalker/stagehand:docs/reference/errors.md'
15
+ - 'miaklwalker/stagehand:src/ui/prompt.ts'
16
+ - 'miaklwalker/stagehand:test/prompt.test.ts'
17
+ ---
18
+
19
+ # Stagehand — Prompting the User
20
+
21
+ `prompt` is on a step handler's context. Calling it suspends the live frame, draws the question, and repaints once answered. It is hand-rolled on the renderer's ANSI primitives, so there is no extra dependency.
22
+
23
+ ## Setup
24
+
25
+ ```ts
26
+ .addStep({
27
+ name: "confirm production",
28
+ handler: async ({ flags, prompt }) => {
29
+ if (flags.environment === "production" && !flags.force) {
30
+ const ok = await prompt.confirm({ message: "Deploy to production?", default: false });
31
+ if (!ok) throw new Error("cancelled by user");
32
+ }
33
+ return {};
34
+ },
35
+ })
36
+ ```
37
+
38
+ ## Core Patterns
39
+
40
+ | method | resolves to |
41
+ | --- | --- |
42
+ | `prompt.text({ message, default?, placeholder?, validate?, mask? })` | `string` |
43
+ | `prompt.confirm({ message, default? })` | `boolean` |
44
+ | `prompt.select({ message, choices, default? })` | the chosen `value` |
45
+ | `prompt.multiselect({ message, choices, default?, min?, max? })` | `value[]` |
46
+
47
+ `choices` are `{ label, value, hint? }`. `validate` returns `true` or an error string, may be async. `mask: true` echoes `•`.
48
+
49
+ ### Always give a `default` if the script may run unattended
50
+
51
+ With no TTY (CI, a pipe) nothing is drawn: a `default` is returned immediately, and a missing one throws `PromptUnavailableError`.
52
+
53
+ ### Prefer a flag, prompt only as the fallback
54
+
55
+ ```ts
56
+ const environment = flags.environment ?? (await prompt.select({ message: "environment?", choices, default: "staging" }));
57
+ ```
58
+
59
+ ## Common Mistakes
60
+
61
+ ### HIGH Prompting without a `default` in a script CI runs
62
+
63
+ Wrong:
64
+ ```ts
65
+ const tag = await prompt.text({ message: "release tag?" }); // PromptUnavailableError under CI
66
+ ```
67
+
68
+ Correct:
69
+ ```ts
70
+ const tag = await prompt.text({ message: "release tag?", default: "latest" });
71
+ ```
72
+
73
+ ### MEDIUM Treating Ctrl-C at a prompt as a handler failure
74
+
75
+ Wrong:
76
+ ```ts
77
+ if (!result.ok) report(result.error); // reports a cancellation as a bug
78
+ ```
79
+
80
+ Correct:
81
+ ```ts
82
+ if (!result.ok && isAbort(result.error)) { /* result.status === "aborted"; rollback policy applied */ }
83
+ ```
84
+
85
+ `PromptCancelledError` is recognised by `isAbort`, so the run unwinds exactly like a SIGINT.
86
+
87
+ ### MEDIUM Prompting from a rollback
88
+
89
+ `prompt` exists only on `StepContext`. A rollback runs during an unwind and cannot stop to ask; decide inside the step and keep what the rollback needs in `rollbackKeys`.
90
+
91
+ Source: docs/guides/prompts.md; src/ui/prompt.ts
@@ -5,7 +5,7 @@ description: >
5
5
  metadata:
6
6
  type: 'core'
7
7
  library: 'stagehand'
8
- library_version: '0.5.3'
8
+ library_version: '0.8.0'
9
9
  sources:
10
10
  - 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
11
11
  - 'miaklwalker/stagehand:docs/guides/script-options-and-result.md'
@@ -60,6 +60,10 @@ new Script<{ n: number }>({ name: "t" })
60
60
 
61
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
62
 
63
+ ### Two ways to do one step: `addBranch`
64
+
65
+ `addBranch({ name, condition, onTrue, onFalse })` runs one of two arms that return the same shape, so the context stays typed either way. It takes `retry` and `timeoutMs` like any step, but no `rollback`, `cache` or `clean`. See skills/building-scripts/branching-steps/SKILL.md.
66
+
63
67
  ### Retry with backoff and veto specific errors
64
68
 
65
69
  ```ts
@@ -10,7 +10,7 @@ description: >
10
10
  metadata:
11
11
  type: 'core'
12
12
  library: 'stagehand'
13
- library_version: '0.5.3'
13
+ library_version: '0.8.0'
14
14
  sources:
15
15
  - 'miaklwalker/stagehand:docs/guides/typed-context.md'
16
16
  - 'miaklwalker/stagehand:docs/reference/errors.md'
@@ -1,16 +1,17 @@
1
1
  ---
2
2
  name: 'caching-expensive-work'
3
3
  description: >
4
- fileStore/memoryStore, per-slot stale/schema options, per-run CacheMode
4
+ fileStore/memoryStore, per-slot key/stale/schema options, per-run CacheMode
5
5
  ("on"/"off"/"refresh"/"read-only"), and reaching a cached slot with
6
6
  context.cache (read/write/clear/ageOf) from a later step or rollback. Load
7
7
  this for "skip re-running an expensive phase," "invalidate a cache entry
8
8
  from a later step," "correct a cached value without resetting its TTL," or
9
- "guard a cache entry against an old script shape."
9
+ "guard a cache entry against an old script shape," or "rerun a step only
10
+ when its input changed since last time."
10
11
  metadata:
11
12
  type: 'core'
12
13
  library: 'stagehand'
13
- library_version: '0.5.3'
14
+ library_version: '0.8.0'
14
15
  sources:
15
16
  - 'miaklwalker/stagehand:docs/guides/caching.md'
16
17
  - 'miaklwalker/stagehand:docs/reference/cache.md'
@@ -23,9 +24,9 @@ sources:
23
24
  # Stagehand — Caching Expensive Work
24
25
 
25
26
  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.
27
+ runs. A slot's name is derived from the phase's or step's own name, and it
28
+ holds one entry. Whether that entry can be reused is up to `key` (was it
29
+ computed for the same thing?) and `stale` (is it still fresh?).
29
30
 
30
31
  ## Setup
31
32
 
@@ -71,16 +72,32 @@ const BuildSchema = z.object({ artifact: z.string() });
71
72
  name: "compile",
72
73
  cache: {
73
74
  store: cache,
74
- stale: ({ value, ctx, ageMs }) => ageMs > 3_600_000,
75
+ stale: ({ value, ctx, flags, ageMs }) => flags.fresh || ageMs > 3_600_000,
75
76
  schema: BuildSchema,
76
77
  },
77
78
  handler: async ({ input }) => ({ artifact: `dist/${input.ref}.tgz` }),
78
79
  })
79
80
  ```
80
81
 
81
- `stale` and `schema` both see the live context as of that step; a stored
82
+ `stale` and `schema` both see the live context as of that step (`stale` also gets `flags`, so a `--fresh` flag can force a miss); a stored
82
83
  value that fails `schema` is treated as a miss, not an error.
83
84
 
85
+ ### Rerunning only when the input changed, with `key`
86
+
87
+ ```ts
88
+ .addStep({
89
+ name: "price",
90
+ cache: { store: cache, key: ({ ctx }) => [...ctx.skus].sort().join(",") },
91
+ handler: async ({ ctx }) => ({ prices: await priceAll(ctx.skus) }),
92
+ })
93
+ ```
94
+
95
+ `key` returns a string, stored with the entry and compared with `===` next
96
+ run. Same key hits; a different key — or an entry written before `key` was
97
+ added — reruns and overwrites, reported as `cacheMiss: "key changed"` on the
98
+ `StepReport`. There is one entry, not one per key. The function owns
99
+ equality: sort when order doesn't matter, serialise objects deliberately.
100
+
84
101
  ### Controlling caching per run without touching the script
85
102
 
86
103
  ```ts
@@ -238,6 +255,47 @@ A store that throws — an unreadable file, a full disk, a buggy `stale` predica
238
255
 
239
256
  Source: docs/guides/caching.md ("A store that throws... logs a warning and does the work")
240
257
 
258
+ ### HIGH Encoding a cache key through `stale` and a returned field
259
+
260
+ Wrong:
261
+ ```ts
262
+ .addStep({
263
+ name: "price",
264
+ cache: { store, stale: ({ value, ctx }) => (value as { sku: string }).sku !== ctx.sku },
265
+ handler: async ({ ctx }) => ({ sku: ctx.sku, price: await price(ctx.sku) }), // sku leaks into ctx
266
+ })
267
+ ```
268
+
269
+ Correct:
270
+ ```ts
271
+ .addStep({
272
+ name: "price",
273
+ cache: { store, key: ({ ctx }) => ctx.sku },
274
+ handler: async ({ ctx }) => ({ price: await price(ctx.sku) }),
275
+ })
276
+ ```
277
+
278
+ `key` stores the fingerprint beside the entry instead of inside the value, so the handler's output — and the context type downstream — carries only what later steps need.
279
+
280
+ Source: README.md ("Caching"); src/cache.ts readCache()
281
+
282
+ ### MEDIUM Returning a non-canonical key
283
+
284
+ Wrong:
285
+ ```ts
286
+ cache: { store, key: ({ ctx }) => ctx.skus.join(",") } // API order varies run to run
287
+ cache: { store, key: ({ ctx }) => JSON.stringify(ctx.filter) } // object key order varies too
288
+ ```
289
+
290
+ Correct:
291
+ ```ts
292
+ cache: { store, key: ({ ctx }) => [...ctx.skus].sort().join(",") }
293
+ ```
294
+
295
+ Keys are compared with `===` and the library never normalises them, so two orderings of the same data are two keys — a needless miss every time the order shifts.
296
+
297
+ Source: README.md ("Caching")
298
+
241
299
  ### MEDIUM Patching a value with `cache.write` and losing its age
242
300
 
243
301
  Wrong:
@@ -83,11 +83,12 @@ const stepSlot = (phase: string, step: string): string => `${phase}::${step}`;
83
83
  interface CachedEntry {
84
84
  value: unknown;
85
85
  savedAt: number; // Date.now() at the moment it was written
86
+ key?: string; // what `key` returned on the run that wrote it, if declared
86
87
  }
87
88
  ```
88
89
 
89
90
  The exact shape a `CacheStore` reads and writes — one stored result plus its
90
- write timestamp.
91
+ write timestamp, and the key it was computed for when the slot declares one.
91
92
 
92
93
  ## `CacheSource<In, Ctx, Value>`
93
94
 
@@ -107,12 +108,21 @@ expands it to `{ store: source }`; anything else is passed through as
107
108
  ```ts
108
109
  interface CacheOptions<In = unknown, Ctx = unknown, Value = unknown> {
109
110
  store: CacheStore;
111
+ key?: (context: KeyContext<In, Ctx>) => Awaitable<string>;
110
112
  stale?: (context: StaleContext<In, Ctx>) => Awaitable<boolean>;
111
113
  schema?: StandardSchemaV1<unknown, Value>;
112
114
  }
113
115
  ```
114
116
 
115
- - `store` is required; `stale` and `schema` are both optional.
117
+ - `store` is required; `key`, `stale` and `schema` are all optional.
118
+ - `key` is evaluated once, against `{ input, ctx, flags }` as they stand
119
+ entering the phase or step, before the read. Its string is compared with
120
+ `===` against the stored entry's `key`; a mismatch (including an entry with
121
+ no key) is a miss with reason `"key changed"`, and the write that follows
122
+ stores the new key. There is still one entry per slot — it is not a memo
123
+ table. A `key` that throws or returns a non-string logs a warning, runs the
124
+ work, and neither reads nor writes the slot. `run(…, { cache: "off" })`
125
+ never calls it.
116
126
  - `stale` returning `true` treats a stored entry as a miss — the work runs
117
127
  again and the entry is overwritten on success.
118
128
  - `schema`, when given, validates the stored value on every read via
@@ -157,18 +167,18 @@ Passed as `run(input, { cache })`. Default is `"on"`.
157
167
  Implementation detail from `src/cache.ts`:
158
168
 
159
169
  ```ts
160
- export async function readCache(cache, slot, input, ctx, mode) {
170
+ export async function readCache(cache, slot, key, input, ctx, mode, flags) {
161
171
  if (mode === "off" || mode === "refresh") return MISS;
162
- // ...store.read, schema validate, stale check...
172
+ // ...store.read, key compare, schema validate, stale check...
163
173
  }
164
174
 
165
- export async function writeCache(cache, slot, value, mode) {
175
+ export async function writeCache(cache, slot, value, key, mode) {
166
176
  if (mode === "off" || mode === "read-only") return;
167
- await cache.store.write(slot, { value, savedAt: Date.now() });
177
+ await cache.store.write(slot, { value, savedAt: Date.now(), key });
168
178
  }
169
179
  ```
170
180
 
171
- A store or `stale`/`schema` call that throws is caught elsewhere in
181
+ A store or `key`/`stale`/`schema` call that throws is caught elsewhere in
172
182
  `script.ts` and treated as a miss with a logged warning — caching never
173
183
  decides whether the run itself passes or fails.
174
184
 
@@ -10,7 +10,7 @@ description: >
10
10
  metadata:
11
11
  type: 'core'
12
12
  library: 'stagehand'
13
- library_version: '0.5.3'
13
+ library_version: '0.8.0'
14
14
  sources:
15
15
  - 'miaklwalker/stagehand:docs/guides/rollbacks.md'
16
16
  - 'miaklwalker/stagehand:docs/reference/errors.md'
@@ -26,6 +26,8 @@ completed, first undone. The step that actually failed is never compensated: it
26
26
  there's nothing to undo. A rollback gets no context by default; it has to name every key it needs
27
27
  through `rollbackKeys`, which both narrows and permanently reserves them.
28
28
 
29
+ Rollback timing, retries and scope are specified exactly in docs/guides/error-handling.md (summary: skills/errors-results/handling-results-and-errors/SKILL.md, "The failure contract"). Notably, retries do not trigger rollback and the failed step is never rolled back.
30
+
29
31
  ## Setup
30
32
 
31
33
  ```ts
@@ -5,7 +5,7 @@ description: >
5
5
  metadata:
6
6
  type: 'core'
7
7
  library: 'stagehand'
8
- library_version: '0.5.3'
8
+ library_version: '0.8.0'
9
9
  sources:
10
10
  - 'miaklwalker/stagehand:docs/guides/cleaning-context.md'
11
11
  - 'miaklwalker/stagehand:src/script.ts'
@@ -8,16 +8,17 @@ description: >
8
8
  metadata:
9
9
  type: 'core'
10
10
  library: 'stagehand'
11
- library_version: '0.5.3'
11
+ library_version: '0.8.0'
12
12
  sources:
13
13
  - 'miaklwalker/stagehand:docs/guides/phases-and-steps.md'
14
14
  - 'miaklwalker/stagehand:docs/guides/reusable-scripts.md'
15
+ - 'miaklwalker/stagehand:docs/guides/flags.md'
15
16
  - 'miaklwalker/stagehand:src/script.ts'
16
17
  ---
17
18
 
18
19
  # Stagehand — Conditional Steps and Phases
19
20
 
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
+ `when` on `addStep` or `addPhase` is a function of `{ input, ctx, flags }` (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
 
22
23
  ## Setup
23
24
 
@@ -58,7 +59,7 @@ new Script<{ environment: "development" | "production" }>({ name: "deploy" })
58
59
  });
59
60
  ```
60
61
 
61
- `when` sees the same `{ input, ctx }` shape a handler does, so it can read anything an earlier step already put in context.
62
+ `when` sees the same `{ input, ctx, flags }` a handler does, so it can read anything an earlier step already put in context, and any `defineFlag` value (`when: ({ flags }) => !flags.quiet`).
62
63
 
63
64
  ### Gate an entire phase
64
65
 
@@ -87,6 +88,21 @@ A falsy phase-level `when` marks every step in `"Release"` `"skipped"` in one sh
87
88
 
88
89
  `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
 
91
+ ### Need the output either way? Use `addBranch`
92
+
93
+ A skipped step produces nothing, but `ctx` is still typed as though it ran. When later steps depend on the value, pick between two ways of producing it instead:
94
+
95
+ ```ts
96
+ .addBranch({
97
+ name: "resolve commit",
98
+ condition: ({ flags }) => Boolean(flags.sha),
99
+ onTrue: ({ flags }) => ({ sha: flags.sha as string }),
100
+ onFalse: async () => ({ sha: await git.head() }),
101
+ })
102
+ ```
103
+
104
+ See skills/building-scripts/branching-steps/SKILL.md.
105
+
90
106
  ## Common Mistakes
91
107
 
92
108
  ### MEDIUM Skipped step's `clean` still runs