diff --git a/.github/workflows/record-docs.yml b/.github/workflows/record-docs.yml
new file mode 100644
index 0000000..0667c8a
--- /dev/null
+++ b/.github/workflows/record-docs.yml
@@ -0,0 +1,44 @@
+# Copyright 2026 Undermountain Coding Company
+# SPDX-License-Identifier: Apache-2.0
+
+name: record-docs
+
+# Regenerate the documentation asciicast recordings on demand.
+#
+# GATED to manual dispatch on purpose — same posture as docs.yml. virtui is an
+# early-stage (v0.2.0) recorder, so it is deliberately NOT wired into the
+# required `ci` gate: a flaky upstream must never be able to block `main`.
+# Run this when a demoed command's output changes; download the rendered
+# artifacts and commit the ones you want to embed.
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ record:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ - name: install virtui (recorder)
+ run: go install github.com/honeybadge-labs/virtui/cmd/virtui@latest
+ - name: install agg (.cast -> .gif renderer)
+ run: |
+ curl -fsSL -o /usr/local/bin/agg \
+ https://github.com/asciinema/agg/releases/latest/download/agg-x86_64-unknown-linux-gnu
+ chmod +x /usr/local/bin/agg
+ - name: record + render demos
+ run: scripts/record-docs.sh --render
+ - uses: actions/upload-artifact@v4
+ with:
+ name: doc-casts
+ path: |
+ docs/assets/casts/*.cast
+ docs/assets/casts/*.gif
+ docs/assets/casts/*.svg
+ if-no-files-found: warn
diff --git a/README.md b/README.md
index 039cdc8..43d63a1 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,11 @@ $ kubectl --context arn:aws:eks:us-east-1:…:cluster/prod delete ns payments
flip this pane: failsafe toggle
```
+
+
+ ↑ a real recording — failsafe explain against the shipped binary, regenerated by scripts/record-docs.sh
+
+
The only thing between a hijacked or careless agent and a wiped cluster is you, clicking
"allow" on the hundredth prompt — and that fails exactly when you're tired or moving fast.
**failsafe is the guard that doesn't get tired.**
diff --git a/docs/assets/casts/.gitignore b/docs/assets/casts/.gitignore
new file mode 100644
index 0000000..098d636
--- /dev/null
+++ b/docs/assets/casts/.gitignore
@@ -0,0 +1,3 @@
+# Rendered GIFs are heavy build artifacts — regenerate with scripts/record-docs.sh --render.
+# .cast (source) and .svg (embedded in docs) ARE committed.
+*.gif
diff --git a/docs/assets/casts/audit.cast b/docs/assets/casts/audit.cast
new file mode 100644
index 0000000..e0c871b
--- /dev/null
+++ b/docs/assets/casts/audit.cast
@@ -0,0 +1,29 @@
+{"version":2,"width":92,"height":22,"timestamp":1784331898}
+[0.005935, "o", "\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"]
+[0.006175, "o", "\u001b[?1034h"]
+[0.006191, "o", "bash-3.2$ "]
+[0.013003, "i", "export PS1='$ '; cd \"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/project\"; clear\r"]
+[0.013056, "o", "e"]
+[0.013092, "o", "xport PS1='$ '; "]
+[0.013099, "o", "cd"]
+[0.013206, "o", " \"/var/folders/12/mydd4m455zn9t"]
+[0.013214, "o", "__"]
+[0.013315, "o", "48syck4940000gn/T//fa"]
+[0.013368, "o", "ilsafe-do \rcs-"]
+[0.013432, "o", "home/project\"; clear\r\n"]
+[0.015773, "o", "\u001b[3J\u001b[H\u001b[2J"]
+[0.016056, "o", "$ "]
+[0.543033, "i", "failsafe audit\r"]
+[0.543163, "o", "f"]
+[0.543232, "o", "ailsafe audit\r\n"]
+[0.557055, "o", "Policy layers in effect at /var/folders/12/mydd4m455zn9t__48syck4940000gn/T/failsafe-docs-home/project:\r\n\r\n [bundled]\r\n"]
+[0.557309, "o", " bundled/aws.rego — 2 block rule(s)\r\n"]
+[0.557386, "o", " bundled/failsafe.rego — 2 block rule(s)\r\n"]
+[0.557536, "o", " bundled/git.rego — 0 block rule(s)\r\n"]
+[0.557630, "o", " bundled/helm.rego — 2 block rule(s)\r\n"]
+[0.557722, "o", " bundled/kubectl.rego — 1 block rule(s)\r\n"]
+[0.557803, "o", " bundled/terraform.rego — 2 block rule(s)\r\n\r\n9 block rules total, 0 allow_override.\r\n"]
+[0.558949, "o", "$ "]
+[2.585943, "i", "exit\r"]
+[2.586019, "o", "ex"]
+[2.586093, "o", "it\r\nexit\r\n"]
diff --git a/docs/assets/casts/audit.svg b/docs/assets/casts/audit.svg
new file mode 100644
index 0000000..9455ff9
--- /dev/null
+++ b/docs/assets/casts/audit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/assets/casts/explain-block.cast b/docs/assets/casts/explain-block.cast
new file mode 100644
index 0000000..04aa745
--- /dev/null
+++ b/docs/assets/casts/explain-block.cast
@@ -0,0 +1,33 @@
+{"version":2,"width":92,"height":22,"timestamp":1784331884}
+[0.007669, "o", "\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"]
+[0.010914, "o", "\u001b[?1034h"]
+[0.011090, "o", "bash-3.2$ "]
+[0.016704, "i", "export PS1='$ '; cd \"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/project\"; clear\r"]
+[0.016757, "o", "e"]
+[0.016839, "o", "xport PS1='$ '; cd \"/var/fol"]
+[0.016850, "o", "de"]
+[0.016962, "o", "rs/12/mydd4m455zn9t__48sy"]
+[0.017024, "o", "ck4940000gn/"]
+[0.017032, "o", "T"]
+[0.017127, "o", "//failsafe-do \rcs-hom"]
+[0.017198, "o", "e/project\"; clear\r\n"]
+[0.021285, "o", "\u001b[3J\u001b[H\u001b[2J"]
+[0.021574, "o", "$ "]
+[0.544513, "i", "failsafe explain \"kubectl --context arn:aws:eks:us-east-1:123456789012:cluster/prod delete ns payments\"\r"]
+[0.544583, "o", "f"]
+[0.544639, "o", "ailsafe explain \"k"]
+[0.544713, "o", "ubectl --context "]
+[0.544726, "o", "ar"]
+[0.544847, "o", "n:aws:eks:us-east-1:"]
+[0.544859, "o", "12"]
+[0.544984, "o", "3456789012:cluste"]
+[0.545062, "o", "r/prod de"]
+[0.545137, "o", "lete \r ns pa"]
+[0.545211, "o", "yments\"\r\n"]
+[0.848167, "o", "── call 1: kubectl ──\r\nVerb: delete\r\nFlags:\r\n"]
+[0.848216, "o", " context = arn:aws:eks:us-east-1:123456789012:cluster/prod\r\nPositional: ns payments\r\nEffective cwd: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T/failsafe-docs-home/project\r\nMode: enabled\r\nPolicy chain (6 modules at this cwd):\r\n [bundled] bundled/aws.rego\r\n [bundled] bundled/failsafe.rego\r\n [bundled] bundled/git.rego\r\n [bundled] bundled/helm.rego\r\n [bundled] bundled/kubectl.rego\r\n [bundled] bundled/terraform.rego\r\nDecision: BLOCK\r\nReason : kubectl delete blocked while failsafe is enabled\r\n"]
+[0.849408, "o", "$ "]
+[2.882566, "i", "exit\r"]
+[2.882745, "o", "e"]
+[2.882788, "o", "xit\r\n"]
+[2.882877, "o", "exit\r\n"]
diff --git a/docs/assets/casts/explain-block.svg b/docs/assets/casts/explain-block.svg
new file mode 100644
index 0000000..5caab03
--- /dev/null
+++ b/docs/assets/casts/explain-block.svg
@@ -0,0 +1 @@
+Thedefaultinteractiveshellisnowzsh.Toupdateyouraccounttousezsh,pleaserun`chsh-s/bin/zsh`.Formoredetails,pleasevisithttps://support.apple.com/kb/HT208050.bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-do$$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:123456789012:cluster/proddeletenspayments"──call1:kubectl──Verb:deleteFlags:context=arn:aws:eks:us-east-1:123456789012:cluster/prodPositional:nspaymentsEffectivecwd:/var/folders/12/mydd4m455zn9t__48syck4940000gn/T/failsafe-docs-home/projectMode:enabledPolicychain(6modulesatthiscwd):[bundled]bundled/aws.rego[bundled]bundled/failsafe.rego[bundled]bundled/git.rego[bundled]bundled/helm.rego[bundled]bundled/kubectl.rego[bundled]bundled/terraform.regoDecision:BLOCKReason:kubectldeleteblockedwhilefailsafeisenabled$exitbash-3.2$bash-3.2$ebash-3.2$exportPS1='$';cd"/var/folbash-3.2$exportPS1='$';cd"/var/foldebash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48sybash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/Tcs-homcs-home/project";clear$f$failsafeexplain"k$failsafeexplain"kubectl--context$failsafeexplain"kubectl--contextar$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:12$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:123456789012:cluste$failsafeexplain"kubectl--contextarn:aws:eks:us-east-1:123456789012:cluster/proddenspa$eexit
\ No newline at end of file
diff --git a/docs/assets/casts/mode-toggle.cast b/docs/assets/casts/mode-toggle.cast
new file mode 100644
index 0000000..76a3cb8
--- /dev/null
+++ b/docs/assets/casts/mode-toggle.cast
@@ -0,0 +1,43 @@
+{"version":2,"width":92,"height":22,"timestamp":1784331888}
+[0.005927, "o", "\r\nThe default interactive shell is now zsh.\r\nTo update your account to use zsh, please run `chsh -s /bin/zsh`.\r\nFor more details, please visit https://support.apple.com/kb/HT208050.\r\n"]
+[0.006178, "o", "\u001b[?1034h"]
+[0.006212, "o", "bash-3.2$ "]
+[0.014081, "i", "export PS1='$ '; cd \"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/project\"; clear\r"]
+[0.014288, "o", "e"]
+[0.014310, "o", "xport PS1='$"]
+[0.014358, "o", " '; cd \"/var/folde"]
+[0.014367, "o", "rs/"]
+[0.014445, "o", "12/mydd4m455zn9t__48sy"]
+[0.014452, "o", "ck"]
+[0.014535, "o", "4940000gn/T//failsa"]
+[0.014578, "o", "fe-do \rcs-home/"]
+[0.014585, "o", "pr"]
+[0.014589, "o", "oj"]
+[0.014664, "o", "ect\"; clear\r\n"]
+[0.016854, "o", "\u001b[3J\u001b[H\u001b[2J"]
+[0.017117, "o", "$ "]
+[0.539295, "i", "failsafe mode get\r"]
+[0.539375, "o", "f"]
+[0.539437, "o", "ailsafe mode get\r\n"]
+[0.550818, "o", "enabled\t(default; no source resolved)\r\n"]
+[0.551704, "o", "$ "]
+[2.583063, "i", "failsafe mode set disabled\r"]
+[2.583205, "o", "f"]
+[2.583281, "o", "ailsafe mode set disabled\r\n"]
+[2.598676, "o", "disabled\t(file: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)\r\n"]
+[2.599698, "o", "$ "]
+[4.614719, "i", "failsafe mode get\r"]
+[4.614865, "o", "f"]
+[4.614953, "o", "ailsafe mode get\r\n"]
+[4.629334, "o", "disabled\t(file: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)\r\n"]
+[4.630511, "o", "$ "]
+[7.160456, "i", "failsafe mode set enabled\r"]
+[7.160543, "o", "f"]
+[7.160604, "o", "ailsafe mode set e"]
+[7.160689, "o", "nabled\r\n"]
+[7.171934, "o", "enabled\t(file: /var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)\r\n"]
+[7.172671, "o", "$ "]
+[9.711063, "i", "exit\r"]
+[9.711225, "o", "e"]
+[9.711286, "o", "xit\r\n"]
+[9.711402, "o", "exit\r\n"]
diff --git a/docs/assets/casts/mode-toggle.svg b/docs/assets/casts/mode-toggle.svg
new file mode 100644
index 0000000..22dbb04
--- /dev/null
+++ b/docs/assets/casts/mode-toggle.svg
@@ -0,0 +1 @@
+Thedefaultinteractiveshellisnowzsh.Toupdateyouraccounttousezsh,pleaserun`chsh-s/bin/zsh`.Formoredetails,pleasevisithttps://support.apple.com/kb/HT208050.bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-do$$f$failsafemodegetenabled(default;nosourceresolved)$failsafemodesetdisableddisabled(file:/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)$failsafemodesetenabledenabled(file:/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsafe-docs-home/.claude/pane-mode/w0t1p0:E03E0B4D-6630-49D4-96E2-ADCE77DB2402)$exitbash-3.2$bash-3.2$ebash-3.2$exportPS1='$bash-3.2$exportPS1='$';cd"/var/foldebash-3.2$exportPS1='$';cd"/var/folders/bash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48sybash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syckbash-3.2$exportPS1='$';cd"/var/folders/12/mydd4m455zn9t__48syck4940000gn/T//failsacs-home/cs-home/prcs-home/projcs-home/project";clear$failsafemodesete$eexit
\ No newline at end of file
diff --git a/docs/demos/README.md b/docs/demos/README.md
new file mode 100644
index 0000000..4e67b43
--- /dev/null
+++ b/docs/demos/README.md
@@ -0,0 +1,73 @@
+
+
+# Documentation demos (asciicast automation)
+
+This directory documents the **recording** half of the docs flow. It pairs with
+the doc-validation harness (`test/docs/`):
+
+| Concern | Owner | What it guarantees |
+|---------|-------|--------------------|
+| The copy-paste snippets in `docs/` actually run against the real binary | `test/docs/` (bats) | correctness — snippets don't drift |
+| A demo *shows* those same commands running | `scripts/record-docs.sh` (virtui) | currency — the GIF/SVG matches today's binary |
+
+Both drive the **real `failsafe` binary** from the **same commands**, so prose,
+tests, and recordings stay in lock-step.
+
+## How it works
+
+`scripts/record-docs.sh` uses [virtui](https://github.com/honeybadge-labs/virtui)
+("Playwright for the terminal") to drive `failsafe` through a real PTY and record
+each session to [asciicast v2](https://docs.asciinema.org/manual/asciicast/v2/):
+
+1. Build `failsafe` into a throwaway bindir and put it on `PATH`.
+2. Point `$HOME` at a sandbox so demos never touch your real `~/.config/failsafe`
+ (same isolation trick as the harness's `helpers.bash`).
+3. Start the virtui daemon, then for each scenario spawn a recorded `bash`
+ session, type the documented command, and `--wait` for the expected screen
+ text before moving on (deterministic, no fixed `sleep`s).
+4. Write `docs/assets/casts/.cast`.
+5. Optionally render each `.cast` to an embeddable `.svg`/`.gif` (`--render`) —
+ GitHub markdown and MkDocs do **not** play asciicast inline.
+
+```bash
+scripts/record-docs.sh # record .cast files
+scripts/record-docs.sh --render # also render .svg (svg-term) or .gif (agg)
+```
+
+If `virtui` is not installed the script **skips cleanly** (exit 0) with an
+install hint — it never fails a build just because the recorder is absent, the
+same "missing tool ⇒ skip, never a vacuous pass" rule the harness uses.
+
+## Scenarios
+
+| Cast | Command | Documents |
+|------|---------|-----------|
+| `explain-block.cast` | `failsafe explain "kubectl … delete ns payments"` | tutorials/getting-started, README — the guard blocking a destructive command |
+| `mode-toggle.cast` | `failsafe mode get / set disabled / set enabled` | reference/modes, getting-started step 4 |
+| `audit.cast` | `failsafe audit` | how-to/repo-policy — the policy chain in force |
+
+Add a scenario by appending one `record_scenario` block in the script; keep the
+command byte-identical to the doc snippet it illustrates.
+
+## What is **not** automated (honest split)
+
+virtui is headless/VT100, so it can only record the **CLI** surface. The
+GUI-only surfaces — WezTerm toasts & `format-tab-title`, iTerm2's Python-runtime
+keybinding — are not recordable here and remain **manual**, exactly the
+STATIC / LIVE-MANUAL surfaces called out in `test/docs/REPORT.md`. This script
+automates the CLI documentation flow, not the *full* flow.
+
+## Requirements
+
+| Tool | Purpose | Install |
+|------|---------|---------|
+| [virtui](https://github.com/honeybadge-labs/virtui) | record the sessions | `go install github.com/honeybadge-labs/virtui/cmd/virtui@latest` |
+| [agg](https://github.com/asciinema/agg) | `.cast` → `.gif` (optional) | `brew install agg` |
+| [svg-term-cli](https://github.com/marionebl/svg-term-cli) | `.cast` → `.svg` (optional) | `npm i -g svg-term-cli` |
+
+> **Status:** prototype. virtui is early-stage (v0.2.0). The recorder is wired as
+> an **on-demand** tool (and a `workflow_dispatch`-only CI job), not a required
+> gate, so an unstable upstream can never block `main`.
diff --git a/docs/how-to/explain-a-command.md b/docs/how-to/explain-a-command.md
index 204843c..8f362e9 100644
--- a/docs/how-to/explain-a-command.md
+++ b/docs/how-to/explain-a-command.md
@@ -21,6 +21,10 @@ failsafe explain kubectl delete ns payments
Both forms are equivalent: the command is re-assembled and fed through the same shell parser the hook uses.
+
+
+*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh).*
+
## Reading the output
```
diff --git a/docs/how-to/repo-policy.md b/docs/how-to/repo-policy.md
index d309e55..5f4de1e 100644
--- a/docs/how-to/repo-policy.md
+++ b/docs/how-to/repo-policy.md
@@ -109,6 +109,12 @@ Decision: BLOCK
Reason : this repo: kubectl apply via CI only — submit a PR
```
+To see every layer that applies at a path at once — not just one command's verdict — run `failsafe audit`:
+
+
+
+*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh).*
+
## 5. Commit and review with teammates
`.failsafe.rego` is a first-class source file. Commit it, review it in PRs, and keep it next to the infra code it governs.
diff --git a/docs/reference/modes.md b/docs/reference/modes.md
index fd97917..3cfb776 100644
--- a/docs/reference/modes.md
+++ b/docs/reference/modes.md
@@ -18,6 +18,12 @@ The two mode values and the exact source-resolution chain that determines which
The `allow_override` mechanism is separate from mode: a repo policy can override a block at any mode. See the [bundled policies](bundled-policies.md) and [configuration](configuration.md) pages.
+Flipping the current pane between the two values with `mode get` / `mode set`:
+
+
+
+*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh).*
+
---
## Mode source chain
diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md
index a6f3822..cc5b77e 100644
--- a/docs/tutorials/getting-started.md
+++ b/docs/tutorials/getting-started.md
@@ -95,6 +95,12 @@ Reason : kubectl delete blocked while failsafe is enabled
!!! success "You just verified the guard"
failsafe parsed the command, identified the tool (`kubectl`), extracted the verb (`delete`), and blocked it before any agent or cluster was involved.
+The same run, recorded against the real binary:
+
+
+
+*Recording regenerated by [`scripts/record-docs.sh`](https://github.com/UndermountainCC/failsafe/blob/main/scripts/record-docs.sh) — see [docs/demos](https://github.com/UndermountainCC/failsafe/tree/main/docs/demos).*
+
---
## Step 4: Flip to write mode and back
@@ -115,6 +121,10 @@ failsafe mode get
disabled
```
+Flipping to `disabled` and back, recorded:
+
+
+
Now run a read-only `kubectl get` to confirm allowed commands still pass through:
```bash
diff --git a/scripts/record-docs.sh b/scripts/record-docs.sh
new file mode 100755
index 0000000..a9af27c
--- /dev/null
+++ b/scripts/record-docs.sh
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+# Copyright 2026 Undermountain Coding Company
+# SPDX-License-Identifier: Apache-2.0
+#
+# record-docs.sh — drive the failsafe CLI under virtui and emit asciicast
+# recordings for the documentation site.
+#
+# This is the *recording* half of the docs automation flow. It is a sibling to
+# the doc-validation harness (test/docs/): that harness proves the
+# copy-paste snippets in docs/ actually run against the real binary; this script
+# produces the demo recordings that *show* them running, from the same commands,
+# so the two never drift.
+#
+# It is deliberately scoped to the HEADLESS CLI surface (explain / mode / audit /
+# report). The GUI terminal-integration surfaces (WezTerm toasts & tab-title,
+# iTerm2's Python keybinding) are NOT recordable headlessly and stay manual —
+# see docs/demos/README.md, same honest split as test/docs/REPORT.md.
+#
+# Requirements (all optional — missing tools downgrade gracefully, never fail):
+# - virtui https://github.com/honeybadge-labs/virtui (the recorder)
+# - agg https://github.com/asciinema/agg (.cast -> .gif) [optional render]
+# - svg-term npm i -g svg-term-cli (.cast -> .svg) [optional render]
+#
+# Usage:
+# scripts/record-docs.sh # record all scenarios into docs/assets/casts/
+# scripts/record-docs.sh --render # also render .cast -> .svg/.gif if a renderer is present
+# COLS=100 ROWS=24 scripts/record-docs.sh
+set -euo pipefail
+
+# ── Resolve paths ────────────────────────────────────────────────────────────
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+OUT_DIR="${OUT_DIR:-$REPO_ROOT/docs/assets/casts}"
+COLS="${COLS:-92}"
+ROWS="${ROWS:-22}"
+# Pause (seconds) held AFTER each command's output, so playback lingers on the
+# result before moving on instead of blurring past it. virtui records in real
+# wall-clock time, so a host-side idle gap becomes a visible pause baked into the
+# cast (re-rendering can't add it — it must be recorded). STEP_PAUSE=0 = fast.
+STEP_PAUSE="${STEP_PAUSE:-2}"
+RENDER=0
+[[ "${1:-}" == "--render" ]] && RENDER=1
+
+mkdir -p "$OUT_DIR"
+
+log() { printf '\033[1;34m▸\033[0m %s\n' "$*" >&2; }
+warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; }
+skip() { printf '\033[1;33mSKIP\033[0m %s\n' "$*" >&2; exit 0; }
+
+# ── Preflight: virtui ────────────────────────────────────────────────────────
+command -v virtui >/dev/null 2>&1 || skip \
+ "virtui not found — install from https://github.com/honeybadge-labs/virtui (go install …/cmd/virtui@latest). No recordings produced."
+
+# ── Build the binary under test into a throwaway bindir ──────────────────────
+BINDIR="$(mktemp -d)"
+log "building failsafe -> $BINDIR/failsafe"
+( cd "$REPO_ROOT" && go build -o "$BINDIR/failsafe" ./cmd/failsafe )
+export PATH="$BINDIR:$PATH"
+
+# ── Sandbox HOME so demos never touch the real ~/.config/failsafe ────────────
+# failsafe resolves all of its state from $HOME (policy.rego, trusted-repos.yaml,
+# decisions.jsonl, pane-mode files). A throwaway HOME keeps demos side-effect
+# free, mirroring helpers.bash in the doc-validation harness. The path is FIXED
+# (not mktemp-random) so the cwd printed by `explain` is readable and identical
+# on every re-record — committed SVGs then diff only when behaviour changes.
+SANDBOX_HOME="${TMPDIR:-/tmp}/failsafe-docs-home"
+rm -rf "$SANDBOX_HOME"
+export HOME="$SANDBOX_HOME"
+mkdir -p "$SANDBOX_HOME/project"
+unset FAILSAFE_MODE FAILSAFE_LOG 2>/dev/null || true
+
+# ── virtui daemon lifecycle ──────────────────────────────────────────────────
+DAEMON_STARTED=0
+cleanup() {
+ [[ "$DAEMON_STARTED" == 1 ]] && virtui daemon stop >/dev/null 2>&1 || true
+ rm -rf "$BINDIR" "$SANDBOX_HOME"
+}
+trap cleanup EXIT
+
+log "starting virtui daemon"
+virtui daemon start >/dev/null 2>&1 && DAEMON_STARTED=1 || warn "daemon may already be running"
+
+# ── record_scenario NAME -- "cmd|wait-for-text" ["cmd|wait" …] ───────────────
+# Spawns a recorded bash session, normalises the prompt, runs each step typing
+# the command and waiting for the given screen text, then exits to flush the cast.
+record_scenario() {
+ local name="$1"; shift
+ [[ "$1" == "--" ]] && shift
+ local cast="$OUT_DIR/$name.cast"
+ log "recording $name -> $cast"
+
+ # `virtui run` prints a human block ("session_id: \npid: …"); pull the id
+ # out of it so we don't take a hard dependency on jq.
+ local sid
+ sid="$(virtui run --record --record-path "$cast" --cols "$COLS" --rows "$ROWS" bash \
+ | sed -n 's/^session_id: //p')"
+ [[ -n "$sid" ]] || { warn "could not start session for $name"; return 1; }
+
+ # Quiet, deterministic prompt; start from the sandbox project dir.
+ virtui exec "$sid" "export PS1='\$ '; cd \"$HOME/project\"; clear" --wait-stable >/dev/null
+
+ local step cmd want
+ for step in "$@"; do
+ cmd="${step%%|*}"
+ want="${step#*|}"
+ if [[ "$want" == "$step" || -z "$want" ]]; then
+ virtui exec "$sid" "$cmd" --wait-stable >/dev/null
+ else
+ virtui exec "$sid" "$cmd" --wait "$want" >/dev/null
+ fi
+ sleep "$STEP_PAUSE" # linger on this command's output before the next one
+ done
+
+ virtui exec "$sid" "exit" --wait-stable >/dev/null 2>&1 || true
+ virtui kill "$sid" >/dev/null 2>&1 || true # finalize/flush the recording
+}
+
+# ── Scenarios — the headless CLI demos ───────────────────────────────────────
+# Each scenario maps to a doc page. Keep the commands identical to the snippets
+# the doc-validation harness checks, so the recording and the prose can't drift.
+
+# 1. The money shot: the guard blocking a destructive command (tutorials/getting-started, README).
+record_scenario "explain-block" -- \
+ "failsafe explain \"kubectl --context arn:aws:eks:us-east-1:123456789012:cluster/prod delete ns payments\"|Decision: BLOCK"
+
+# 2. The enabled/disabled mode switch (reference/modes, tutorials/getting-started step 4).
+# Later steps use --wait-stable (no |text): their words already sit on the
+# screen from earlier steps, so a text wait would fire before the output lands.
+record_scenario "mode-toggle" -- \
+ "failsafe mode get|enabled" \
+ "failsafe mode set disabled|disabled" \
+ "failsafe mode get" \
+ "failsafe mode set enabled"
+
+# 3. The policy chain in force (how-to/repo-policy, reference).
+record_scenario "audit" -- \
+ "failsafe audit|block rule"
+
+log "done — casts in $OUT_DIR"
+ls -1 "$OUT_DIR"/*.cast 2>/dev/null >&2 || true
+
+# ── Optional render: .cast -> embeddable .svg / .gif ─────────────────────────
+# GitHub markdown and MkDocs do NOT play asciicast inline, so an embeddable
+# artifact is required to actually surface a demo in the docs.
+if [[ "$RENDER" == 1 ]]; then
+ if command -v svg-term >/dev/null 2>&1; then
+ for cast in "$OUT_DIR"/*.cast; do
+ log "render $(basename "$cast") -> svg"
+ svg-term --in "$cast" --out "${cast%.cast}.svg" --window --no-cursor
+ done
+ elif command -v agg >/dev/null 2>&1; then
+ for cast in "$OUT_DIR"/*.cast; do
+ log "render $(basename "$cast") -> gif"
+ agg "$cast" "${cast%.cast}.gif"
+ done
+ else
+ warn "--render requested but neither svg-term nor agg found; left .cast files only."
+ fi
+fi