diff --git a/.gitignore b/.gitignore index 4ad142f..4313a3b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ go.work.sum # goreleaser output dist/ + +# scripts/record-demo/record.sh scratch output +scripts/record-demo/.bin/ +/.demo-repo/ diff --git a/AGENTS.md b/AGENTS.md index 13fda86..44a25f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - `cmd/writ/main.go`'s `version`, `commit`, and `date` must stay package-level `var`s (with `default*` const placeholders), never `const` — goreleaser's `-X main.version=...` ldflags silently no-op against a `const` string, so every released binary would misreport itself. `versionString` is the single place that renders them; it omits the `(commit ..., built ...)` suffix when both are still at their defaults. - `goreleaser check`/`release` (v2.16+) soft-deprecate the `brews:` key in favor of `homebrew_casks:`; kept `brews:` here to match vessel's existing convention and the tap's `bin.install`-style formula. `dist/` (goreleaser's output dir) is gitignored. - `brews:` sets `directory: Formula` so the generated formula writes to `Formula/writ.rb` in `Laaaaksh/homebrew-writ`, not the tap root. Homebrew resolves a tap's formulae from `Formula/` first, then `HomebrewFormula/`, then the root — a formula anywhere in `Formula/` always wins over one at the root, so omitting `directory` risks silently serving a stale root formula the moment anything else creates a `Formula/` dir in the tap (this happened to vessel's tap). Confirm the key name (`directory` vs the older `folder`) against the installed goreleaser's own schema (`goreleaser jsonschema`) before touching this, not from memory. +- The README's `## Demo` embeds `docs/assets/demo.gif` (960px, ~12fps, palette-quantized, must stay under 10 MB) and links `docs/assets/demo.mp4` (1280px H.264/yuv420p) for full quality — GitHub does not play a committed MP4 inline. Both are regenerated by `make demo` (`scripts/record-demo/record.sh`), which builds the real `writ` binary, seeds a throwaway repo, and drives it live through [VHS](https://github.com/charmbracelet/vhs) via `scripts/record-demo/demo.tape` — the tape is the actual recording script, not a mockup, so re-run it (never hand-edit the assets) whenever CLI output changes. See `scripts/record-demo/README.md` for the full mechanics. ## Maintaining this file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6cde125 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build test demo + +build: + go build -o writ ./cmd/writ + +test: + go test ./... + +demo: + scripts/record-demo/record.sh diff --git a/README.md b/README.md index b7c7f46..e71df3e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,15 @@ refuses to accept one - at intake and again before any status or merge decision. +## Demo + +![writ demo](docs/assets/demo.gif) + +A real writ run: `propose` -> `approve --yes` -> an in-scope edit that gets +`attest`ed and leaves `status` clean and auto-mergeable, then an out-of-scope +edit that `status` flags as drift while staying quiet about the in-scope +work. [Full-quality video](docs/assets/demo.mp4). + ## Install ``` diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif new file mode 100644 index 0000000..5fae479 Binary files /dev/null and b/docs/assets/demo.gif differ diff --git a/docs/assets/demo.mp4 b/docs/assets/demo.mp4 new file mode 100644 index 0000000..019d896 Binary files /dev/null and b/docs/assets/demo.mp4 differ diff --git a/scripts/record-demo/README.md b/scripts/record-demo/README.md new file mode 100644 index 0000000..b1fdfe1 --- /dev/null +++ b/scripts/record-demo/README.md @@ -0,0 +1,59 @@ +# Recording the demo + +`record.sh` builds the real `writ` binary, seeds a throwaway git repo, drives +that repo through the actual propose -> approve -> implement -> attest -> +status loop under [VHS](https://github.com/charmbracelet/vhs), and converts +the capture into the assets the README embeds. Nothing in the recording is +staged or hand-edited: `demo.tape` types real commands into a real shell +running the real binary. + +## Run it + +``` +brew install vhs ffmpeg # if not already installed +make demo # or: scripts/record-demo/record.sh +``` + +This writes: + +- `docs/assets/demo.mp4` - the raw capture (1280px, H.264, yuv420p) +- `docs/assets/demo.gif` - a palette-quantized conversion (960px, 12fps) for + the README, kept under 10 MB + +## How it fits together + +- `record.sh` builds `writ` into `scripts/record-demo/.bin/` (gitignored), + seeds `.demo-repo/` at the repo root (gitignored) with a two-package Go + module and a writ proposal, runs `vhs scripts/record-demo/demo.tape`, then + shells out to `ffmpeg` for the GIF conversion. Both scratch directories are + removed again when it finishes. `.demo-repo` is kept short and at the repo + root (not nested under `scripts/record-demo/`) so writ's printed absolute + paths - `writ approved: ` - stay within the recorded terminal's + column width instead of wrapping. +- `demo.tape` is the actual recording script, committed so the capture is + reviewable and reproducible from the diff. It types the CLI commands live: + propose from a file, approve `--yes`, an in-scope edit to + `internal/webhook/sender.go` that gets attested and shows a clean + `writ status`, then an out-of-scope edit to `internal/other/thing.go` that + `writ status` flags as drift. + +## Verify the output + +``` +ffprobe -v error -select_streams v:0 \ + -show_entries stream=width,height,nb_frames,codec_name,pix_fmt \ + -of default=noprint_wrappers=1 docs/assets/demo.mp4 + +ffprobe -v error -select_streams v:0 \ + -show_entries stream=width,height,nb_frames \ + -of default=noprint_wrappers=1 docs/assets/demo.gif +``` + +A useful capture has hundreds of frames at the resolutions above, not a +handful - check `nb_frames` and open the GIF to confirm it plays the loop. + +## Re-recording + +Re-run `make demo` any time the CLI's output format changes; the tape +re-seeds its own throwaway repo each run, so nothing from a previous +recording carries over. diff --git a/scripts/record-demo/demo.tape b/scripts/record-demo/demo.tape new file mode 100644 index 0000000..88035e7 --- /dev/null +++ b/scripts/record-demo/demo.tape @@ -0,0 +1,104 @@ +# Recorded by scripts/record-demo/record.sh, which builds the real writ +# binary and runs this against a freshly seeded throwaway repo. Every +# command below executes for real; nothing here is staged or faked. +# +# Run: vhs scripts/record-demo/demo.tape (from the repo root, after +# scripts/record-demo/.bin/writ and .demo-repo/ exist - see record.sh, +# which does both before invoking vhs). + +Output docs/assets/demo.mp4 + +Set Shell "zsh" +Set FontFamily "JetBrains Mono" +Set FontSize 20 +Set Width 1280 +Set Height 480 +Set Padding 20 +Set Theme "Dracula" +Set TypingSpeed 45ms + +Hide +Type "export PATH=$PWD/scripts/record-demo/.bin:$PATH" +Enter +Type "cd .demo-repo" +Enter +Type "export PS1='$ '" +Enter +Type "clear" +Enter +Show + +Type "cat writ-proposal.toml" +Enter +Sleep 2500ms + +Type "writ propose --file writ-proposal.toml" +Enter +Sleep 1500ms + +Type "writ approve --yes" +Enter +Sleep 1000ms + +Type "git checkout -b writ-1" +Enter +Sleep 800ms + +Type "clear" +Enter + +Type "echo '--- in-scope change: the file the writ declared ---'" +Enter +Sleep 500ms +Type "cat >> internal/webhook/sender.go << 'EOF'" +Enter +Type "" +Enter +Type "func retryWithBackoff(url string) error {" +Enter +Type " return Send(url)" +Enter +Type "}" +Enter +Type "EOF" +Enter +Sleep 500ms + +Type "git commit -am 'add retry with backoff'" +Enter +Sleep 800ms + +Type "writ attest retries-on-5xx --note 'retries Send on failure'" +Enter +Sleep 1200ms + +Type "clear" +Enter +Type "writ status" +Enter +Sleep 3500ms + +Type "clear" +Enter +Type "echo '--- out-of-scope change: nothing the writ declared ---'" +Enter +Sleep 500ms +Type "cat >> internal/other/thing.go << 'EOF'" +Enter +Type "" +Enter +Type "func unrelated() int { return 2 }" +Enter +Type "EOF" +Enter +Sleep 500ms + +Type "git commit -am 'tweak unrelated file'" +Enter +Sleep 800ms + +Type "clear" +Enter +Type "writ status" +Enter +Sleep 4500ms diff --git a/scripts/record-demo/record.sh b/scripts/record-demo/record.sh new file mode 100755 index 0000000..a87848a --- /dev/null +++ b/scripts/record-demo/record.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Builds writ, seeds a throwaway demo repo, records the real CLI with VHS, +# and converts the capture into docs/assets/demo.{mp4,gif}. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +if ! command -v vhs >/dev/null 2>&1; then + echo "vhs not found. Install it with: brew install vhs" >&2 + exit 1 +fi +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg not found. Install it with: brew install ffmpeg" >&2 + exit 1 +fi + +BIN_DIR="scripts/record-demo/.bin" +# Kept short and at the repo root (not nested under scripts/record-demo/) +# so writ's printed absolute paths (e.g. "writ approved: ") stay +# within the recorded terminal's column width instead of wrapping. +REPO_DIR=".demo-repo" + +rm -rf "$BIN_DIR" "$REPO_DIR" +mkdir -p "$BIN_DIR" "$REPO_DIR" + +echo "building writ..." +go build -o "$BIN_DIR/writ" ./cmd/writ + +echo "seeding throwaway demo repo at $REPO_DIR..." +( + cd "$REPO_DIR" + git init -q -b master + git config user.email "demo@example.invalid" + git config user.name "writ demo" + + mkdir -p internal/webhook internal/other + cat >internal/webhook/sender.go <<'EOF' +package webhook + +func Send(url string) error { + return nil +} +EOF + cat >internal/other/thing.go <<'EOF' +package other + +func Thing() int { + return 1 +} +EOF + cat >go.mod <<'EOF' +module demo + +go 1.22 +EOF + cat >writ-proposal.toml <<'EOF' +id = "writ-1" +intent = "add retry to the webhook sender" +base = "master" +created = 2026-08-30T00:00:00Z +scope = ["internal/webhook/**"] + +[[criteria]] +id = "retries-on-5xx" +text = "a 5xx response is retried with backoff" + +[verify] +command = "go build ./..." +EOF + + git add -A + git commit -q -m "initial" +) + +mkdir -p docs/assets +rm -f docs/assets/demo.mp4 docs/assets/demo.gif + +echo "recording with vhs..." +vhs scripts/record-demo/demo.tape + +echo "converting to gif (palette-quantized, 960px, 12fps)..." +PALETTE="$(mktemp -t writ-demo-palette).png" +ffmpeg -y -i docs/assets/demo.mp4 -vf "fps=12,scale=960:-1:flags=lanczos,palettegen" "$PALETTE" -loglevel error +ffmpeg -y -i docs/assets/demo.mp4 -i "$PALETTE" \ + -filter_complex "fps=12,scale=960:-1:flags=lanczos[x];[x][1:v]paletteuse" \ + docs/assets/demo.gif -loglevel error +rm -f "$PALETTE" + +echo +echo "done:" +ls -lh docs/assets/demo.mp4 docs/assets/demo.gif +echo +echo "verify with:" +echo " ffprobe -v error -select_streams v:0 -show_entries stream=width,height,nb_frames,codec_name,pix_fmt -of default=noprint_wrappers=1 docs/assets/demo.mp4" +echo " ffprobe -v error -select_streams v:0 -show_entries stream=width,height,nb_frames -of default=noprint_wrappers=1 docs/assets/demo.gif" + +rm -rf "$BIN_DIR" "$REPO_DIR"