Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ go.work.sum

# goreleaser output
dist/

# scripts/record-demo/record.sh scratch output
scripts/record-demo/.bin/
/.demo-repo/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.PHONY: build test demo

build:
go build -o writ ./cmd/writ

test:
go test ./...

demo:
scripts/record-demo/record.sh
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ refuses to accept one - at intake and again before any status or merge decision.

</div>

## 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

```
Expand Down
Binary file added docs/assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/demo.mp4
Binary file not shown.
59 changes: 59 additions & 0 deletions scripts/record-demo/README.md
Original file line number Diff line number Diff line change
@@ -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: <path>` - 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.
104 changes: 104 additions & 0 deletions scripts/record-demo/demo.tape
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions scripts/record-demo/record.sh
Original file line number Diff line number Diff line change
@@ -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: <path>") 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"