diff --git a/.justfiles/vhs.just b/.justfiles/vhs.just
index da9f248..4663720 100644
--- a/.justfiles/vhs.just
+++ b/.justfiles/vhs.just
@@ -32,6 +32,11 @@ booth:
chmod +x {{ root }}/tapes/fake-say
just vhs record tapes/booth.tape
+# Real mouth, no audio: a script pipes lines in, wavs land in out/.
+pipe:
+ just build quick
+ just vhs record tapes/pipe.tape
+
# Real mouth + VHS terminal, muxed to docs/booth.mp4 (gif stays silent).
# The line is baked with qwen3-tts-cli, the one-shot tool that ships next to
# the worker (cans doctor puts both in ~/.cans/native/bin).
diff --git a/README.md b/README.md
index d380092..7dac915 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,55 @@ Keep needs the clip and the words spoken in it. Throat stays put for the session
The CLI is Go. The mouth is a native [Qwen3-TTS](https://github.com/Obedience-Corp/qwen3-tts-native) worker that clones a wav. No Python.
+## Scripting
+
+The script owns the document. cans speaks what it is handed.
+
+
+
+
+
+`--stream` reads stdin a line at a time and speaks each line through one worker: one model load for the whole document. Blank lines are skipped and do not take an index.
+
+```bash
+# one wav per paragraph, script decides what a paragraph is
+awk -v RS='' '{gsub(/\n/," "); print}' chapter.md | cans say --stream -o 'out/%03d.wav'
+
+# a line at a time, with the script's own naming
+i=0
+while IFS= read -r line; do
+ i=$((i+1))
+ cans say "$line" -o "$(printf 'out/%03d.wav' "$i")"
+done < lines.txt
+
+# metadata for a build step
+cans say --stream --json -o 'out/%03d.wav' < lines.txt | jq -r 'select(.error==null) | .wav' > manifest.txt
+```
+
+| Flag | Effect |
+|------|--------|
+| `-o`, `--out` | Write the wav here. Under `--stream` the path takes one `%d`, as in `out/%03d.wav`. |
+| `--stream` | Read stdin line by line, one utterance per line, one worker for all of them. Text on argv is a usage error. |
+| `--json` | One JSON record per utterance on stdout; `--stream` adds `"line"`. `wav` is present only with `-o`. |
+| `--play` | Play the wav as well as writing it. Needs `-o`. |
+| `--nowait` | Do not queue behind another cans: give up at once. |
+| `--wait ` | Queue for at most that long. Without either flag, cans waits. |
+| `-` | Read one utterance from stdin. Text on argv is a usage error. |
+
+| Exit | Meaning |
+|------|---------|
+| `0` | Spoke it. |
+| `1` | Runtime failure, or a line in a stream failed. |
+| `2` | Usage error. |
+| `75` | Another cans holds the mouth and the wait was refused or ran out. |
+| `130` | Interrupted. |
+
+stdout carries data — a JSON record, a wav path, or `ttfa_ms=N`, the worker's total synthesis time for that line. Prose goes to stderr.
+
+Ctrl-C stops the stream: the line being spoken is dropped, finished wavs stay, exit 130. A second Ctrl-C stops at once.
+
+Without -o the wav is a temp file removed after playback. `--json` without `-o` omits `wav` so a script is not handed a path that will not survive. Pass `-o` if the file is needed.
+
---
Built with [Festival](https://fest.build)
diff --git a/cmd/cans/main.go b/cmd/cans/main.go
index 394ad92..e8df393 100644
--- a/cmd/cans/main.go
+++ b/cmd/cans/main.go
@@ -5,17 +5,19 @@ import (
"fmt"
"io"
"os"
+ "os/signal"
"strings"
+ "syscall"
"github.com/veronica-agent/cans/internal/booth"
"github.com/veronica-agent/cans/internal/doctor"
"github.com/veronica-agent/cans/internal/keep"
- "github.com/veronica-agent/cans/internal/play"
+ "github.com/veronica-agent/cans/internal/say"
"github.com/veronica-agent/cans/internal/ship"
- "github.com/veronica-agent/cans/internal/tts"
)
var (
+ stdin io.Reader = os.Stdin
stdout io.Writer = os.Stdout
stderr io.Writer = os.Stderr
)
@@ -25,10 +27,15 @@ const usage = `cans — put the cans on.
Apple Silicon. The mouth is a native Qwen3-TTS worker cloning a wav.
cans booth (throat frozen for the session)
- cans say speak one line
cans keep -text WORDS freeze this throat (both orders work)
cans doctor set up the mouth, check the machine
cans version print version
+
+ cans say [-o out.wav] [--json] [--play] [--nowait|--wait 30s]
+ echo text | cans say
+ cans say --stream -o 'out/%03d.wav' < lines.txt
+
+exit 75 when another cans holds the mouth and --nowait was set or --wait ran out
`
func main() {
@@ -37,45 +44,11 @@ func main() {
func run(args []string) int {
if len(args) == 0 {
- if err := doctor.Prepare(context.Background(), stderr); err != nil {
- fmt.Fprintln(stderr, err)
- return 1
- }
- throat, err := keep.Load()
- if err != nil {
- fmt.Fprintln(stderr, err)
- return 1
- }
- if err := booth.Run(context.Background(), keep.Quote(), throat); err != nil {
- fmt.Fprintln(stderr, err)
- return 1
- }
- return 0
+ return runBooth()
}
switch args[0] {
case "say":
- text := strings.TrimSpace(strings.Join(args[1:], " "))
- if text == "" {
- fmt.Fprintln(stderr, "say: missing text")
- return 2
- }
- if err := doctor.Prepare(context.Background(), stderr); err != nil {
- fmt.Fprintln(stderr, err)
- return 1
- }
- r, err := tts.Say(context.Background(), text)
- if err != nil {
- fmt.Fprintln(stderr, err)
- return 1
- }
- fmt.Fprintf(stdout, "ttfa_ms=%d\n", r.TTFAMs)
- playErr := play.File(r.Wav)
- tts.RemoveTemp(r.Wav)
- if playErr != nil {
- fmt.Fprintln(stderr, playErr)
- return 1
- }
- return 0
+ return runSay(args[1:])
case "doctor":
if err := doctor.Run(context.Background(), stdout, stderr); err != nil {
return 1
@@ -106,6 +79,44 @@ func run(args []string) int {
}
}
+// runSay speaks one `cans say`, cancellable by SIGINT or SIGTERM.
+func runSay(args []string) int {
+ o, err := parseSay(args)
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ return 2
+ }
+ o.StdinTTY = stdinIsTTY()
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ // D014: the first signal cancels; stop() then restores the default
+ // disposition, so a second Ctrl-C ends the process at once. stop() cancels
+ // ctx as well, so this goroutine always ends with runSay.
+ go func() {
+ <-ctx.Done()
+ stop()
+ }()
+ return say.Run(ctx, o, stdin, stdout, stderr)
+}
+
+// runBooth prepares the mouth and opens the TUI on the frozen throat.
+func runBooth() int {
+ if err := doctor.Prepare(context.Background(), stderr); err != nil {
+ fmt.Fprintln(stderr, err)
+ return 1
+ }
+ throat, err := keep.Load()
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ return 1
+ }
+ if err := booth.Run(context.Background(), keep.Quote(), throat); err != nil {
+ fmt.Fprintln(stderr, err)
+ return 1
+ }
+ return 0
+}
+
// parseKeep accepts both `keep take.wav -text words` and `keep -text words take.wav`.
func parseKeep(args []string) (wav, text string, err error) {
var positional []string
diff --git a/cmd/cans/main_test.go b/cmd/cans/main_test.go
index 547aaf8..11c1391 100644
--- a/cmd/cans/main_test.go
+++ b/cmd/cans/main_test.go
@@ -2,12 +2,15 @@ package main
import (
"bytes"
+ "context"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
"github.com/veronica-agent/cans/internal/audio"
+ "github.com/veronica-agent/cans/internal/mouth"
)
func TestParseKeepBothOrders(t *testing.T) {
@@ -114,3 +117,84 @@ func TestSayMock(t *testing.T) {
t.Fatalf("code %d", code)
}
}
+
+func TestSayNoTextIsUsage(t *testing.T) {
+ var buf bytes.Buffer
+ oldErr, oldIn := stderr, stdin
+ stderr, stdin = &buf, strings.NewReader("")
+ defer func() { stderr, stdin = oldErr, oldIn }()
+ if code := run([]string{"say"}); code != 2 {
+ t.Fatalf("code %d", code)
+ }
+ if !strings.Contains(buf.String(), "say: empty text") {
+ t.Fatalf("%q", buf.String())
+ }
+}
+
+func TestSayUnknownFlag(t *testing.T) {
+ var buf bytes.Buffer
+ old := stderr
+ stderr = &buf
+ defer func() { stderr = old }()
+ if code := run([]string{"say", "--bogus"}); code != 2 {
+ t.Fatalf("code %d", code)
+ }
+ if !strings.Contains(buf.String(), "--bogus") {
+ t.Fatalf("%q", buf.String())
+ }
+}
+
+func TestSayNowaitBusy(t *testing.T) {
+ setupFakeMouth(t)
+ lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lk.Release()
+ var buf bytes.Buffer
+ old := stderr
+ stderr = &buf
+ defer func() { stderr = old }()
+ if code := run([]string{"say", "--nowait", "Put the cans on."}); code != 75 {
+ t.Fatalf("code %d stderr %q", code, buf.String())
+ }
+ if !strings.Contains(buf.String(), "mouth busy") {
+ t.Fatalf("%q", buf.String())
+ }
+}
+
+func setupFakeMouth(t *testing.T) {
+ t.Helper()
+ dir, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ src := filepath.Join(dir, "..", "..", "internal", "tts", "testdata", "fakeworker")
+ bin := filepath.Join(t.TempDir(), "fake-worker")
+ out, err := exec.Command("go", "build", "-o", bin, src).CombinedOutput()
+ if err != nil {
+ t.Fatalf("build fake worker: %s\n%s", err, out)
+ }
+ home := t.TempDir()
+ root := t.TempDir()
+ t.Setenv("CANS_HOME", home)
+ t.Setenv("CANS_ROOT", root)
+ t.Setenv("CANS_NOPLAY", "1")
+ t.Setenv("CANS_SAY_BIN", "")
+ t.Setenv("CANS_WORKER_BIN", bin)
+ t.Setenv("CANS_WORKER_MODELS", t.TempDir())
+ t.Setenv("CANS_NATIVE_URL", "http://127.0.0.1/cans-test")
+ ref := filepath.Join(root, "voices", "veronica", "ref.wav")
+ if err := os.MkdirAll(filepath.Dir(ref), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(ref, audio.Minimal(), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "character.toml"), []byte("name = \"veronica\"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(filepath.Dir(bin), "libqwen3tts.0.dylib"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/cmd/cans/say_args.go b/cmd/cans/say_args.go
new file mode 100644
index 0000000..d0ee0d2
--- /dev/null
+++ b/cmd/cans/say_args.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/veronica-agent/cans/internal/say"
+)
+
+// sayValueFlags are the say flags that take a value, so `--flag=v` splits.
+var sayValueFlags = map[string]bool{"-o": true, "--out": true, "--wait": true}
+
+// parseSay accepts flags and text in either order, the way parseKeep does:
+// `say "line" -o out.wav` and `say -o out.wav "line"` parse identically.
+func parseSay(args []string) (say.Options, error) {
+ o := say.DefaultOptions()
+ var positional []string
+ var sawWait, sawNoWait bool
+ args = splitSayEquals(args)
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ switch a {
+ case "-o", "--out":
+ i++
+ if i >= len(args) {
+ return say.Options{}, fmt.Errorf("say: %s needs a path", a)
+ }
+ o.Out = args[i]
+ case "--wait":
+ i++
+ if i >= len(args) {
+ return say.Options{}, fmt.Errorf("say: --wait needs a duration")
+ }
+ d, err := parseWait(args[i])
+ if err != nil {
+ return say.Options{}, err
+ }
+ o.Wait, sawWait = d, true
+ case "--json":
+ o.JSON = true
+ case "--stream":
+ o.Stream = true
+ case "--play":
+ o.Play = true
+ case "--nowait":
+ o.Wait, sawNoWait = 0, true
+ case "-":
+ o.Stdin = true
+ case "-h", "--help":
+ return say.Options{}, fmt.Errorf("%s", strings.TrimSpace(usage))
+ default:
+ if strings.HasPrefix(a, "-") {
+ return say.Options{}, fmt.Errorf("say: unknown flag %s", a)
+ }
+ positional = append(positional, a)
+ }
+ }
+ o.Text = strings.TrimSpace(strings.Join(positional, " "))
+ return o, validateSay(o, sawWait, sawNoWait)
+}
+
+// splitSayEquals rewrites `--flag=value` into `--flag value` for value flags.
+func splitSayEquals(args []string) []string {
+ out := make([]string, 0, len(args))
+ for _, a := range args {
+ if name, val, ok := strings.Cut(a, "="); ok && sayValueFlags[name] {
+ out = append(out, name, val)
+ continue
+ }
+ out = append(out, a)
+ }
+ return out
+}
+
+func parseWait(v string) (time.Duration, error) {
+ d, err := time.ParseDuration(v)
+ if err != nil {
+ return 0, fmt.Errorf("say: --wait %q is not a duration", v)
+ }
+ if d <= 0 {
+ return 0, fmt.Errorf("say: --wait must be positive (use --nowait for none)")
+ }
+ return d, nil
+}
+
+func validateSay(o say.Options, sawWait, sawNoWait bool) error {
+ if sawWait && sawNoWait {
+ return fmt.Errorf("say: --nowait and --wait together")
+ }
+ if o.Play && o.Out == "" {
+ return fmt.Errorf("say: --play needs -o")
+ }
+ if o.Stdin && o.Text != "" {
+ return fmt.Errorf("say: - and text together")
+ }
+ if o.Stream && o.Text != "" {
+ return fmt.Errorf("say: --stream reads stdin; drop the text")
+ }
+ return nil
+}
diff --git a/cmd/cans/say_args_test.go b/cmd/cans/say_args_test.go
new file mode 100644
index 0000000..10ac308
--- /dev/null
+++ b/cmd/cans/say_args_test.go
@@ -0,0 +1,119 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/veronica-agent/cans/internal/say"
+)
+
+func TestParseSayErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {"unknown flag", []string{"--bogus"}, "say: unknown flag --bogus"},
+ {"unknown short flag", []string{"-x", "Put the cans on."}, "say: unknown flag -x"},
+ {"help short", []string{"-h"}, "cans say [-o out.wav]"},
+ {"help long", []string{"--help"}, "cans say [-o out.wav]"},
+ {"wait unparsable", []string{"--wait", "bogus"}, `say: --wait "bogus" is not a duration`},
+ {"wait zero", []string{"--wait", "0s"}, "say: --wait must be positive"},
+ {"wait negative", []string{"--wait=-2s"}, "say: --wait must be positive"},
+ {"nowait and wait", []string{"--nowait", "--wait", "1s"}, "say: --nowait and --wait together"},
+ {"play without out", []string{"--play", "Put the cans on."}, "say: --play needs -o"},
+ {"stdin with text", []string{"-", "Put the cans on."}, "say: - and text together"},
+ {"stream with text", []string{"--stream", "Put the cans on."}, "say: --stream reads stdin; drop the text"},
+ {"out without value", []string{"-o"}, "say: -o needs a path"},
+ {"wait without value", []string{"--wait"}, "say: --wait needs a duration"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := parseSay(tt.args)
+ if err == nil {
+ t.Fatalf("expected an error for %q", tt.args)
+ }
+ if !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("error %q does not contain %q", err, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseSayGrammar(t *testing.T) {
+ base := say.DefaultOptions()
+ with := func(f func(*say.Options)) say.Options {
+ o := base
+ f(&o)
+ return o
+ }
+ tests := []struct {
+ name string
+ args []string
+ want say.Options
+ }{
+ {"no args", nil, base},
+ {"text only", []string{"Put the cans on."},
+ with(func(o *say.Options) { o.Text = "Put the cans on." })},
+ {"multiple positionals joined", []string{"Put", "the", "cans", "on."},
+ with(func(o *say.Options) { o.Text = "Put the cans on." })},
+ {"out before text", []string{"-o", "out.wav", "Put the cans on."},
+ with(func(o *say.Options) { o.Text, o.Out = "Put the cans on.", "out.wav" })},
+ {"out after text", []string{"Put the cans on.", "-o", "out.wav"},
+ with(func(o *say.Options) { o.Text, o.Out = "Put the cans on.", "out.wav" })},
+ {"out equals", []string{"-o=out.wav", "Put the cans on."},
+ with(func(o *say.Options) { o.Text, o.Out = "Put the cans on.", "out.wav" })},
+ {"long out", []string{"--out", "out.wav", "Put the cans on."},
+ with(func(o *say.Options) { o.Text, o.Out = "Put the cans on.", "out.wav" })},
+ {"long out equals", []string{"--out=out.wav", "Put the cans on."},
+ with(func(o *say.Options) { o.Text, o.Out = "Put the cans on.", "out.wav" })},
+ {"json and stream", []string{"--json", "--stream"},
+ with(func(o *say.Options) { o.JSON, o.Stream = true, true })},
+ {"stdin alone", []string{"-"},
+ with(func(o *say.Options) { o.Stdin = true })},
+ {"play with out", []string{"--play", "-o", "out.wav", "Put the cans on."},
+ with(func(o *say.Options) { o.Text, o.Out, o.Play = "Put the cans on.", "out.wav", true })},
+ {"wait duration", []string{"--wait", "30s", "Put the cans on."},
+ with(func(o *say.Options) { o.Text, o.Wait = "Put the cans on.", 30*time.Second })},
+ {"wait equals", []string{"--wait=30s"},
+ with(func(o *say.Options) { o.Wait = 30 * time.Second })},
+ {"nowait", []string{"--nowait", "Put the cans on."},
+ with(func(o *say.Options) { o.Text, o.Wait = "Put the cans on.", 0 })},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := parseSay(tt.args)
+ if err != nil {
+ t.Fatalf("parseSay(%q): %v", tt.args, err)
+ }
+ if got != tt.want {
+ t.Fatalf("parseSay(%q) = %+v, want %+v", tt.args, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseSayBothOrdersMatch(t *testing.T) {
+ first, err := parseSay([]string{"Put the cans on.", "-o", "out.wav"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := parseSay([]string{"-o", "out.wav", "Put the cans on."})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first != second {
+ t.Fatalf("%+v != %+v", first, second)
+ }
+}
+
+func TestParseSayDefaultWaitsForever(t *testing.T) {
+ o, err := parseSay([]string{"Put the cans on."})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Wait != say.WaitForever {
+ t.Fatalf("wait %v, want %v", o.Wait, say.WaitForever)
+ }
+}
diff --git a/cmd/cans/tty.go b/cmd/cans/tty.go
new file mode 100644
index 0000000..520ce6e
--- /dev/null
+++ b/cmd/cans/tty.go
@@ -0,0 +1,14 @@
+package main
+
+import "os"
+
+// stdinIsTTY is true only for a real terminal. /dev/null is a char device
+// (Stat ModeCharDevice is set) but not a TTY; treating it as one made
+// `cans say < /dev/null` print "missing text" instead of "empty text".
+func stdinIsTTY() bool {
+ f, ok := stdin.(*os.File)
+ if !ok {
+ return false
+ }
+ return fdIsTTY(f.Fd())
+}
diff --git a/cmd/cans/tty_darwin.go b/cmd/cans/tty_darwin.go
new file mode 100644
index 0000000..15fd9c2
--- /dev/null
+++ b/cmd/cans/tty_darwin.go
@@ -0,0 +1,14 @@
+//go:build darwin
+
+package main
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+func fdIsTTY(fd uintptr) bool {
+ var t syscall.Termios
+ _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, uintptr(syscall.TIOCGETA), uintptr(unsafe.Pointer(&t)), 0, 0, 0)
+ return errno == 0
+}
diff --git a/cmd/cans/tty_linux.go b/cmd/cans/tty_linux.go
new file mode 100644
index 0000000..50c6874
--- /dev/null
+++ b/cmd/cans/tty_linux.go
@@ -0,0 +1,14 @@
+//go:build linux
+
+package main
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+func fdIsTTY(fd uintptr) bool {
+ var t syscall.Termios
+ _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(&t)), 0, 0, 0)
+ return errno == 0
+}
diff --git a/cmd/cans/tty_other.go b/cmd/cans/tty_other.go
new file mode 100644
index 0000000..74e818a
--- /dev/null
+++ b/cmd/cans/tty_other.go
@@ -0,0 +1,5 @@
+//go:build !darwin && !linux
+
+package main
+
+func fdIsTTY(uintptr) bool { return false }
diff --git a/cmd/cans/tty_test.go b/cmd/cans/tty_test.go
new file mode 100644
index 0000000..3ab2b97
--- /dev/null
+++ b/cmd/cans/tty_test.go
@@ -0,0 +1,40 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestStdinDevNullIsNotTTY(t *testing.T) {
+ f, err := os.Open(os.DevNull)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ old := stdin
+ stdin = f
+ defer func() { stdin = old }()
+ if stdinIsTTY() {
+ t.Fatal("/dev/null must not count as a TTY")
+ }
+}
+
+func TestSayDevNullIsEmptyText(t *testing.T) {
+ f, err := os.Open(os.DevNull)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ var buf bytes.Buffer
+ oldErr, oldIn := stderr, stdin
+ stderr, stdin = &buf, f
+ defer func() { stderr, stdin = oldErr, oldIn }()
+ if code := run([]string{"say"}); code != 2 {
+ t.Fatalf("code %d", code)
+ }
+ if !strings.Contains(buf.String(), "say: empty text") {
+ t.Fatalf("%q", buf.String())
+ }
+}
diff --git a/docs/pipe.gif b/docs/pipe.gif
new file mode 100644
index 0000000..fb862f7
Binary files /dev/null and b/docs/pipe.gif differ
diff --git a/festivals/CV0001/001_INGEST/GATES.md b/festivals/CV0001/001_INGEST/GATES.md
new file mode 100644
index 0000000..3d20c0a
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/GATES.md
@@ -0,0 +1,58 @@
+---
+fest_type: phase_gate
+fest_id: 001_INGEST-GATE
+fest_parent: 001_INGEST
+---
+
+# Ingest Phase Gate
+
+This gate verifies the ingest phase achieved its goal and produced approved structured output.
+
+---
+
+## Step 1: PHASE GOAL — Verify Goal Achievement
+
+**Question:** Does the structured output capture the user's intent as specified in the ingest objective?
+
+**Actions:**
+1. Re-read PHASE_GOAL.md and compare stated ingest objectives against produced output
+2. Verify the structured output faithfully represents the original input meaning
+3. Confirm interpretive decisions are documented and justified
+
+**Checkpoint:** APPROVAL REQUIRED — Confirm ingest goal is met
+
+---
+
+## Step 2: COMPLETENESS — Verify All Inputs Processed
+
+**Question:** Were all input specifications processed?
+
+**Actions:**
+1. Confirm every file in `input_specs/` was read completely
+2. Verify no inputs were overlooked or partially processed
+3. Check that ambiguities and questions were noted
+
+**Checkpoint:** APPROVAL REQUIRED — Confirm all inputs processed
+
+---
+
+## Step 3: APPROVAL — Verify User Validated Output
+
+**Question:** Did the user validate the structured output?
+
+**Actions:**
+1. Confirm the user reviewed and approved the output specifications
+2. Verify any user corrections were incorporated
+3. Check that requirements are clear enough for downstream planning
+
+**Checkpoint:** APPROVAL REQUIRED — Confirm user validated output
+
+---
+
+## Gate State Tracking
+
+| Step | Status | Notes |
+|------|--------|-------|
+| 1. PHASE GOAL | [ ] pending | Ingest goal achieved |
+| 2. COMPLETENESS | [ ] pending | All inputs processed |
+| 3. APPROVAL | [ ] pending | User validated output |
diff --git a/festivals/CV0001/001_INGEST/PHASE_GOAL.md b/festivals/CV0001/001_INGEST/PHASE_GOAL.md
new file mode 100644
index 0000000..de416a5
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/PHASE_GOAL.md
@@ -0,0 +1,66 @@
+---
+fest_type: phase
+fest_id: 001_INGEST
+fest_name: INGEST
+fest_parent: cans-v2-CV0001
+fest_order: 1
+fest_status: completed
+fest_created: 2026-08-21T04:32:49.132577-06:00
+fest_updated: 2026-08-21T05:00:07.75662-06:00
+fest_phase_type: ingest
+fest_tracking: true
+---
+
+
+# Phase Goal: 001_INGEST
+
+**Phase:** 001_INGEST | **Status:** Pending | **Type:** Ingest
+
+## Phase Objective
+
+**Primary Goal:** Ingest and structure input materials into actionable specifications
+
+**Context:** Seeded input is available in input_specs/seed.md and should be transformed into structured output specs for planning.
+
+## Input Sources
+
+Place all raw input materials in `input_specs/`:
+
+- [x] `seed.md` — the slice in one line, v2-is / v2-is-not, satisfied dependencies
+- [x] `design-surface.md`, `design-pipes.md`, `design-queue.md`, `design-fest-ad.md`, `design-recommend.md` — the accepted design pack (`WI-a2e393`), copied verbatim
+- [x] `user-direction.md` — the operator's verbatim direction and the rules that do not move
+
+## Expected Outputs
+
+The following structured documents will be created in `output_specs/`:
+
+| Output | Purpose |
+|--------|---------|
+| `purpose.md` | Festival purpose, success criteria, motivation |
+| `requirements.md` | Prioritized requirements (P0/P1/P2) with traceability |
+| `constraints.md` | Technical and process constraints |
+| `context.md` | Prior art, related systems, key references |
+
+## Success Criteria
+
+This ingest phase is complete when:
+
+- [ ] All input sources reviewed and understood
+- [ ] Output specs created following standard structure
+- [ ] User has approved the structured output
+- [ ] No unresolved questions or ambiguities
+
+## Workflow
+
+This phase uses step-based workflow guidance. See `WORKFLOW.md` for the step-by-step process.
+
+Use `fest next` to see the current step.
+Use `fest workflow advance` to move to the next step.
+
+## Notes
+
+The design pack was already reviewed and accepted by the operator, so ingest is restructuring, not discovery. Do not re-open decisions the pack closed (no ingestion, no daemon). The PRESENT checkpoint is approved by the orchestrator on the operator's delegation — log it in CONTEXT.md.
+
+---
+
+*Ingest phases transform unstructured input into structured specifications ready for planning.*
\ No newline at end of file
diff --git a/festivals/CV0001/001_INGEST/WORKFLOW.md b/festivals/CV0001/001_INGEST/WORKFLOW.md
new file mode 100644
index 0000000..d4369ba
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/WORKFLOW.md
@@ -0,0 +1,115 @@
+---
+fest_type: workflow
+fest_id: 001_INGEST-WF
+fest_parent: 001_INGEST
+---
+
+# Ingest Phase Workflow
+
+This document guides the agent through the ingest phase. Follow these steps in order, completing each checkpoint before proceeding.
+
+---
+
+## Step 1: GATHER — Copy All Requirement Artifacts into input_specs/
+
+**Goal:** Ensure every piece of requirement material the user already has is present in `input_specs/` before any reading or analysis begins.
+
+**Actions:**
+1. Ask the user (or check the festival context) what requirement artifacts exist: intents, notes, structured specs, prior planning text, chat history summaries, docs, screenshots, or any other relevant material
+2. Copy or transcribe each artifact into `input_specs/` — one file per source, named descriptively (e.g., `intent-original.md`, `prior-design-notes.md`, `user-chat-summary.md`)
+3. If the user seeded content at festival creation time, confirm it is present in `input_specs/` and correctly named
+4. List the files now in `input_specs/` and confirm with the user that nothing is missing before proceeding
+
+**Output:** All available requirement artifacts present in `input_specs/`
+
+**Checkpoint:** None — proceed to Step 2
+
+---
+
+## Step 2: READ — Understand All Input
+
+**Goal:** Build comprehensive understanding of what the user has provided.
+
+**Actions:**
+1. List all files in `input_specs/`
+2. Read each file completely — do not skim
+3. Identify: What is the user trying to accomplish? What problem are they solving?
+4. Note any questions or ambiguities
+
+**Output:** Mental model of the user's intent (no document yet)
+
+**Checkpoint:** None — proceed to Step 3
+
+---
+
+## Step 3: EXTRACT — Identify Key Elements
+
+**Goal:** Pull out the essential information that needs to be structured.
+
+**Actions:**
+1. Extract festival purpose (end goal, "done" criteria, why it matters)
+2. Extract requirements (what needs to happen, acceptance criteria, priorities)
+3. Extract constraints (technical, process, timeline)
+4. Extract context (prior art, related systems, references)
+
+**Output:** Notes on each element (can be rough)
+
+**Checkpoint:** None — proceed to Step 4
+
+---
+
+## Step 4: STRUCTURE — Produce Output Specs
+
+**Goal:** Transform extracted elements into structured documents.
+
+**Actions:**
+1. Create `output_specs/purpose.md` with festival purpose, success criteria, motivation
+2. Create `output_specs/requirements.md` with prioritized requirements (P0/P1/P2)
+3. Create `output_specs/constraints.md` with technical and process constraints
+4. Create `output_specs/context.md` with prior art and key references
+
+**Output:** Four documents in `output_specs/`
+
+**Checkpoint:** None — proceed to Step 5
+
+---
+
+## Step 5: PRESENT — Get User Approval
+
+**Goal:** Verify the structured output captures the user's intent.
+
+**Actions:**
+1. Summarize what you've produced (don't dump full documents)
+2. Highlight any interpretations you made or questions you have
+3. Ask: "Do these specs accurately capture what you want to accomplish?"
+
+**Output:** Summary presented to user
+
+**Checkpoint:** APPROVAL REQUIRED — Wait for user response
+
+---
+
+## Step 6: ITERATE or COMPLETE
+
+**Goal:** Handle user feedback or finalize the phase.
+
+**Actions:**
+1. If user rejects: Note feedback, return to Step 4, update specs
+2. If user approves: Mark phase complete, note any caveats
+
+**Output:** Phase completion or iteration
+
+**Checkpoint:** None — phase ends
+
+---
+
+## Workflow State Tracking
+
+| Step | Status | Notes |
+|------|--------|-------|
+| 1. GATHER | [ ] pending | Blocks until all artifacts are in input_specs/ |
+| 2. READ | [ ] pending | |
+| 3. EXTRACT | [ ] pending | |
+| 4. STRUCTURE | [ ] pending | |
+| 5. PRESENT | [ ] pending | Blocks until user approval |
+| 6. COMPLETE | [ ] pending | |
diff --git a/festivals/CV0001/001_INGEST/output_specs/PRESENTATION.md b/festivals/CV0001/001_INGEST/output_specs/PRESENTATION.md
new file mode 100644
index 0000000..477cd8d
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/output_specs/PRESENTATION.md
@@ -0,0 +1,40 @@
+# 001_INGEST — presentation
+
+## What we are building
+
+1. `cans say` gains `-o` (write the wav where told, don't play, don't delete) and stdin (`echo x | cans say`, `cans say -`).
+2. `--stream` speaks one utterance per stdin line over **one** warm `Session` — one model load instead of N.
+3. `--json` puts JSONL records on stdout, flushed per utterance; prose stays on stderr.
+4. A mouth lock (`flock` on `CANS_HOME/mouth.lock`) keeps exactly one worker resident under any loop, `xargs -P`, or second terminal. `--nowait` → 75, `--wait` bounds the block.
+5. A pipe tape, a README scripting section, and the CV0001 festival tree in the public repo.
+
+## Locked — do not re-ask
+
+| ID | Decision |
+|----|----------|
+| D001 | Lock lifetime = `Session` lifetime; the booth holds it for its whole run |
+| D002 | One worktree, one branch (`cans-v2`), one PR |
+| D003 | stdlib `syscall.Flock`, non-blocking acquire polled under `ctx`; lock never deleted |
+| D004 | `say` flags interleave with text both orders, like `parseKeep`; exactly 7 flags, nothing else |
+| D005 | Blank stream lines skipped (no index consumed); other failures continue, exit 1 at EOF |
+| D006 | `{"wav","ttfa_ms","sample_rate"}`; stream adds `"line":N`; bare `-o` prints the path; plain `say` keeps `ttfa_ms=N` |
+| D007 | `--stream` without `-o` plays each line in order |
+
+## P0 (20 items, all cited in `requirements.md`)
+
+`-o` / `--play` · stdin one-shot / `-` / TTY-empty = exit 2 · `--stream` over one `Session` · `%03d` template · per-line failure policy · stream-to-speakers · `--json` records · exit codes 0/1/2/75 · **stdout data, stderr prose** · **`cans say "x"` unchanged from `1e8cea2`** · interleaved flag grammar · mouth lock + wait line + `--nowait`/`--wait` · booth holds the lock · ctrl-C / `kill -9` safety · fake-worker testable.
+
+P1: pipe tape, README scripting section, CV0001 snapshot, measurements with the command that produced them.
+P2 (**do not build**): `cansd`, mid-utterance cancel, `ttfa_ms` fix, ref-text change, re-cut booth GIF, FIFO fairness.
+
+## Interpretations I made
+
+- Freeze point for "unchanged" is **`1e8cea2`**, not the pack's `07ddf48` — the pack predates PR #13. Same behavior, newer base.
+- Phase names follow the real tree (`002_PLAN`, `004_REVIEW`), not the pack's `001_PLAN` / `003_REVIEW`; worktree is `cans-v2`, not the pack's `cans-v2-out`.
+- Ctrl-C safety and fake-worker testability are **P0**, not quality-nice-to-have: they are items 3, 4 and 6 of the ship bar.
+- `--play` is folded into the `-o` surface rather than counted as a sixth "v2 is" item.
+- The pack left the booth-vs-lock call open for planning; `CONTEXT.md D001` already closed it, so it is not re-opened.
+
+## The ask
+
+Approve `purpose.md` / `requirements.md` / `constraints.md` / `context.md` so `002_PLAN` can size the measurements and scaffold `01_out` → `05_snapshot`. Confirm P2 stays parked and that the two low open questions (default ref text, non-infinite `--wait`) stay with the operator.
diff --git a/festivals/CV0001/001_INGEST/output_specs/README.md b/festivals/CV0001/001_INGEST/output_specs/README.md
new file mode 100644
index 0000000..98eda08
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/output_specs/README.md
@@ -0,0 +1,18 @@
+# Output Specifications
+
+Structured output from the ingest phase will be created here.
+
+## Expected Documents
+
+| Document | Purpose |
+|----------|---------|
+| `purpose.md` | Festival purpose, success criteria, motivation |
+| `requirements.md` | Prioritized requirements (P0/P1/P2) with traceability |
+| `constraints.md` | Technical and process constraints |
+| `context.md` | Prior art, related systems, key references |
+
+## Quality Standards
+
+- Requirements should be traceable to input sources
+- Constraints should explain "why" not just "what"
+- All documents require user approval before phase completion
diff --git a/festivals/CV0001/001_INGEST/output_specs/constraints.md b/festivals/CV0001/001_INGEST/output_specs/constraints.md
new file mode 100644
index 0000000..fc6653c
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/output_specs/constraints.md
@@ -0,0 +1,38 @@
+# Constraints — cans v2 (CV0001)
+
+Each constraint carries its reason. A constraint without a reason is a preference, and preferences do not survive a subagent.
+
+## Technical
+
+| Constraint | Why | Source |
+|-----------|-----|--------|
+| **Exactly one `qwen3-tts-worker` resident, ever.** | The whole point of the lock. Eight resident copies of the model weights in unified memory is the `xargs -P 8` failure. | `design-queue.md §Limits`, `FESTIVAL_RULES.md` |
+| **Lock lifetime equals `Session` lifetime**, and the booth holds it for its whole run. | Releasing per line while keeping the worker resident would let a second worker load — the exact failure the lock exists to prevent. Closing the booth's session per line throws away the warm path that is the booth's point. | `CONTEXT.md D001` |
+| **`flock` via stdlib `syscall.Flock`**, non-blocking acquire polled under `ctx` (~50–100 ms backoff). Lock file `CANS_HOME/mouth.lock`, created if missing, never deleted. Acquire **before** `StartWorker`, release **after** `Client.Close` returns. | No new dependency. The kernel drops the lock on death, so ctrl-C, a panic, or `kill -9` cannot wedge the next run — this is why `flock` beats a PID file with staleness heuristics. Polling keeps cancellation and `--wait` trivial. Acquiring before the worker starts is what makes "one resident worker" true rather than approximately true. | `CONTEXT.md D003`, `design-queue.md §Lock mechanics` |
+| **No new module dependencies.** `golang.org/x/sys` is already indirect; do not promote it without need. | Every dependency is a maintenance surface on a tool whose selling point is that you can read it in one sitting. | `user-direction.md §Standing rules`, `FESTIVAL_RULES.md` |
+| **No Python in the shipped payload.** `tapes/render-demo-tape.py` is dev tooling and stays. | `WI-8b1c5d` (`1e8cea2`) just removed it. Do not put it back. | `seed.md`, `FESTIVAL_RULES.md` |
+| **Do not import `projects/veronica-voice` or `qwen3-tts-native` into `go.mod`.** | The CLI ships as a Go binary plus a native worker, not as an engine build. | `FESTIVAL_RULES.md` |
+| **Everything new must be fake-worker testable**: `CANS_WORKER_BIN=internal/tts/testdata/fakeworker`, `CANS_NOPLAY=1` in tests. | If the stream and lock paths need a real mouth, CI loses its teeth and the tests only run on one machine. | `design-queue.md §Stream mode` constraint 2, `FESTIVAL_RULES.md §Code` |
+| **Files under 500 lines, functions under 50.** `internal/tts/worker.go` is at 196 — add files, do not grow it. | Single responsibility, and the tool stays readable. | `FESTIVAL_RULES.md §Code` |
+| **`context.Context` first on anything doing I/O**; check `ctx.Err()` before long work; honor cancellation through to the worker. | `WI-7eb171` already did this end to end (`SayWith(ctx,…)`, `exec.CommandContext`, `readLine` selecting on `ctx.Done()`). New code that breaks the chain breaks ctrl-C. | `FESTIVAL_RULES.md §Code`, `design-surface.md §What the merge changed` #3 |
+| **The worker has no mid-utterance abort; Ctrl-C terminates it instead (D014).** Document what actually happens; do not pretend otherwise. | A line can run 17–30 s when the mouth misses end-of-speech (measurements). Waiting that long after Ctrl-C is worse than a clean terminate, and nothing is lost: the in-flight wav is never written on cancel, the kernel frees memory and the `flock`. | `design-queue.md §Stream mode` constraint 5, `CONTEXT.md §Deferred` |
+| **Backpressure is inherent — do not add a buffer.** Go writes the next `synthesize` only after the previous `final`. | A fast producer cannot queue unbounded work inside the worker, so there is no buffer to size and no knob to expose. | `design-queue.md §Stream mode` constraint 4 |
+| **Wrap errors with the failing operation** (`fmt.Errorf("say: %w", err)` is the project's established style). | Matches the existing code; a bare error in a 200-line stream is useless. | `FESTIVAL_RULES.md §Code` |
+| **`gofmt -l .` empty, `go vet ./...` clean, `just test unit` green before every gate.** | Cheap gates catch cheap mistakes before a reviewer spends attention on them. | `FESTIVAL_RULES.md §Code` |
+| **Measure before you size.** Real RSS and real GGUF load time come first; every limit and README claim quotes measured numbers with the command that produced them. | The pack's estimates predate the native mouth. Going native changed the mechanism; the numbers must be re-taken, not inherited. | `design-recommend.md §Festival shape`, `FESTIVAL_RULES.md §Process` |
+| Assume `main` at or after `1e8cea2`; `syscall.Flock` on darwin/arm64 (cans is darwin-only). | Stated active assumptions; a different base invalidates the seams this design leans on. | `CONTEXT.md §Active assumptions` |
+
+## Process
+
+| Constraint | Why | Source |
+|-----------|-----|--------|
+| **One worktree, one branch, one PR.** `projects/worktrees/cans/cans-v2` → branch `cans-v2` → PR to `main`. Never edit `projects/cans` directly. | Operator direction: "open a pr when it's done". Supersedes the pack's worktree-per-sequence. | `CONTEXT.md D002`, `user-direction.md` #4 |
+| **`fest commit -m ": "` in sequences; `camp p commit --no-sync` otherwise. Never raw `git commit`.** | Festival traceability, and `--no-sync` keeps a feature worktree from moving the submodule pointer. | `FESTIVAL_RULES.md §Process`, campaign `CLAUDE.md` |
+| **Git author is Veronica / `318153306+veronica-agent@users.noreply.github.com`; `gh auth switch -u veronica-agent` before any push or PR.** Remote `git@github-veronica-agent:veronica-agent/cans.git`. | The public repo is hers. A commit authored by anyone else on that branch is a leak of the campaign split. | `FESTIVAL_RULES.md`, `user-direction.md §Standing rules` |
+| **No "Co-authored-by" or AI attribution in commit messages.** | Standing campaign rule. | `user-direction.md §Standing rules` |
+| **The public surface stays professional.** Every README / tape / fixture / festival line passes the campaign phrase lock (`docs/phrases/NEVER.md`, campaign-private) and the professional-surface grep (pattern in `CONTEXT.md §Professional grep`, campaign-private): no pitch, no suggestive example text. Example text is boring and technical — `"Put the cans on."` is fine. | `veronica-agent`'s public GitHub is an engineer's professional surface. One stray line in a public README breaks that permanently. | `design-fest-ad.md §The professional lock`, `FESTIVAL_RULES.md` |
+| **The reviewer is a different agent than the implementer**, and reads the diff cold. | An agent that wrote the code cannot see what it assumed. | `FESTIVAL_RULES.md §Process` |
+| **Sequence order is load-bearing: `01_out` → `02_lock` → `03_stream` → `04_tape` → `05_snapshot`.** Do not start `03_stream` before `02_lock` is committed. | `01_out` is the smallest shippable thing and unblocks the tape. `02_lock` makes the naive path **safe** before `03_stream` makes the fast path **fast** — so a festival that stalls after two sequences still leaves a safe tool, just not yet a quick one. `04_tape` needs both `-o` and `--stream` to demo honestly. `05_snapshot` is last so it captures the finished tree and the final professional recheck. | `design-recommend.md §Festival shape`, `FESTIVAL_OVERVIEW.md §003_IMPLEMENT`, `FESTIVAL_RULES.md §Process` |
+| **Record every number you measure** in the task file or `002_PLAN/inputs/measurements.md`, with the command that produced it. | A margin nobody can reproduce is marketing. | `FESTIVAL_RULES.md §Process` |
+| **Update `CONTEXT.md` when a decision is made or a blocker is hit.** Approval checkpoints are taken by the orchestrating agent on the operator's delegation and logged there with the basis. | The operator delegated execution in one instruction; logging each approval is what lets them veto after the fact. | `CONTEXT.md §Approvals`, `user-direction.md` #4 |
+| **Do not re-open what the pack closed** (no ingestion, no daemon) or what `CONTEXT.md` decided (D001–D007). | The pack was reviewed and accepted. Ingest is restructuring, not discovery. | `001_INGEST/PHASE_GOAL.md §Notes` |
diff --git a/festivals/CV0001/001_INGEST/output_specs/context.md b/festivals/CV0001/001_INGEST/output_specs/context.md
new file mode 100644
index 0000000..76223dd
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/output_specs/context.md
@@ -0,0 +1,63 @@
+# Context — cans v2 (CV0001)
+
+## Where this sits
+
+The design pack (`workflow/design/cans-v2`, `WI-a2e393`) was reviewed and accepted. This festival builds it. Base: `origin/main` at or after `1e8cea2`. Worktree `projects/worktrees/cans/cans-v2`, branch `cans-v2`, linked to `WI-a2e393`.
+
+## Prior art — the dependencies that made this slice small
+
+### `WI-7eb171` — the native mouth (PR #7/#8, main at `07ddf48`)
+
+Replaced the Python/MLX sidecar with `qwen3-tts-worker` (C++/GGML/Metal). It shipped **three things the earlier draft of this pack listed as v2 work**:
+
+| Previously designed as v2 work | Delivered by `WI-7eb171` |
+|---|---|
+| A JSONL request/response protocol over a long-lived child | `qwen3-tts-worker/v1` — `ready` handshake, `{"type":"synthesize","id","text","ref_wav"}`, `pcm_meta`/`final`, `{"type":"shutdown"}` (`internal/tts/worker.go`) |
+| A warm-worker abstraction | `Session` — *"a warm worker for one booth (or one say)"* (`internal/tts/session.go:16`), `Open(ctx)` / `Say(ctx,text,throat)` / `Close()` |
+| `ctx` through to the child | `SayWith(ctx,…)` (`synth.go:37`), `exec.CommandContext` (`worker.go:46`), `readLine` selecting on `ctx.Done()` (`worker.go:158-163`) |
+| A streaming reader replacing buffered `lastJSONLine` | `bufio.Reader` line loop (`worker_pcm.go:13-52`) |
+
+So **stream mode is no longer a protocol build** — it is open once, loop, close. What is genuinely new in v2 is the mouth lock plus the CLI surface. (`design-queue.md §What the merge already gave us`)
+
+### `WI-8b1c5d` — drop the Python sidecar (PR #13, `1e8cea2`, merged 2026-08-21)
+
+The shipped payload is Go + native worker only. `internal/ship/fs/` no longer unpacks `pyproject.toml`, `uv.lock`, `sidecar/say.py` for a path nothing calls. This closed finding #2 from `design-surface.md §Two findings` and is the base commit for every v2 worktree. (`seed.md §Dependencies — satisfied`)
+
+## Key code locations the design cites
+
+| Location | What it is / why v2 touches it |
+|---|---|
+| `internal/tts/synth.go:37-50` `SayWith` | Opens a worker and **defers `Close` per call** — the one-shot tax. Also the `CANS_SAY_BIN` fake-binary hook (`synth.go:41-43` → `synth_bin.go`). |
+| `internal/tts/session.go:16` `Session`, `:54-60` | The warm-worker abstraction stream mode loops over. `Say` receives PCM and calls `audio.WritePCM16` to a **generated temp path** — `-o` passes the caller's path to that writer instead and skips `RemoveTemp`. Fewer moving parts than the pack's original sidecar-`--out` plan. |
+| `internal/booth/booth.go:143-161` `Run` | Opens **one** `Session` in `Run` and reuses it for the whole TUI session (`defer sess.Close()`). The proof the warm pattern is cheap, and the place the lock is held for a human-length span (D001). |
+| `internal/tts/worker.go` | `StartWorker` (`:46`), the `qwen3-tts-worker/v1` protocol, `readLine` ctx select. **196 lines — add files, do not grow it.** |
+| `internal/tts/worker_pcm.go:13-52` | The PCM read loop. `wall` is stamped at `final` (`:42-47`), which is why `ttfa_ms` is really total synthesis time (P2-3). |
+| `internal/tts/testdata/fakeworker/main.go` | The fake JSONL worker. `CANS_WORKER_BIN` points at it so CI runs the stream and lock paths without a real mouth. |
+| `cmd/cans/main.go:56-78` (`say` case) | Today: argv joined, `doctor.Prepare`, `tts.Say`, print `ttfa_ms=N`, `play.File`, `tts.RemoveTemp`. This is the behavior P0-17 freezes. |
+| `cmd/cans/main.go:109-140` `parseKeep` | **The interleaved-flag precedent.** It already accepts `keep take.wav -text words` and `keep -text words take.wav` by collecting positionals in a hand-rolled loop. `say` follows the same shape (D004) because stdlib `flag` stops at the first positional and the `design-pipes.md` loop examples put the text first. |
+| `internal/play/play.go:12-30` `File` | `CANS_NOPLAY=1` skips playback **after** validating the wav header. It stays what it is: a test hook. `-o` is the supported way to be headless. |
+| `internal/keep/keep.go` | The frozen throat (`keep.Load`) every path uses. `:42` holds the shipped default ref text flagged for the operator (P2-4). |
+| `CANS_HOME` (default `~/.cans`) | `current/` (throat `ref.wav` + `current.json`), `native/bin/qwen3-tts-worker` (`CANS_WORKER_BIN` overrides), `native/models` (`CANS_WORKER_MODELS`), `shipped/`. **`mouth.lock` is new and lives here.** |
+
+## The snapshot precedent — CA0001
+
+The v1 festival tree is already committed in the public repo under `projects/cans/festivals/CA0001/` (`3c20108`) with the standard shape (`fest.yaml`, `FESTIVAL_GOAL.md`, `FESTIVAL_OVERVIEW.md`, `FESTIVAL_RULES.md`, `TODO.md`, `gates/`, `001_INGEST` … `004_REVIEW`). A stranger who clones can read how v1 was planned without installing `fest`. `05_snapshot` lands **CV0001** the same way, as the second readable plan. (`design-fest-ad.md §What v2 adds`)
+
+## The fest-ad chrome rules
+
+The repo advertises Festival through **chrome** — a topic, a readable `festivals/` tree, one footer line — never through a pitch in her mouth. (`design-fest-ad.md`)
+
+| Lever | v2 |
+|---|---|
+| Tape | **Adds one**: a script piping lines in, wavs appearing on disk. Next to the booth GIF, not replacing it. No narration, no pitch, no claim about what she is. |
+| `festivals/` | **Adds one plan** (CV0001). The ad compounds, one plan per slice. |
+| Topic | Unchanged — `festival-methodology` plus `tts` / `local-ai`. |
+| Footer | Unchanged — **Built with [Festival](https://fest.build)**, one line, shipped at `c72d4a0`. Not re-litigated, not duplicated. |
+| Author | `veronica-agent`, via `fest commit` / `camp p commit` in the worktree. |
+
+Verification: the professional-surface grep (`CONTEXT.md §Professional grep`, campaign-private) is empty over README, docs/, tapes/ and the snapshot; exactly one footer; `festivals/` holds two readable plans; `cans` still runs with `fest` uninstalled; `just vhs ` regenerates the new tape.
+
+## Open questions carried in (both low, both for the operator — not this festival's call)
+
+- The shipped default ref text `"Just like that, feel the rhythm of my voice."` (`internal/keep/keep.go:42`) predates the professional lock. The native mouth no longer sends it, but `keep` stores it and `doctor` can surface it. Changing the text without changing `ref.wav` would make it a false transcript — a voice-lock call.
+- Whether `--wait` should have a non-infinite default for the booth. The festival ships infinite with the stderr line; revisit if it annoys.
diff --git a/festivals/CV0001/001_INGEST/output_specs/purpose.md b/festivals/CV0001/001_INGEST/output_specs/purpose.md
new file mode 100644
index 0000000..6ba177c
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/output_specs/purpose.md
@@ -0,0 +1,57 @@
+# Purpose — cans v2 (CV0001)
+
+## The slice in one line
+
+**Cans becomes a unix primitive a script can drive over a document: text in from argv or stdin, wav out where you point it, and one mouth at a time no matter how the script loops.** (`design-recommend.md §The slice in one line`, `seed.md`)
+
+The script owns the document. Cans speaks what it is handed. (`user-direction.md` #2, `design-pipes.md §The boundary`)
+
+## What v2 is
+
+| Item | Shape | Source |
+|------|-------|--------|
+| `-o take.wav` | write the wav instead of playing and deleting it | `design-recommend.md §v2 is` |
+| stdin | `echo hi \| cans say`, `cans say -`; empty argv on a TTY stays a usage error | `design-pipes.md §Text in` |
+| `--stream` | one utterance per stdin line over **one** `Session`, records flushed per line | `design-pipes.md §Text in` |
+| `--json` | JSONL records on stdout; prose on stderr | `design-pipes.md §Streams and exit codes` |
+| mouth lock | `flock` on `CANS_HOME/mouth.lock`, held for the lifetime of one `Session` | `design-queue.md §Lock mechanics` |
+
+The lock is the only genuinely new machinery. Everything else is plumbing onto what `WI-7eb171` already shipped. (`design-queue.md §What the merge already gave us`)
+
+## Why it matters
+
+**The one-shot tax.** `tts.SayWith` opens a worker and defers `Close` per call (`internal/tts/synth.go:44-48`), and every `Open` loads the GGUF model before answering `ready`. A 200-line loop pays 200 model loads. The booth already runs warm on one `Session` (`internal/booth/booth.go:149-156`); the CLI does not. (`design-surface.md §What did not change: the one-shot tax`)
+
+**The `xargs -P 8` failure.** Eight concurrent `cans say` calls put eight workers with model weights resident in unified memory. Nothing refuses, nothing waits — swap, thrash, wedge. Going native changed the mechanism (C++/GGML/Metal instead of Python/MLX); it did not change the arithmetic. (`design-queue.md §The problem, stated from the code`)
+
+Stream mode fixes the first. The lock fixes the second. Neither adds a background process or persisted state. (`design-recommend.md §Why this shape`)
+
+**The fest-ad tape.** The pipe tape is the honest demo of what v2 is for: a terminal, a document the *script* walks, and files landing in `out/`. No narration, no pitch. It sits next to the booth GIF; it does not replace it. (`design-fest-ad.md §What v2 adds`)
+
+**The public festival tree.** The v2 festival lands under `projects/cans/festivals/CV0001/` as the second readable plan a stranger can read without installing `fest`. The ad compounds — one plan per slice. (`design-fest-ad.md §What v2 adds`)
+
+## Done criteria
+
+Functional — from `design-recommend.md §Ship verification` and `design-queue.md §The bar`:
+
+1. `cans say "x" -o take.wav` writes the wav, does not play, does not delete; stdout is the path.
+2. `echo x | cans say` and `cans say -` read one utterance from stdin; empty argv on a TTY is still exit 2.
+3. `cat lines | cans say --stream -o 'out/%03d.wav'` writes one wav per line over **one** `Session`, with exactly one GGUF load.
+4. `--json` emits one record per utterance on stdout, flushed as each finishes; prose is on stderr.
+5. `xargs -P 8` over 50 lines: **one** `qwen3-tts-worker` at every `pgrep` sample, no swap.
+6. Ctrl-C mid-stream keeps completed wavs, leaves no orphaned worker and no held lock; `kill -9` leaves the next run unblocked.
+7. A booth session and a background script never interleave audio.
+8. A second VHS tape shows a script piping lines in and wavs landing on disk.
+
+Quality:
+
+9. `cans say "x"` behaves exactly as at `1e8cea2`; every existing test passes.
+10. Stream and lock paths are tested on the fake worker (`internal/tts/testdata/fakeworker`) — CI needs no real mouth.
+11. A 200-line stream beats the 200-call loop by a **recorded** margin; both numbers live in this festival.
+12. `just test unit`, `go vet ./...`, `gofmt -l .` clean; files under 500 lines, functions under 50.
+13. README, docs, tapes and the snapshot pass the professional-surface grep (`CONTEXT.md §Professional grep`, campaign-private); exactly one Festival footer. (`design-fest-ad.md §Verification for the ad`)
+14. `fest validate` green; `projects/cans/festivals/CV0001/` holds the readable snapshot.
+
+## Not the point
+
+Making cans a document reader, a daemon, or a config surface. Every "cans does not" in `design-pipes.md §The boundary` is the script's job — `sed`, `jq`, `awk`, and a `for` loop already do them better, and each refusal keeps the tool small enough to read in one sitting.
diff --git a/festivals/CV0001/001_INGEST/output_specs/requirements.md b/festivals/CV0001/001_INGEST/output_specs/requirements.md
new file mode 100644
index 0000000..dae958a
--- /dev/null
+++ b/festivals/CV0001/001_INGEST/output_specs/requirements.md
@@ -0,0 +1,70 @@
+# Requirements — cans v2 (CV0001)
+
+Every requirement cites the input it came from. `CONTEXT.md` decisions D001–D007 are already made and are not re-opened here.
+
+## P0 — the slice. Without these there is no v2.
+
+| # | Requirement | Source |
+|---|-------------|--------|
+| P0-1 | `-o take.wav` writes the wav to the caller's path, does **not** play, does **not** delete. `Session.Say` already writes in Go via `audio.WritePCM16`; `-o` passes the caller's path to that writer and skips `RemoveTemp`. | `design-pipes.md §Audio out`, `design-recommend.md §v2 is` |
+| P0-2 | `--play` with `-o` writes **and** plays, for a human watching a script run. | `design-pipes.md §Audio out` |
+| P0-3 | stdin is one utterance when argv is empty: `echo hi \| cans say`, and `cans say -` explicitly. Whole input is one utterance. | `design-pipes.md §Text in` |
+| P0-4 | Empty argv **and** stdin is a TTY → the usage error it is today (exit 2), not a hang. | `design-pipes.md §Text in` |
+| P0-5 | `--stream` changes stdin from one utterance to one utterance per line, spoken over **one** `Session`: open once, loop `Say`, close at EOF. | `design-pipes.md §Text in`, `design-queue.md §Stream mode` |
+| P0-6 | `-o 'out/%03d.wav'` in stream mode: one file per line, index substituted. A bad template is exit 2. | `design-pipes.md §Audio out`, `§Streams and exit codes` |
+| P0-7 | Stream per-line policy: blank stdin lines are **skipped and do not consume an index**; any other per-line failure is reported on stderr (and as `{"line":N,"error":"…"}` under `--json`), the stream continues, and the exit code at EOF is 1 if any line failed. | `CONTEXT.md D005`, `design-pipes.md §Streams and exit codes` |
+| P0-8 | `--stream` without `-o` plays each line through the speakers in order. | `CONTEXT.md D007` |
+| P0-9 | `--json` emits JSONL on stdout, **flushed as each utterance finishes**. One-shot record: `{"wav":…,"ttfa_ms":…,"sample_rate":…}` (the existing `tts.Result` tags). Stream adds `"line":N`, 1-based on the stdin line. | `CONTEXT.md D006`, `design-pipes.md §Streams and exit codes` |
+| P0-10 | `-o` without `--json` prints the wav path on stdout. No `-o`, no `--json`: `ttfa_ms=N` exactly as today. | `CONTEXT.md D006` |
+| P0-11 | Mouth lock: `flock(2)` on `CANS_HOME/mouth.lock`, held for the lifetime of one `Session`. Any loop, any `xargs -P`, any second terminal: exactly one `qwen3-tts-worker` resident. | `design-queue.md §Lock mechanics`, `CONTEXT.md D003` |
+| P0-12 | A blocked caller writes one line to **stderr** (`waiting for the mouth…`) so a script that looks hung explains itself. Never stdout. | `design-queue.md §Lock mechanics` |
+| P0-13 | `--nowait` exits 75 immediately; `--wait ` bounds the block; default waits forever, because for a document script blocking *is* correct. | `design-queue.md §Lock mechanics`, `§Limits` |
+| P0-14 | The booth takes the lock and holds it for its whole run. A script started alongside it waits (stderr line) or exits 75 with `--nowait`; a booth started while a script holds the mouth waits the same way before the TUI opens. | `CONTEXT.md D001`, `design-queue.md §Lock mechanics` |
+| P0-15 | Exit codes: `0` spoke it · `1` runtime failure (worker died or missing, bad wav, disk) · `2` usage (no text, unknown flag, bad `-o` template) · `75` busy with `--nowait` (`EX_TEMPFAIL`, so `xargs` and retry loops read it correctly). | `design-pipes.md §Streams and exit codes` |
+| P0-16 | **stdout is data, stderr is prose. They never mix.** stdout carries only the v1 `ttfa_ms=` line, wav paths, or JSONL. Progress, waiting, and errors go to stderr. | `design-pipes.md §Streams and exit codes`, `FESTIVAL_RULES.md` |
+| P0-17 | **`cans say "x"` behaves exactly as at `1e8cea2`.** One-shot stays one-shot: prints `ttfa_ms=N`, plays, deletes the temp wav. Every existing test passes unchanged. | `seed.md`, `user-direction.md §Standing rules`, `design-queue.md §Stream mode` constraint 1 |
+| P0-18 | Flags interleave with text in **both** orders, the way `keep` already parses (`cmd/cans/main.go:109` `parseKeep`): `cans say "$line" -o out.wav` and `cans say -o out.wav "$line"` both work. The flag set is exactly `-o`, `--json`, `--stream`, `--play`, `--nowait`, `--wait `, and `-`. Nothing else. | `CONTEXT.md D004` |
+| P0-19 | Ctrl-C mid-stream: stop reading stdin, `Close` the session (graceful `shutdown` if the worker is idle; SIGTERM then SIGKILL if it is mid-utterance — D014), leave completed wavs in place, release the lock, report the line it stopped on. `kill -9` leaves the next run unblocked — the kernel drops the `flock`. | `design-queue.md §Cancellation and partial output`, `§Lock mechanics` |
+| P0-20 | The stream and lock paths are fakeable: tests run against `internal/tts/testdata/fakeworker` via `CANS_WORKER_BIN`, with `CANS_NOPLAY=1`, so `go test ./...` and CI need no real mouth. | `design-queue.md §Stream mode` constraint 2, `FESTIVAL_RULES.md §Code` |
+
+## P1 — the slice is not delivered without these, but the tool works.
+
+| # | Requirement | Source |
+|---|-------------|--------|
+| P1-1 | A second VHS tape (`just vhs pipe`) showing a script piping lines in and wavs appearing in `out/`. Sits next to the booth GIF; does not replace it. Reproducible: `just vhs ` regenerates it. | `design-fest-ad.md §What v2 adds`, `§Verification for the ad` #5 |
+| P1-2 | README scripting section: the loops from `design-pipes.md §Loops this makes possible`, the exit-code table, and a plain statement of what Ctrl-C does — per D014: the line being spoken is dropped, finished wavs stay, exit 130, a second Ctrl-C stops at once. | `design-pipes.md`, `design-queue.md §Stream mode` constraint 5, `CONTEXT.md §Deferred` |
+| P1-3 | Snapshot of this festival into `projects/cans/festivals/CV0001/` — the second readable plan in the public tree; `cans` still runs with `fest` uninstalled. | `design-fest-ad.md §What v2 adds`, `§Verification for the ad` #4 |
+| P1-4 | Measurements recorded in the festival with the command that produced each: real resident memory (RSS) for one `qwen3-tts-worker`, real GGUF load time, 200-line stream vs the equivalent 200-call loop, and `pgrep` samples under `xargs -P 8` over 50 lines. Every limit and every README claim quotes these numbers, not the pack's estimates. | `design-recommend.md §Festival shape` ("Measure before you size"), `design-queue.md §The bar`, `FESTIVAL_RULES.md §Process` |
+| P1-5 | Professional-surface grep (pattern in `CONTEXT.md §Professional grep`, campaign-private) is empty over README, docs/, tapes/ and the festival snapshot; exactly one Festival footer, unchanged from `c72d4a0`. Example text in README, tape, and fixtures is boring and technical. | `design-fest-ad.md §The professional lock`, `§Verification for the ad` |
+
+## P2 — parked. **Do not build these.**
+
+| # | Item | Why parked | Source |
+|---|------|-----------|--------|
+| P2-1 | `cansd` daemon — warmth *across* invocations | Real lifecycle, real version skew, and not what makes v2 useful. `Session` is already the client it would wrap, so it stays cheap to add later. Revisit only if a real user asks. | `design-queue.md §Options`, `CONTEXT.md §Deferred` |
+| P2-2 | Mid-utterance abort in the worker protocol | Needs worker support. Ctrl-C terminates a busy worker instead (D014); documented instead of pretended away (P1-2). | `design-queue.md §Stream mode` constraint 5, `CONTEXT.md §Deferred` |
+| P2-3 | `ttfa_ms` semantics fix (`worker_pcm.go` stamps `wall` at `final`, so the field is total synthesis time, not first-audio) | Separate fix; the festival keeps the field's current meaning and says so. | `design-surface.md §Two findings for the operator` #1, `CONTEXT.md §Deferred` |
+| P2-4 | Changing the shipped default ref text (`internal/keep/keep.go:42`) | Changing the text without changing `ref.wav` would make it a false transcript. A voice-lock call for the operator, not this festival's. | `design-fest-ad.md §The professional lock`, `CONTEXT.md §Deferred` |
+| P2-5 | Re-cutting `docs/booth.gif` / `booth.mp4` with the native mouth | Out of the slice. | `CONTEXT.md §Deferred` |
+| P2-6 | A fair / FIFO queue among waiters | `flock` is not FIFO, and for a document script every waiter is doing the same work, so fairness buys nothing. Do not build a fair queue for a problem nobody has. | `design-queue.md §Lock mechanics` |
+
+## Negative requirements — these must NOT exist when the festival is done
+
+From `seed.md §v2 is not`, `design-pipes.md §Rejected`, `design-recommend.md §v2 is not`, `FESTIVAL_RULES.md`.
+
+| Must not exist | Why |
+|----------------|-----|
+| `cans read ` | Document ingestion. The script owns the document. |
+| `-f file.txt` | The same thing wearing a flag. |
+| Markdown / code-fence stripping | A text filter, not a mouth. `sed` already exists. |
+| A sentence chunker | Cans speaks what it is handed; the caller decides what a line is. |
+| `--voice name`, a voice picker, SSML | Keep is the only throat change. |
+| A config file | Four flags do not need a config surface. |
+| Any flag not in `design-pipes.md` | "If a task adds a flag that is not in `design-pipes.md`, the task is wrong." (`FESTIVAL_RULES.md`) |
+| A daemon, a job queue file, job IDs, persisted lock state | The lock is `flock`; the kernel cleans up. No state to garbage-collect. |
+| Restyle mid-session | Same woman does not restyle mid-scene; keep stays the only throat change. |
+| Mic, VAD, replies, browser booth, radio | Still parked from v1. |
+| New module dependencies | The lock uses stdlib `syscall.Flock`. `golang.org/x/sys` stays indirect. |
+| Python in the shipped payload | `tapes/render-demo-tape.py` is dev tooling and stays; nothing else. |
+| `projects/veronica-voice` or `qwen3-tts-native` in `go.mod` | Engine stays out of the CLI module. |
+| A second Festival footer line, or re-litigating the first | It shipped at `c72d4a0` as chrome and stays as shipped. |
diff --git a/festivals/CV0001/002_PLAN/GATES.md b/festivals/CV0001/002_PLAN/GATES.md
new file mode 100644
index 0000000..ae605fa
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/GATES.md
@@ -0,0 +1,58 @@
+---
+fest_type: phase_gate
+fest_id: 002_PLAN-GATE
+fest_parent: 002_PLAN
+---
+
+# Planning Phase Gate
+
+This gate verifies the planning phase achieved its goal and produced an approved, valid plan.
+
+---
+
+## Step 1: PHASE GOAL — Verify Goal Achievement
+
+**Question:** Does the plan address the stated planning objective? Is the planned approach sound and complete?
+
+**Actions:**
+1. Re-read PHASE_GOAL.md and compare stated objectives against the produced plan
+2. Verify the plan covers all aspects of the planning objective
+3. Confirm the approach is feasible and the decomposition is appropriate
+
+**Checkpoint:** APPROVAL REQUIRED — Confirm planning goal is met
+
+---
+
+## Step 2: APPROVAL — Verify User Sign-Off
+
+**Question:** Did the user explicitly approve the plan?
+
+**Actions:**
+1. Confirm the plan received user approval before scaffolding
+2. Verify any user feedback was incorporated
+3. Check that the plan was not scaffolded without approval
+
+**Checkpoint:** APPROVAL REQUIRED — Confirm user approved the plan
+
+---
+
+## Step 3: STRUCTURE — Verify Festival Integrity
+
+**Question:** Is the scaffolded festival structurally valid?
+
+**Actions:**
+1. Run `fest validate` and confirm it passes
+2. Verify no `[REPLACE: ...]` markers remain in any document
+3. Confirm phases are properly ordered with clear goals
+
+**Checkpoint:** APPROVAL REQUIRED — Confirm structure is valid
+
+---
+
+## Gate State Tracking
+
+| Step | Status | Notes |
+|------|--------|-------|
+| 1. PHASE GOAL | [ ] pending | Planning goal achieved |
+| 2. APPROVAL | [ ] pending | User sign-off |
+| 3. STRUCTURE | [ ] pending | Festival integrity |
diff --git a/festivals/CV0001/002_PLAN/PHASE_GOAL.md b/festivals/CV0001/002_PLAN/PHASE_GOAL.md
new file mode 100644
index 0000000..cf9ad52
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/PHASE_GOAL.md
@@ -0,0 +1,77 @@
+---
+fest_type: phase
+fest_id: 002_PLAN
+fest_name: PLAN
+fest_parent: cans-v2-CV0001
+fest_order: 2
+fest_status: completed
+fest_created: 2026-08-21T04:32:49.135275-06:00
+fest_updated: 2026-08-21T05:14:24.841899-06:00
+fest_phase_type: planning
+fest_tracking: true
+---
+
+
+# Phase Goal: 002_PLAN
+
+**Phase:** 002_PLAN | **Status:** Pending | **Type:** Planning
+
+## Phase Objective
+
+**Primary Goal:** Plan architecture, design decisions, and task breakdown
+
+**Context:** The pack left one contentious call open (does the booth hold the mouth lock?), asked for real measurements before any limit is sized, and the operator changed the delivery shape to one PR. Those have to be settled and the sequences scaffolded before a subagent can execute a task file.
+
+## Exploration Topics
+
+What areas need to be explored during this phase:
+
+- Lock lifetime vs the booth (D001 in CONTEXT.md — confirm against `internal/booth/booth.go`)
+- Where the lock lives in code so every `Session` is guarded by construction
+- Flag parsing that tolerates interleaved flags and text (the `keep` precedent in `cmd/cans/main.go`)
+- Real worker load time and resident memory on this Mac (`inputs/measurements.md`)
+- What the fake worker can and cannot exercise (cancel, lock contention)
+
+
+
+## Key Questions to Answer
+
+Questions that must be answered before this phase is complete:
+
+- Does the booth hold the lock for its whole run? (Yes — D001.)
+- One PR or one per sequence? (One — D002.)
+- Blank stdin lines in stream mode: error or skip? (Skip — D005.)
+- Which sequence owns the 200-line stream-vs-loop measurement? (`03_stream`.)
+
+
+
+## Expected Documents
+
+Documents that will be produced during this phase:
+
+- `inputs/measurements.md` — worker load time, RSS, cold `cans say` wall, with commands
+- `decisions/D001…D007` + `INDEX.md` — the decisions above, one file each
+- `plan/STRUCTURE.md` — the tree
+- `plan/IMPLEMENTATION_PLAN.md` — sequences, tasks, dependencies, verification per sequence
+- `003_IMPLEMENT/` and `004_REVIEW/` scaffolded with task files and gates
+
+
+
+## Success Criteria
+
+This planning phase is complete when:
+
+- [ ] Every decision has a file and a one-line rationale
+- [ ] Measurements recorded with the command that produced them
+- [ ] `003_IMPLEMENT` has five sequences, each with task files and four gates; `004_REVIEW` has the bar
+- [ ] `fest validate` passes with zero markers
+
+
+
+## Notes
+
+Planning is done by the orchestrating agent, which holds the full design context. The PRESENT checkpoint is approved on the operator's delegation and logged in CONTEXT.md.
+
+---
+
+*Planning phases use freeform structure. Create topic directories as needed.*
\ No newline at end of file
diff --git a/festivals/CV0001/002_PLAN/WORKFLOW.md b/festivals/CV0001/002_PLAN/WORKFLOW.md
new file mode 100644
index 0000000..49ec1b5
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/WORKFLOW.md
@@ -0,0 +1,176 @@
+---
+fest_type: workflow
+fest_id: 002_PLAN-WF
+fest_parent: 002_PLAN
+---
+
+# Planning Phase Workflow
+
+This document guides the agent through the planning phase. Follow these steps in order, completing each checkpoint before proceeding.
+
+---
+
+## Step 1: REVIEW — Understand the Inputs
+
+**Goal:** Build comprehensive understanding of what needs to be built.
+
+**Actions:**
+1. Read all output specs from ingest phase (if exists)
+2. Read research summary (if exists)
+3. Identify key requirements, constraints, recommendations
+
+**Output:** Mental model of what needs to be planned
+
+**Checkpoint:** None — proceed to Step 2
+
+---
+
+## Step 2: GAP ANALYSIS — Identify What's Missing
+
+**Goal:** Find unclear requirements or needed decisions.
+
+**Actions:**
+1. Note anything unclear or ambiguous
+2. Identify decisions that need to be made
+3. List questions you'd need answered
+4. Create `inputs/gaps.md` if significant gaps exist
+
+**Output:** List of gaps and questions
+
+**Checkpoint:** If critical gaps exist, present to user for clarification
+
+---
+
+## Step 3: DECOMPOSE — Break Down Goals into Festival Structure
+
+**Goal:** Transform requirements into the festival hierarchy (core methodology).
+
+**Actions:**
+1. Identify the **Festival Goal** — what the entire festival accomplishes
+2. Break into **Phase Goals** — major stages of work:
+ - What planning phases are needed?
+ - What implementation phases are needed?
+ - What review phases are needed?
+3. For each phase, identify **Sequence Goals** — groups of related tasks
+4. For each sequence, identify **Task Specifications** — atomic units of work
+5. Document the hierarchy in `plan/STRUCTURE.md`
+
+**Output:** Documented festival structure showing:
+- Phase breakdown with goals
+- Sequence breakdown within phases
+- Task list within sequences
+- Dependencies between components
+
+**Checkpoint:** None — proceed to Step 4
+
+---
+
+## Step 4: DESIGN — Make Architecture Decisions
+
+**Goal:** Make and document key design decisions.
+
+**Actions:**
+1. For each significant decision, document options and tradeoffs
+2. Create `decisions/D###_title.md` for each decision
+3. Update `decisions/INDEX.md`
+
+**Output:** Documented architecture decisions
+
+**Checkpoint:** None — proceed to Step 5
+
+---
+
+## Step 5: STRUCTURE — Create Implementation Plan Document
+
+**Goal:** Define phases, sequences, and tasks in detail.
+
+**Actions:**
+1. Create `plan/IMPLEMENTATION_PLAN.md` with:
+ - Overview of what will be implemented
+ - Phases with their goals
+ - Sequences within each phase
+ - Tasks within each sequence
+ - Dependencies and ordering
+
+**Output:** Complete implementation plan document
+
+**Checkpoint:** None — proceed to Step 6
+
+---
+
+## Step 6: PRESENT — Get User Approval
+
+**Goal:** Verify plan is ready for implementation.
+
+**Actions:**
+1. Summarize the plan (phases, sequences, key decisions)
+2. Note areas of uncertainty
+3. Ask: "Is this plan ready for implementation?"
+
+**Output:** Summary presented to user
+
+**Checkpoint:** APPROVAL REQUIRED — Wait for user response
+
+---
+
+## Step 7: SCAFFOLD — Create Festival Structure
+
+**Goal:** Create the festival directory structure using fest CLI.
+
+**Actions:**
+1. If user rejects: Note feedback, return to relevant step
+2. If user approves, **first learn the structure rules:**
+ a. Run `fest understand structure` — learn the 3-level hierarchy, required files, and what a well-formed festival looks like
+ b. Run `fest understand rules` — learn mandatory naming conventions (phase/sequence/task prefixes), required files at each level, and quality gate placement
+ c. Run `fest understand tasks` — learn when task files are required (implementation phases MUST have them) vs. optional (planning/review/research phases)
+ d. Run `fest understand templates` — learn template variables you can pass to `fest create` to generate pre-filled documents and avoid post-creation editing
+3. **Then scaffold the structure using fest CLI:**
+ a. Create phases: `fest create phase --type `
+ b. Create sequences: `fest create sequence `
+ c. Create tasks: `fest create task --name ""`
+4. **For each created file:**
+ - Read the template that was used
+ - Replace ALL `[REPLACE: ...]` markers with actual values
+ - Ensure no markers remain unfilled
+
+**Output:** Scaffolded festival structure with all markers filled
+
+**Checkpoint:** None — proceed to Step 8
+
+---
+
+## Step 8: VALIDATE — Verify Structure and Apply Gates
+
+**Goal:** Ensure festival is structurally valid and ready for execution.
+
+**Actions:**
+1. Run `fest validate` to check festival structure
+2. Fix any validation errors
+3. **Fill gate markers at phase level:**
+ - Navigate to each implementation phase's `gates/` directory
+ - Read each gate template file
+ - Replace markers with project-specific values:
+ - Test commands (e.g., `go test ./...`)
+ - Coverage thresholds (e.g., `80%`)
+ - Project-specific verification steps
+4. Run `fest gates apply --approve` to propagate gates to sequences
+5. Run `fest validate` again to confirm no unfilled markers
+
+**Output:** Valid festival structure ready for implementation
+
+**Checkpoint:** None — phase ends
+
+---
+
+## Workflow State Tracking
+
+| Step | Status | Notes |
+|------|--------|-------|
+| 1. REVIEW | [ ] pending | |
+| 2. GAP ANALYSIS | [ ] pending | May checkpoint if critical gaps |
+| 3. DECOMPOSE | [ ] pending | Core methodology step |
+| 4. DESIGN | [ ] pending | |
+| 5. STRUCTURE | [ ] pending | |
+| 6. PRESENT | [ ] pending | Blocks until user approval |
+| 7. SCAFFOLD | [ ] pending | Fill all markers |
+| 8. VALIDATE | [ ] pending | Run fest validate |
diff --git a/festivals/CV0001/002_PLAN/decisions/D001_lock_lifetime_booth.md b/festivals/CV0001/002_PLAN/decisions/D001_lock_lifetime_booth.md
new file mode 100644
index 0000000..fc0e5c4
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D001_lock_lifetime_booth.md
@@ -0,0 +1,7 @@
+# D001 — Lock lifetime equals Session lifetime; the booth holds it
+
+**Decision:** The mouth lock is acquired before `StartWorker` and released after `Client.Close` returns — its lifetime is exactly one `tts.Session`. The booth opens one `Session` for its whole run, so it holds the lock for its whole run. A script started alongside waits (stderr: `waiting for the mouth…`) or exits 75 with `--nowait`. A booth started while a script holds the mouth waits the same way, before the TUI opens.
+
+**Why:** Releasing per line while keeping the worker resident would let a second worker load — two resident workers is the exact failure the lock exists to prevent. Closing the booth's session per line throws away the warm path that is the booth's point.
+
+**Not:** A lock the booth skips. A lock released between utterances.
diff --git a/festivals/CV0001/002_PLAN/decisions/D002_one_pr.md b/festivals/CV0001/002_PLAN/decisions/D002_one_pr.md
new file mode 100644
index 0000000..f6dede2
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D002_one_pr.md
@@ -0,0 +1,7 @@
+# D002 — One worktree, one branch, one PR
+
+**Decision:** All sequences land on branch `cans-v2` in `projects/worktrees/cans/cans-v2` (linked to `WI-a2e393`) through `fest commit`. One PR to `main` when `004_REVIEW` signs off.
+
+**Why:** Operator direction: "open a pr when it's done." Supersedes the pack's worktree-per-sequence.
+
+**Not:** Per-sequence PRs. Editing `projects/cans` directly.
diff --git a/festivals/CV0001/002_PLAN/decisions/D003_flock_stdlib.md b/festivals/CV0001/002_PLAN/decisions/D003_flock_stdlib.md
new file mode 100644
index 0000000..56a0102
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D003_flock_stdlib.md
@@ -0,0 +1,7 @@
+# D003 — flock via stdlib syscall.Flock, polled under ctx
+
+**Decision:** `syscall.Flock(fd, LOCK_EX|LOCK_NB)` on `CANS_HOME/mouth.lock` (created `0644` if missing, never deleted). Acquire loops on `LOCK_NB` with a 100 ms sleep, checking `ctx.Done()` and the `--wait` deadline each turn. Release = `LOCK_UN` + close the fd.
+
+**Why:** No new dependency (`golang.org/x/sys` stays indirect). The kernel drops a `flock` when the holder dies, so Ctrl-C, a panic, or `kill -9` cannot wedge the next run — the reason to prefer it over a PID file with staleness heuristics. Polling keeps cancellation and bounded wait trivial; a blocking `LOCK_EX` in a goroutine cannot be cancelled.
+
+**Not:** A PID file. A socket. FIFO fairness.
diff --git a/festivals/CV0001/002_PLAN/decisions/D004_flag_grammar.md b/festivals/CV0001/002_PLAN/decisions/D004_flag_grammar.md
new file mode 100644
index 0000000..79b84d7
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D004_flag_grammar.md
@@ -0,0 +1,7 @@
+# D004 — say flags interleave with text, both orders
+
+**Decision:** `cans say` parses its arguments the way `keep` already does (`cmd/cans/main.go` `parseKeep`): flags and text may interleave, both orders work. The flag set is exactly `-o/--out `, `--json`, `--stream`, `--play`, `--nowait`, `--wait `, and a bare `-` meaning stdin. Positionals are joined with single spaces into the text. Unknown flag → usage on stderr, exit 2.
+
+**Why:** Stdlib `flag` stops at the first positional; the loop examples in `design-pipes.md` put text first (`cans say "$line" -o out.wav`).
+
+**Not:** A config file. A `--voice`. Any flag not in `design-pipes.md`.
diff --git a/festivals/CV0001/002_PLAN/decisions/D005_blank_lines.md b/festivals/CV0001/002_PLAN/decisions/D005_blank_lines.md
new file mode 100644
index 0000000..21f1555
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D005_blank_lines.md
@@ -0,0 +1,7 @@
+# D005 — Stream: blank lines skipped; other failures continue
+
+**Decision:** In `--stream`, a stdin line that is empty after trimming is skipped and does not consume an output index. Any other per-line failure is reported on stderr (`line N: `), emitted as `{"line":N,"error":"…"}` under `--json`, and the stream continues. At EOF the exit code is 1 if any line failed, else 0.
+
+**Why:** Speaking nothing is meaningless; skipping matches `xargs` and keeps `%03d` indices dense. A 200-line render must not lose 199 lines to one bad one.
+
+**Not:** Aborting on first error. Treating blank lines as errors.
diff --git a/festivals/CV0001/002_PLAN/decisions/D006_records.md b/festivals/CV0001/002_PLAN/decisions/D006_records.md
new file mode 100644
index 0000000..bac183e
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D006_records.md
@@ -0,0 +1,11 @@
+# D006 — stdout records
+
+**Decision:**
+- no `-o`, no `--json`: `ttfa_ms=N` — unchanged from `1e8cea2`
+- `-o path`, no `--json`: the written path, one per line
+- `--json`: `{"wav":"…","ttfa_ms":N,"sample_rate":24000}` per utterance, using the existing `tts.Result` tags; `--stream` adds `"line":N` (1-based stdin line number) as the first field; failures are `{"line":N,"error":"…"}`
+- every record is followed by a flush
+
+**Why:** Scripts need the path or the record; humans keep the v1 line. stdout is data, stderr is prose.
+
+**Not:** Progress on stdout. Mixed formats.
diff --git a/festivals/CV0001/002_PLAN/decisions/D007_stream_plays.md b/festivals/CV0001/002_PLAN/decisions/D007_stream_plays.md
new file mode 100644
index 0000000..6e05afa
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D007_stream_plays.md
@@ -0,0 +1,5 @@
+# D007 — --stream without -o plays each line
+
+**Decision:** `--stream` with no `-o` plays each line through the speakers in order, over the one warm `Session`, printing `ttfa_ms=N` per line (or JSON with `--json`).
+
+**Why:** It is still one mouth — the booth with stdin for a keyboard. Refusing it would be a rule without a reason.
diff --git a/festivals/CV0001/002_PLAN/decisions/D008_interrupt_130.md b/festivals/CV0001/002_PLAN/decisions/D008_interrupt_130.md
new file mode 100644
index 0000000..601a494
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D008_interrupt_130.md
@@ -0,0 +1,7 @@
+# D008 — Interrupted stream exits 130
+
+**Decision:** `cans say` installs `signal.NotifyContext` for SIGINT/SIGTERM. In `--stream`, cancellation stops reading stdin, finishes the in-flight line (the worker has no mid-synth abort), closes the `Session` (`shutdown` + wait), releases the lock, prints `interrupted after line N` on stderr, and exits **130** (128 + SIGINT). Completed wavs stay on disk. One-shot interrupted while waiting for the lock exits 130 too.
+
+**Why:** The pack said only "non-zero." 130 is what every shell user expects from Ctrl-C, and it is distinct from 1 (a line failed) so a rerun script can tell the two apart.
+
+**Not:** Killing the worker mid-utterance. Deleting partial output.
diff --git a/festivals/CV0001/002_PLAN/decisions/D009_public_snapshot.md b/festivals/CV0001/002_PLAN/decisions/D009_public_snapshot.md
new file mode 100644
index 0000000..714ab29
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D009_public_snapshot.md
@@ -0,0 +1,7 @@
+# D009 — Snapshot exclusions and public wording
+
+**Decision:** The festival snapshot copied into `projects/cans/festivals/CV0001/` excludes `CONTEXT.md`, `001_INGEST/input_specs/`, `.fest/`, `.workflow/`, `.festival-checksums.json`, and the reviewers' hidden working notes `.review-*` (amended in `004_REVIEW`: they are scratch, and `fest validate` warns on their filename shape). Every other festival document is written as if public: it names the campaign phrase lock and the professional-surface grep, never quotes them, and refers to "the operator," not a person. `05_snapshot` runs the grep from `CONTEXT.md §Professional grep` over the snapshot directory and fails if anything matches.
+
+**Why:** The tree is the fest-ad; a stranger reads it. v1's snapshot already exposed more than the current lock allows; v2 does not add to that.
+
+**Not:** Scrubbing after the fact. Copying `input_specs/` (the design pack stays in the campaign).
diff --git a/festivals/CV0001/002_PLAN/decisions/D010_internal_say.md b/festivals/CV0001/002_PLAN/decisions/D010_internal_say.md
new file mode 100644
index 0000000..690294b
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D010_internal_say.md
@@ -0,0 +1,7 @@
+# D010 — internal/say owns the say and stream flow
+
+**Decision:** A new package `internal/say` exposes `Run(ctx, Options, io.Reader, io.Writer, io.Writer) int` — options in, stdin/stdout/stderr in, exit code out. `cmd/cans` parses flags into `say.Options` and returns `Run`'s code. One-shot, `-o`, stdin, `--json`, `--stream`, the lock flags and cancellation all live there. `internal/tts` gains `SayTo` (caller-supplied output path) and `OpenWith` (lock options); nothing else.
+
+**Why:** `cmd/cans/main.go` is already the flag parser for `keep`; putting a 200-line stream loop in it breaks the file limit and makes the flow untestable without the binary. `say.Run` is tested directly against the fake worker.
+
+**Not:** Growing `internal/tts/worker.go` (196 lines). A second binary.
diff --git a/festivals/CV0001/002_PLAN/decisions/D011_internal_mouth.md b/festivals/CV0001/002_PLAN/decisions/D011_internal_mouth.md
new file mode 100644
index 0000000..a8829f2
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D011_internal_mouth.md
@@ -0,0 +1,7 @@
+# D011 — internal/mouth is the lock
+
+**Decision:** Package `internal/mouth`: `Acquire(ctx, path, wait time.Duration, onWait func()) (*Lock, error)`, `ErrBusy`, `(*Lock).Release()`. `wait < 0` waits forever, `wait == 0` is `--nowait`, `wait > 0` is `--wait`. `onWait` fires once, the first time the lock is found held. `tts.OpenWith` is the only production caller.
+
+**Why:** One small package with one job, testable in-process (two opens of the same file in one process do conflict under `flock`) and across processes (a re-exec'd test helper holds the lock, gets `kill -9`'d, and the next acquire succeeds).
+
+**Not:** A lock in `ship` (it is not payload) or in `tts` (which is already the worker client).
diff --git a/festivals/CV0001/002_PLAN/decisions/D012_mkdir_out.md b/festivals/CV0001/002_PLAN/decisions/D012_mkdir_out.md
new file mode 100644
index 0000000..c62974d
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D012_mkdir_out.md
@@ -0,0 +1,7 @@
+# D012 — -o creates parent directories
+
+**Decision:** `SayTo` runs `os.MkdirAll(filepath.Dir(out), 0o755)` before writing. `-o out/%03d.wav` works on a fresh checkout without a `mkdir out`.
+
+**Why:** Every loop example in `design-pipes.md` writes into `out/`. Failing on a missing directory is a papercut, not a safeguard.
+
+**Not:** Overwrite protection. The script owns its files.
diff --git a/festivals/CV0001/002_PLAN/decisions/D013_measurements.md b/festivals/CV0001/002_PLAN/decisions/D013_measurements.md
new file mode 100644
index 0000000..331a7f2
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D013_measurements.md
@@ -0,0 +1,7 @@
+# D013 — Measurements: median and max, N=50
+
+**Decision:** `inputs/measurements.md` records, with the command for each: worker GGUF load time (ready), one-shot synthesis time, worker max RSS, cold `cans say` wall — each over several runs, as median and max. `03_stream` adds: a 50-line stream vs the same 50 lines as a loop of `cans say -o`, and `xargs -P 8` over 24 lines with `pgrep -fc qwen3-tts-worker` sampled every second (max must be 1). N=50 rather than the pack's 200 because the worker's per-line time varies ~5× (it sometimes runs to its token budget instead of stopping at end-of-speech); the margin, not the magnitude, is the claim, and the per-line load cost being removed is constant in N.
+
+**Why:** The pack's estimates predate the native mouth, and a margin nobody can reproduce is marketing. The variance is pre-existing mouth behavior — recorded and flagged, not fixed here.
+
+**Not:** Quoting any number without its command. Running measurements concurrently with anything else (two workers on Metal at once inflated an early probe 8×).
diff --git a/festivals/CV0001/002_PLAN/decisions/D014_cancel_terminates.md b/festivals/CV0001/002_PLAN/decisions/D014_cancel_terminates.md
new file mode 100644
index 0000000..6238e5f
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/D014_cancel_terminates.md
@@ -0,0 +1,9 @@
+# D014 — Ctrl-C terminates an in-flight utterance (amends D008)
+
+**Decision:** On the first SIGINT/SIGTERM, `say` stops reading stdin and closes the session. If the worker is idle, `Close` sends `shutdown` and waits. If the worker is mid-synthesis it cannot abort, so the process is sent SIGTERM and, if still alive 2 s later, SIGKILL (`exec.Cmd.Cancel` + `WaitDelay`). The in-flight line is dropped; finished wavs stay; the lock is released; exit 130; stderr `interrupted after line N` where N is the last stdin line fully processed (spoken or reported), or `interrupted before the first line`. After the first signal the handler is removed (`stop()`), so a second Ctrl-C falls through to the default disposition and ends the process at once.
+
+**Why:** D008 said "Not: killing the worker mid-utterance" and the README was about to promise that Ctrl-C lets the line finish. The review of `03_stream` found `exec.CommandContext` already kills the worker on cancel, and the baseline measurements show a single line can run 17–30 s when the mouth misses end-of-speech. Making a user wait that long after Ctrl-C is worse than a clean terminate: nothing is lost (the in-flight wav is never written on cancel anyway), the kernel frees the worker's memory and the `flock` on exit, and the next `cans say` starts immediately.
+
+**Not:** Waiting for the in-flight line. Pretending cancel is graceful when it is not. Changing the protocol (the worker still has no abort).
+
+**Supersedes:** D008's "Not: killing the worker mid-utterance" and the README line it implied. The README now says: *Ctrl-C stops the stream: the line being spoken is dropped, finished wavs stay, exit 130.*
diff --git a/festivals/CV0001/002_PLAN/decisions/INDEX.md b/festivals/CV0001/002_PLAN/decisions/INDEX.md
new file mode 100644
index 0000000..611af28
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/decisions/INDEX.md
@@ -0,0 +1,18 @@
+# Decisions
+
+| ID | Title |
+|----|-------|
+| [D001](D001_lock_lifetime_booth.md) | Lock lifetime equals Session lifetime; the booth holds it |
+| [D002](D002_one_pr.md) | One worktree, one branch, one PR |
+| [D003](D003_flock_stdlib.md) | flock via stdlib syscall.Flock, polled under ctx |
+| [D004](D004_flag_grammar.md) | say flags interleave with text, both orders |
+| [D005](D005_blank_lines.md) | Stream: blank lines skipped; other failures continue |
+| [D006](D006_records.md) | stdout records |
+| [D007](D007_stream_plays.md) | --stream without -o plays each line |
+| [D008](D008_interrupt_130.md) | Interrupted stream exits 130 |
+| [D009](D009_public_snapshot.md) | Snapshot exclusions and public wording |
+| [D010](D010_internal_say.md) | internal/say owns the say and stream flow |
+| [D011](D011_internal_mouth.md) | internal/mouth is the lock |
+| [D012](D012_mkdir_out.md) | -o creates parent directories |
+| [D013](D013_measurements.md) | Measurements: median and max, N=50 |
+| [D014](D014_cancel_terminates.md) | Ctrl-C terminates an in-flight utterance (amends D008) |
diff --git a/festivals/CV0001/002_PLAN/inputs/README.md b/festivals/CV0001/002_PLAN/inputs/README.md
new file mode 100644
index 0000000..ad55602
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/inputs/README.md
@@ -0,0 +1,35 @@
+# Planning Inputs
+
+Track inputs and gaps identified during planning.
+
+## Input Sources
+
+| Source | Location | Status |
+|--------|----------|--------|
+| Ingest output | `../001_INGEST/output_specs/` | pending |
+| Research findings | `../002_RESEARCH/findings/` | pending |
+
+## Gaps Document
+
+Create `gaps.md` if you identify:
+- Unclear requirements
+- Decisions that need user input
+- Missing information
+
+## Gap Template
+
+```markdown
+# Gaps and Questions
+
+## Critical Gaps (Block Progress)
+
+- [ ] **Gap:** [Description]
+ - **Impact:** [Why this blocks progress]
+ - **Resolution:** [How to resolve]
+
+## Non-Critical Gaps (Can Proceed)
+
+- [ ] **Gap:** [Description]
+ - **Impact:** [Effect on planning]
+ - **Resolution:** [How to handle]
+```
diff --git a/festivals/CV0001/002_PLAN/inputs/gaps.md b/festivals/CV0001/002_PLAN/inputs/gaps.md
new file mode 100644
index 0000000..01709e6
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/inputs/gaps.md
@@ -0,0 +1,14 @@
+# Gaps — what the inputs did not settle
+
+None of these blocks planning. Each is closed by a decision in `decisions/` or by a task.
+
+| Gap | Found in | Closed by |
+|-----|----------|-----------|
+| The fake worker is Go source (`internal/tts/testdata/fakeworker/main.go`), not a binary. Tests must build it. | ingest presentation | `synth_test.go` already builds it into a temp dir per test; new tests reuse that helper (D010 — `internal/say` tests do the same). |
+| Exit code on Ctrl-C. The pack's table has 0/1/2/75 and says only "exit non-zero" for an interrupted stream. | `design-pipes.md §Streams and exit codes` | D008: 130. |
+| Where the lock and the say/stream flow live in code. The pack names behaviors, not packages. | `design-queue.md` | D010 (`internal/say`), D011 (`internal/mouth`). |
+| `-o out/take.wav` when `out/` does not exist. | `design-pipes.md §Audio out` | D012: create parent directories. |
+| The worker sometimes runs to its token budget instead of stopping at end-of-speech (2 of 3 probe runs produced 17.6 s of audio for four words; 1 produced 1.9 s). Pre-existing mouth behavior, not v2's, but it makes per-line times vary 5×. | `inputs/measurements.md` | D013: measurements report median and max, N=50 per mode; variance recorded, not hidden. Flagged in CONTEXT.md for the operator. |
+| The snapshot copies festival docs into the public repo; some festival docs carry campaign-private phrasing. | this phase | D009: exclusion list + public wording rule + grep over the snapshot. |
+| Worktree-per-sequence (pack) vs one PR (operator). | `design-recommend.md`, `user-direction.md` | D002. |
+| The booth holding the lock. | `design-queue.md §Lock mechanics` | D001. |
diff --git a/festivals/CV0001/002_PLAN/inputs/measurements.md b/festivals/CV0001/002_PLAN/inputs/measurements.md
new file mode 100644
index 0000000..fa11235
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/inputs/measurements.md
@@ -0,0 +1,394 @@
+# Measurements — the real mouth on this Mac
+
+Machine: Apple M4 Max, 16 cores, 128 GB, macOS 26.5. Worker: `~/.cans/native/bin/qwen3-tts-worker` (cans overlay, 2026-08-20) with `~/.cans/native/models`. Ref: `voices/veronica/ref.wav`. Text: `Put the cans on.` (4 words → cans sends `max_tokens 220`, `temperature 0.2`).
+
+**Rule (D013): no number is quoted without its command, and nothing else may be running.** Two workers on Metal at once inflated an early probe by 8×; a 1-minute load average of 282 (other sessions on the box) inflated a later one by 10×. Check `uptime` first; 1-minute load must be below 16.
+
+## Baseline — worker alone, idle machine (2026-08-21 ~04:20, load < 16)
+
+Probe: a 60-line Go program (`scratchpad/measure/main.go`) that starts the worker, stamps `ready`, sends one `synthesize` with cans' exact request fields, stamps `final`, sends `shutdown`, and reads the worker's `Rusage.Maxrss`. The probe was throwaway scaffolding and lives campaign-side, not in this repo; the §Stream numbers below are the reproducible ones, taken with the shipped `cans` binary.
+
+```
+go run . ~/.cans/native/bin/qwen3-tts-worker ~/.cans/native/models
+```
+
+| Run | ready (GGUF load) | synth | audio | worker max RSS |
+|-----|-------------------|-------|-------|----------------|
+| 1 | 7 635 ms | 29 932 ms | 17.58 s | 3 758 MB |
+| 2 | 6 517 ms | 6 185 ms | 1.90 s | 2 853 MB |
+| 3 | 6 568 ms | 36 760 ms | 17.58 s | 3 761 MB |
+
+Without `max_tokens`/`temperature` (worker defaults): ready 6 626–6 762 ms, 28.78 s of audio every time (the 360-token ceiling), RSS 4 397 MB.
+
+**Read:** GGUF load is **~6.6 s** and is the cost a loop pays per call. Resident memory for one worker is **2.8–4.4 GB** depending on how long it generates — eight of them is 22–35 GB of weights. Run 2 is the intended case (stops at end of speech, 1.9 s of audio for four words); runs 1 and 3 ran to the 220-token budget (17.58 s). That variance is **pre-existing mouth behavior** (flagged for the operator in `CONTEXT.md`), not v2's, and it is why stream measurements report median and max.
+
+## Baseline — cold `cans say`, idle machine
+
+```
+cd && just build quick
+CANS_NOPLAY=1 /usr/bin/time -l ./bin/cans say "Put the cans on."
+```
+
+| ttfa_ms (cans' field = total synth wall) | real | note |
+|------------------------------------------|------|------|
+| 5 839 | 13.1 s | 04:21, load < 16, run 2-style end of speech |
+
+So a one-shot `cans say` is **~6.6 s load + ~6 s synth ≈ 13 s** cold for four words, of which the load is what `--stream` removes from every line after the first.
+
+## Contaminated runs (kept so nobody repeats the mistake)
+
+- 04:35 — probe and `cans say` launched **concurrently**: `cans say` real 106–120 s, `ttfa_ms` 97 524–110 290; probe synth 30–77 s. Two workers on Metal.
+- 04:45 — "alone" but 1-minute load average **282** (35 sessions on the box): ready 11–32 s, synth 16–77 s for 1.5–2.2 s of audio.
+
+## Stream — filled by `003_IMPLEMENT/03_stream/04_measure`
+
+**Status: COMPLETE — taken on attempt 4, 2026-08-21 16:24–17:45.** Four attempts. Attempts 1 and 2 deferred before starting; attempt 3 got run (a) only, on a box averaging load 23. **Attempt 4 ran all three — (a) stream, (b) loop, (c) `xargs -P 8` — back to back on a genuinely quiet machine, with 100 % of every run's load samples below 16, so the margin is measured and uncontaminated: `loop wall − stream wall` = **596.7 s over 50 lines, 11.93 s per line**, of which **6.44 s per line is the structural overhead `--stream` removes**. Attempt 4 is the quotable attempt. Everything above it is kept as history so nobody repeats the mistakes.
+
+Read the split below carefully. Attempt 3 produced two kinds of result, and only one kind is quotable:
+
+- **Structural results are solid.** Worker count, record count, index density and error count do not depend on machine load. These are the claims `--stream` actually makes, and they hold.
+- **Every wall clock and every `ttfa_ms` from attempt 3 is contaminated** and must not reach the README. During the 31-minute run the 1-minute load averaged **23.2** and peaked at **92.6**; only 56 % of samples were under the D013 bar of 16.
+
+### Attempts 1 and 2 — deferred before starting
+
+- **Attempt 1 — deferred: load 39.21 at 2026-08-21 07:02.** 1-minute load 39.21 (must be < 16); a `festival-voice` worker (PID 62136, not cans) resident.
+- **Attempt 2 — deferred: load 17.38 at 2026-08-21 14:41**, after a 15-minute wait for a window that never opened. 164 load samples over 14 minutes: mean **32.9**, min 15.84, max 60.04, **only 0.6 % below 16**. Source was two foreign VMs holding 841 % CPU and 25.6 GB:
+
+```
+$ ps -Ao pcpu,rss,pid,comm -r | head -3
+ %CPU RSS PID COMM
+479.7 5587568 74004 …/com.apple.Virtualization.VirtualMachine
+361.5 20017296 13387 …/com.apple.Virtualization.VirtualMachine
+$ ps -Ao pcpu -r | awk 'NR>1{s+=$1} END{print s"%"}'
+1032.6%
+```
+
+### Attempt 3 — run (a) completed, runs (b) and (c) not taken
+
+Started 14:57 when the box briefly quieted; preconditions passed at **14:58:07 with load 15.41** and no cans worker resident.
+
+```
+$ for i in $(seq 1 50); do echo "Measurement line $i. The worker stays warm."; done > lines.txt
+$ while sleep 1; do pgrep -f 'cans/native/bin/qwen3-tts-worker' | wc -l | tr -d ' '; done > a.pgrep &
+$ time ( $CANS say --stream -o "out/stream/%03d.wav" --json < lines.txt > stream.jsonl )
+```
+
+#### (a) Stream, 50 lines — structural results (quotable)
+
+| Property | Result | Why it is load-independent |
+|----------|--------|----------------------------|
+| Exit code | **0** | — |
+| Records emitted | **50 / 50** | one per stdin line |
+| `line` values | **exactly 1…50, dense** (`jq -s 'map(.line) == [range(1;51)]'` → `true`) | D005/D006 |
+| Error records | **0** | — |
+| Wav files written | **50 / 50** on the `%03d` template | — |
+| **Workers resident, max** | **1** | 1 753 one-second samples, distinct values `{0, 1}` — **never 2** |
+| GGUF loads | **1**, amortised across 50 lines | see overhead below |
+
+```
+$ sort -n a.pgrep | tail -1
+1
+$ sort -u a.pgrep | tr '\n' ' '
+0 1
+$ wc -l < a.pgrep
+1753
+$ pgrep -fl 'cans/native/bin/qwen3-tts-worker' # between runs
+(exit=1)
+```
+
+**This is the result the sequence exists to prove.** A 50-line document went through one `Session`, one `flock`, one worker process and one GGUF load, and the worker was gone the moment the stream ended.
+
+The overhead number is the strongest evidence, and it survives the contamination because it is a *ratio* of two quantities measured on the same loaded box:
+
+```
+$ jq -s 'map(.ttfa_ms) | add / 1000' stream.jsonl
+1855.906
+```
+
+**1 855.9 s of reported synthesis inside a 1 877 s wall — 21.1 s of everything else, for all 50 lines.** Process start, `doctor.Prepare`, `keep.Load`, lock acquire, one GGUF load, 50 file writes and 50 flushed records together cost about 21 seconds. 98.9 % of the wall was the mouth. A 50-call loop would have paid the GGUF load 50 times instead of once.
+
+#### (a) Stream — timing (CONTAMINATED, do not quote)
+
+| Metric | Value | |
+|--------|-------|--|
+| Wall | 1 877 s (`real 31m16.462s`, `user 32m45.870s`, `sys 3m32.738s`) | contaminated |
+| `ttfa_ms` min | 9 143 | contaminated |
+| `ttfa_ms` q1 / **median** / q3 | 35 688 / **37 151** / 41 255 | contaminated |
+| `ttfa_ms` max | **105 278** | contaminated |
+| Lines over 20 s | **41 of 50** | contaminated |
+| Pageouts | 866 170 → 869 796, **delta 3 626** | contaminated; not the (c) check |
+
+```
+$ jq -s 'map(.ttfa_ms) | sort | .[length/2|floor], max' stream.jsonl
+37151
+105278
+```
+
+Load traced once per 5 s for the whole run:
+
+```
+$ awk '$1>="14:58:07" && $1<="15:29:30" {n++; v=$2+0; s+=v; if(v<16)k++; if(v>mx)mx=v} \
+ END {printf "samples=%d mean=%.1f max=%.1f below16=%d (%.0f%%)\n", n,s/n,mx,k,(k/n)*100}' loadtrace2.log
+samples=373 mean=23.2 max=92.6 below16=208 (56%)
+```
+
+A median of 37 s per line is not this machine's behaviour. The same binary, minutes earlier on a quiet box, did the three-line manual check at `ttfa_ms` 6 122 / 27 348 / 24 215 and a one-shot at 5 652. The pageouts delta of 3 626 is likewise the two foreign VMs paging, not cans — the real pageouts check belongs to run (c), which did not happen.
+
+#### (b) and (c) — not taken
+
+```
+ABORT after A: load 34.28 >= 16, will not measure B on a loaded box
+```
+
+Run (b) waited its full 5-minute settling window at load 31–34 and gave up; run (c) never started. **Without (b) there is no margin and no per-line saved cost.** The value can be *predicted* from the baseline — one GGUF load is ~6.6 s, so 49 avoided loads ≈ 323 s ≈ **6.5 s/line** — but that is arithmetic on an old measurement, not a measured margin, and it must not be quoted as one.
+
+### What a valid run still needed (written before attempt 4; attempt 4 met all three)
+
+1. `uptime` 1-minute load below 16 **and staying there** for roughly 60–90 minutes. Attempt 3 proves a momentary dip is not enough: it passed the precondition at 15.41 and was at 92.6 twenty minutes later.
+2. All three runs — (a) stream, (b) 50-call loop, (c) `xargs -P 8` over 24 lines — back to back, with `pgrep` empty between them.
+3. `vm_stat` pageouts delta of 0 across run (c).
+
+The `xargs` quoting is solved; BSD `xargs` has no `-d`, so the pipeline is null-delimited. Verified against a stand-in before it was pointed at the mouth:
+
+```
+$ head -24 lines.txt | nl -ba | sed 's/^[[:space:]]*//' | tr '\t\n' '\0\0' \
+ | xargs -0 -P 8 -n 2 sh -c '"$CANS" say "$2" -o "$XOUT/$1.wav" --json' _ > x.jsonl 2> x.err
+```
+
+### Attempt 4 — 2026-08-21 16:24–17:45 — **all three runs completed on a quiet box**
+
+Preconditions passed at **16:27:01 with 1-minute load 9.22** and no cans worker resident. The box stayed quiet for the whole 78 minutes: across the three runs' own 30-second load samples, **every single sample was below 16** (mean 7.1–8.4, max 14.97). This is the sustained window attempt 3 never got.
+
+Binary: `projects/worktrees/cans/cans-v2/bin/cans` at `c443de9` (plus uncommitted README/tape changes) — **not rebuilt** for this attempt. Scratch dir outside the repo: `/private/tmp/…/scratchpad/measure4.bBtMcq`.
+
+#### The sampler trap that produced a false "2 workers" reading
+
+The sampler this task file prescribes — `pgrep -f 'cans/native/bin/qwen3-tts-worker' | wc -l` — **cross-matches other samplers**. A concurrent sampler's own `pgrep` process has the pattern in its argv, so sampler A counts sampler B's `pgrep` as a worker. Mid-run (a) this drove the reading to 2 and then 3 while exactly one worker was resident (393 samples read 2, 63 read 3).
+
+It is not the self-match the earlier note warns about (an inline `while` loop) — a separate script file does not fix it. Proof it was an artifact, taken at the same instants:
+
+```
+$ ./diag2.sh # logs `pgrep -fl` (identities, not a count) once a second, 40 blocks
+$ grep -v '^===' diag2.txt | grep -v '\.cans/native/bin/qwen3-tts-worker …/models$'
+(nothing — every block contained the one worker and nothing else)
+$ tail -20 a.pgrep ; # old counter, same seconds
+2 2 3 3 3 3 3 3 3 3 2 2 2 3 3 2 3 2 3 3
+$ cat a2.pgrep ; # anchored ps counter, same seconds
+1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
+```
+
+The fix used for (b), (c) and the parallel check on (a) — anchored on the absolute path, so neither its own `grep` nor any concurrent sampler's `pgrep`/`grep` can be counted, and the foreign `~/.cache/festival-voice/…/qwen3-tts-worker` is excluded by path:
+
+```sh
+ps -Ao command= | grep -c '^~/\.cans/native/bin/qwen3-tts-worker '
+```
+
+**Every "worker max" below is from that counter.** It never read above 1 in any run.
+
+#### The three runs
+
+| | (a) stream, 50 lines | (b) loop, 50 calls | (c) `xargs -P 8`, 24 lines |
+|---|---|---|---|
+| Started | 16:27:01 | 16:52:16 | 17:29:02 |
+| Launch load | 9.22 | 9.11 | 8.13 |
+| **Wall** | **1 512.6 s** (`real 25m12.556s`) | **2 109.2 s** | **943.2 s** (`real 15m43.217s`) |
+| `user` / `sys` | 27m28.811s / 2m40.714s | — (see note) | 16m40.733s / 1m41.119s |
+| Exit code | 0 | 0 | 0 |
+| Records | **50 / 50** | **50 / 50** | **24 / 24** |
+| Error records | 0 | 0 | 0 |
+| Wavs written | 50 | 50 | 24 (`001`…`024`) |
+| `line` dense 1…50 | **true** | n/a | n/a |
+| `ttfa_ms` min | 8 637 | 10 297 | 9 146 |
+| `ttfa_ms` q1 / **median** / q3 | 23 099 / **35 730** / 36 057 | 35 638 / **35 842** / 36 067 | — / **35 736** / — |
+| `ttfa_ms` max | **37 614** | **77 932** | **36 714** |
+| Σ `ttfa_ms` | 1 495.8 s | 1 770.6 s | 782.7 s |
+| Lines over 20 s | 39 / 50 | 45 / 50 | 21 / 24 |
+| **Worker max** | **1** (430 samples, `{0,1}`) | **1** (1 937 + 44 samples, `{0,1}`) | **1** (878 samples, `{0,1}`) |
+| Load samples | n=51 mean **8.36** min 5.55 max 14.97 — **100 % < 16** | n=70 mean **7.06** min 5.19 max 9.70 — **100 % < 16** | n=32 mean **7.45** min 5.75 max 11.10 — **100 % < 16** |
+| Pageouts | 872 087 → 872 724 (delta 637) | 872 724 → 875 724 (delta 3 000) | 875 724 → 875 724 — **delta 0** |
+| stderr | empty | see note | 20 × `waiting for the mouth…`, nothing else |
+| Worker after | gone | gone | gone |
+
+**No run is contaminated.** Every load sample of all three runs was under the D013 bar of 16.
+
+#### The margin — measured, clean
+
+```
+margin = loop wall − stream wall = 2 109.2 − 1 512.6 = 596.7 s
+per-line cost = 596.7 / 50 = 11.93 s per line
+```
+
+**`--stream` saved 596.7 s on a 50-line document — 11.93 s per line — and the number is clean** (both runs ran with 100 % of load samples below 16, mean 8.4 and 7.1).
+
+#### What the margin is made of
+
+The margin splits cleanly into the part `--stream` removes structurally and the part that is the mouth being the mouth:
+
+| Component | (a) stream | (b) loop | Difference |
+|---|---|---|---|
+| Wall | 1 512.6 s | 2 109.2 s | 596.7 s |
+| Σ reported synthesis (`ttfa_ms`) | 1 495.8 s | 1 770.6 s | 274.9 s |
+| **Everything else** (process start, `doctor.Prepare`, `keep.Load`, lock, **GGUF load**, writes, records) | **16.8 s total — 0.34 s/line** | **338.6 s total — 6.77 s/line** | **321.8 s — 6.44 s/line** |
+
+- **98.9 % of the stream run was the mouth.** For all 50 lines, everything that is not synthesis cost **16.8 seconds**.
+- The loop paid **6.77 s per line** of that same overhead, and run (c) independently paid **6.69 s per line** (160.5 s of non-synthesis over 24 calls). Both reproduce the **~6.6 s GGUF load** measured in the baseline at the top of this file, from two different directions.
+- **The defensible structural claim is therefore ~6.4 s per line** (321.8 s over 50), and that is the number the README should use if it quotes one. The remaining 274.9 s of the 596.7 s margin is the loop's *higher reported synthesis time* — a fresh worker per call, subject to the mouth's end-of-speech variance — which is real but is not something `--stream` engineered away.
+
+#### (c) `xargs -P 8` — the lock does its job
+
+Twenty-four `cans say` calls were launched eight at a time. Sampled mid-run:
+
+```
+$ ps -Ao command= | grep -c '^…/cans-v2/bin/cans say '
+8
+$ head -5 x.err
+waiting for the mouth…
+waiting for the mouth…
+waiting for the mouth…
+waiting for the mouth…
+waiting for the mouth…
+$ sort -n c.pgrep | tail -1
+1
+```
+
+**Eight concurrent `cans` processes, seven of them blocked on the lock, one worker resident, 24/24 wavs, zero errors, and a pageouts delta of exactly 0.** `-P 8` cannot start a second worker and cannot make the machine swap — which is the whole reason D001–D003 exist. The cost is that `-P 8` buys nothing: 943 s for 24 lines is 39.3 s/line, the same serial rate as the loop.
+
+#### Near-silent outputs (the known mouth fault, not v2's)
+
+Wavs under 2 000 bytes — 1 484 bytes each, ~0.03 s of near-silence, reported as success:
+
+| Run | Indices | Count |
+|---|---|---|
+| (a) stream | **002, 033, 036, 048** | 4 / 50 (8 %) |
+| (b) loop | **001, 006, 011, 031, 044** | 5 / 50 (10 %) |
+| (c) xargs | **014** | 1 / 24 (4 %) |
+
+```
+$ jq -c 'select(.line==2 or .line==33 or .line==36 or .line==48)|{line,ttfa_ms}' stream.jsonl
+{"line":2,"ttfa_ms":35489}
+{"line":33,"ttfa_ms":35730}
+{"line":36,"ttfa_ms":35882}
+{"line":48,"ttfa_ms":36197}
+```
+
+Same shape as attempt 3: **35–36 s of synthesis to produce 0.03 s of audio**, at the run's median cost, with the worker reporting success. It appears in all three modes at ~4–10 % of lines, so it is independent of `--stream` — a **mouth** fault, already recorded in `CONTEXT.md` for the operator. The largest wav in the same stream run is `016.wav` at 1 158 614 bytes (~24 s).
+
+#### Exact commands
+
+```bash
+D=$(mktemp -d …/scratchpad/measure4.XXXXXX); mkdir -p "$D"/out/{stream,loop,x}; cd "$D"
+CANS=~/Dev/AI/veronica-campaign/projects/worktrees/cans/cans-v2/bin/cans
+
+for i in $(seq 1 50); do echo "Measurement line $i. The worker stays warm."; done > lines.txt
+
+# sampler (separate script file; anchored ps counter — see the trap above)
+cat > pgrepsample.sh <<'EOF'
+#!/bin/sh
+while :; do
+ ps -Ao command= | grep -c '^~/\.cans/native/bin/qwen3-tts-worker ' >> "$1"
+ sleep 1
+done
+EOF
+# load sampler, every 30 s
+cat > loadsample.sh <<'EOF'
+#!/bin/sh
+while :; do
+ printf '%s %s\n' "$(date +%H:%M:%S)" \
+ "$(uptime | sed 's/.*load averages*: *//' | awk '{print $1}' | tr -d ',')" >> "$1"
+ sleep 30
+done
+EOF
+
+# before each run: uptime (1-min < 16) and no cans worker resident
+uptime; pgrep -fl 'cans/native/bin/qwen3-tts-worker'; vm_stat | grep Pageouts
+
+# (a) stream
+./pgrepsample.sh "$D/a.pgrep" & ./loadsample.sh "$D/a.load" &
+{ time ( "$CANS" say --stream -o "$D/out/stream/%03d.wav" --json \
+ < "$D/lines.txt" > "$D/stream.jsonl" 2> "$D/stream.err" ) ; } 2> time_a.txt
+
+# (b) loop
+./pgrepsample.sh "$D/b.pgrep" & ./loadsample.sh "$D/b.load" &
+{ time ( i=0; while IFS= read -r l; do i=$((i+1));
+ "$CANS" say "$l" -o "$D/out/loop/$(printf %03d $i).wav" --json;
+ done < "$D/lines.txt" > "$D/loop.jsonl" 2> "$D/loop.err" ) ; } 2> time_b.txt
+
+# (c) xargs -P 8 over the first 24 lines (BSD xargs has no -d, so null-delimited)
+export CANS XOUT="$D/out/x"
+{ time ( head -24 "$D/lines.txt" | nl -ba | sed 's/^[[:space:]]*//' | tr '\t\n' '\0\0' \
+ | xargs -0 -P 8 -n 2 sh -c '"$CANS" say "$2" -o "$XOUT/$(printf %03d "$1").wav" --json' _ \
+ > "$D/x.jsonl" 2> "$D/x.err" ) ; } 2> time_c.txt
+
+# analysis
+jq -s 'map(.ttfa_ms) | sort | .[length/2|floor], max' stream.jsonl
+jq -s 'map(.ttfa_ms) | add / 1000' stream.jsonl
+jq -s 'map(select(.error!=null)) | length' stream.jsonl
+jq -s 'map(.line) == [range(1;51)]' stream.jsonl
+find out/stream -name '*.wav' -size -2000c -exec basename {} \; | sort
+sort -n a2.pgrep | tail -1
+```
+
+#### One honest caveat about run (b)'s wall
+
+The agent harness killed the background shell holding run (b) after 60 minutes of wall time, **during call 50** — `loop.err` contains one `say: interrupted` from that kill, and no worker leaked. Calls 1–49 had already completed and were timed exactly; call 50 was re-run alone 90 seconds later on the same idle box and its wall added:
+
+```
+calls 1–49 : 2 062 s (b.t0 epoch → mtime of loop.jsonl at record 49; ±1 s)
+call 50 : 47.229 s (`real 0m47.229s`, load 6.90 → 8.54, worker max 1)
+loop wall : 2 109.2 s
+```
+
+The loop is 50 independent one-shot invocations, so the sum is the same quantity a single uninterrupted `time` would have printed, to within the ±1 s of the mtime read. This is why (b) has no `user`/`sys` figures. Every other number in run (b) — records, errors, wavs, `ttfa_ms`, worker max, load — is from the 50 completed calls.
+
+Total attempt time was 78 minutes against a 75-minute budget; run (c) started at 17:29:02, inside the budget, and was allowed to finish.
+
+### Finding for the reviewer — intermittent near-silent wavs
+
+**3 of the 50 stream wavs are 1 484 bytes**, about **0.03 s** of near-silence, for ordinary inputs — `026.wav`, `034.wav`, `048.wav` — while `004.wav` in the same run is 1 145 808 bytes (~24 s). The same thing appeared in the cancel check (`002.wav`, 1 484 bytes, input `Line 2`).
+
+The important part is what those three lines *cost*:
+
+```
+$ find out/stream -name '*.wav' -size -5000c -exec basename {} \; | sort
+026.wav 034.wav 048.wav
+$ jq -c 'select(.line==26 or .line==34 or .line==48) | {line, ttfa_ms}' stream.jsonl
+{"line":26,"ttfa_ms":36501}
+{"line":34,"ttfa_ms":40391}
+{"line":48,"ttfa_ms":54272}
+```
+
+**36.5 s, 40.4 s and 54.3 s of synthesis to produce 0.03 s of audio each.** So this is *not* an early end-of-speech — the worker ran a long generation and emitted almost nothing at the end of it. Line 48 was the third most expensive line in the whole run and returned silence.
+
+The files themselves are well-formed WAV — PCM, mono, 24 000 Hz, 16-bit, `data` chunk 1 440 bytes, leading samples zero:
+
+```
+$ xxd -l 48 out/stream/048.wav
+00000000: 5249 4646 c405 0000 5741 5645 666d 7420 RIFF....WAVEfmt
+00000010: 1000 0000 0100 0100 c05d 0000 80bb 0000 .........]......
+00000020: 0200 1000 6461 7461 a005 0000 0000 0000 ....data........
+```
+
+`--stream` did the right thing at every step: it wrote what the worker returned, emitted a success record with the true `ttfa_ms`, and continued. Nothing in the write path or the record path is wrong, and no error was available for it to report — the worker reported success.
+
+This is a **mouth** fault, not a `03_stream` defect, and it is outside this festival's scope to fix. But it is worth an operator decision, because at roughly **6 % of lines** a 200-line render would silently lose about a dozen lines *and pay full synthesis time for each one*. A caller cannot currently tell these apart from real output without inspecting wav length — which suggests a cheap future guard: warn when a returned wav is under some floor.
+
+### Not cans' worker (recorded once, per D013)
+
+```
+$ ps -o pid,ppid,pcpu,rss,etime,command -p 62136
+ PID PPID %CPU RSS ELAPSED COMMAND
+62136 61770 0.1 24688 08:56:49 ~/.cache/festival-voice/models/qwen3-tts/bin/qwen3-tts-worker …/models
+```
+
+24 MB resident, 0.1 % CPU, idle 8 h 56 m — no model loaded. It is `festival-voice`, not `~/.cans/native/bin/qwen3-tts-worker`, and it must not be killed. Because a bare `pgrep -f qwen3-tts-worker` matches it, **every cans check must use the path-qualified pattern** `pgrep -f 'cans/native/bin/qwen3-tts-worker'`.
+
+### Clean real-mouth numbers taken on the quiet window (functional checks, not the measurement)
+
+From the `05_testing` gate at 14:54–14:57, load **9.9–14.7**, i.e. genuinely under the bar. Not a substitute for run (a)/(b)/(c), but the only uncontaminated mouth numbers this session produced:
+
+| Check | Load | Result |
+|-------|------|--------|
+| One-shot `cans say "Put the cans on."` | 9.90 → 10.25 | `ttfa_ms=5652`, exit 0, 14 s wall, pgrep max 1 — matches the 5 839 ms / 13.1 s baseline, so one-shot is unchanged |
+| 3-line `--stream --json` | 10.02 → 11.11 | exit 0, 65 s, 3 records, 3 wavs, **pgrep max 1 over 61 samples**, stderr empty; `ttfa_ms` 6 122 / 27 348 / 24 215 |
+| 20-line stream, SIGINT after 002.wav | 10.48 → 14.66 | exit **130**, `interrupted after line 2`, 001+002 kept, no 003, worker gone |
+| one-shot straight after the interrupt | 14.66 | exit 0, **14 s**, no `waiting for the mouth…` — lock already released |
diff --git a/festivals/CV0001/002_PLAN/plan/IMPLEMENTATION_PLAN.md b/festivals/CV0001/002_PLAN/plan/IMPLEMENTATION_PLAN.md
new file mode 100644
index 0000000..999c27a
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/plan/IMPLEMENTATION_PLAN.md
@@ -0,0 +1,68 @@
+# Implementation plan — CV0001 cans-v2
+
+Branch `cans-v2` in `projects/worktrees/cans/cans-v2`, based on `1e8cea2`. One PR at the end. Decisions are in `../decisions/`; requirements in `../../001_INGEST/output_specs/requirements.md` (cited as P0-n / P1-n).
+
+## Shape of the code after this festival
+
+```
+cmd/cans/main.go thin: dispatch, parseKeep, parseSay → say.Run
+cmd/cans/say_args.go parseSay: interleaved flags + text (D004)
+internal/say/ Run(ctx, Options, stdin, stdout, stderr) int — one-shot, -o, stdin, --json, --stream, cancel (D010)
+internal/mouth/ Acquire / Release — flock on CANS_HOME/mouth.lock (D011)
+internal/tts/session.go OpenWith(ctx, Options) — takes the lock before StartWorker; SayTo(ctx, text, cur, out)
+internal/tts/synth.go SayWith unchanged; SayTo added
+internal/booth/booth.go Run uses OpenWith (waits, stderr line) — holds the lock for the session (D001)
+tapes/pipe.tape second tape; just vhs pipe
+festivals/CV0001/ snapshot (D009)
+```
+
+Nothing else moves. `internal/tts/worker.go` stays at 196 lines.
+
+## 003_IMPLEMENT
+
+### 01_out — `-o`, stdin, `--json`, exit codes (P0-1…4, 9, 10, 15–18, 20)
+
+| # | Task | Files | Done when |
+|---|------|-------|-----------|
+| 01 | `parse_say` — `parseSay(args) (say.Options, error)`: interleaved flags/text, `-o/--out`, `--json`, `--play`, bare `-`; unknown flag → error (exit 2). `--stream`, `--nowait`, `--wait` are **parsed** here too so the grammar is settled once, but `Run` rejects `--stream` with exit 2 "not yet" until `03_stream` — no: they are accepted and wired in their own sequences; this task only adds the three flags to the parser with tests. | `cmd/cans/say_args.go`, `say_args_test.go` | table-driven tests: both orders, `-` handling, unknown flag, `--wait` duration parse |
+| 02 | `internal_say` — create `internal/say` with `Options`, `Run`; move the `case "say"` body into it; `tts.SayTo(ctx, text, cur, out)` + `(*Session).SayTo`; `out == ""` keeps the temp path; `-o` → MkdirAll parent (D012), write there, never `RemoveTemp`; `--play` plays after writing. `CANS_SAY_BIN` path: copy the script's wav to `out`. | `internal/say/say.go`, `internal/tts/session.go`, `internal/tts/synth.go`, `cmd/cans/main.go` | `cans say "x"` output/behavior byte-identical (P0-17); `-o` writes and leaves the file; tests on fake worker + `CANS_SAY_BIN` |
+| 03 | `stdin_json_exit` — stdin as one utterance when no text (`-` or empty argv with non-TTY stdin); TTY + empty → exit 2; `--json` record via `json.Encoder` + flush; exit-code mapping 0/1/2; stdout/stderr discipline; `main_test.go` coverage through `run()` with injected stdin. | `internal/say/say.go`, `cmd/cans/main.go`, tests | `echo x \| cans say -o t.wav` works; `cans say < /dev/tty`-style TTY case is exit 2 (inject `isTTY`); JSON record shape matches D006 |
+
+### 02_lock — the mouth lock (P0-11…14, 15 (75), 19 (kill -9), 20)
+
+| # | Task | Files | Done when |
+|---|------|-------|-----------|
+| 01 | `flock` — `internal/mouth`: `Acquire`, `ErrBusy`, `Release`, per D003/D011. Tests: second acquire `wait=0` → `ErrBusy`; release → reacquire; ctx cancel while waiting → `ctx.Err()`; bounded wait → `ErrBusy` after deadline; `onWait` fires once; cross-process: re-exec test helper holds the lock, `Process.Kill()`, next acquire succeeds. | `internal/mouth/lock.go`, `lock_test.go` | all of the above green; file never deleted |
+| 02 | `session_lock` — `tts.OpenWith(ctx, Options{Wait, OnWait})` acquires `ship.Home()/mouth.lock` **before** `StartWorker`, stores it on `Session`, `Close` releases **after** `Client.Close`. `Open` = `OpenWith` defaults (wait forever, stderr `waiting for the mouth…`). `SayWith` unchanged in behavior; `SayTo` gains an options variant. Test: hold Session A (fake worker); `OpenWith(wait=0)` with `CANS_WORKER_BIN` pointing at a **missing** file returns `ErrBusy`, proving the lock precedes the worker start. | `internal/tts/session.go`, `session_test.go` | ordering test green; existing tts tests unchanged |
+| 03 | `flags_booth` — `--nowait` (wait 0) and `--wait ` wired from `Options` to `OpenWith`; `mouth.ErrBusy` → exit 75 with `mouth busy` on stderr; booth `Run` uses `OpenWith` (wait forever, stderr line before the TUI). | `internal/say/say.go`, `internal/booth/booth.go`, tests | `cans say --nowait x` while another cans holds the mouth → 75; booth waits |
+
+### 03_stream — `--stream` (P0-5…9, 19, 20; P1-4 numbers)
+
+| # | Task | Files | Done when |
+|---|------|-------|-----------|
+| 01 | `stream_loop` — in `say.Run`: `OpenWith` once, `bufio.Scanner` (1 MiB buffer) over stdin, skip blank lines (D005), `idx++`, `SayTo` per line, record per line (D006) + flush, play when no `-o` (D007), errors continue, exit 1 at EOF if any failed. | `internal/say/stream.go`, tests | 5-line stream on the fake worker → 5 wavs, 5 records, one worker start |
+| 02 | `out_template` — `-o` with `%d`-family verb required in stream mode (else exit 2); exactly one verb; `fmt.Sprintf(tmpl, idx)`; MkdirAll. | `internal/say/template.go`, tests | `out/%03d.wav` → `out/001.wav`…; `%s` rejected; no verb rejected |
+| 03 | `cancel` — `signal.NotifyContext` in `cmd/cans`; loop checks `ctx` between lines; on cancel: stop reading, close session, release lock, stderr `interrupted after line N`, exit 130 (D008). Test: cancel ctx after the first record on a never-EOF pipe → first wav present, `Run` returns 130, a fresh `mouth.Acquire(wait=0)` succeeds. README line per D014: the line being spoken is dropped; a second Ctrl-C stops at once. | `internal/say/stream.go`, `cmd/cans/main.go`, tests | test green; no orphaned fake worker (`cmd.Wait` returned) |
+| 04 | `measure` — real mouth, nothing else running: (a) 50 lines as `--stream -o 'out/%03d.wav'` vs the same 50 as a `while read` loop of `cans say -o`; wall, median/max per line; `pgrep -fc qwen3-tts-worker` sampled each second (max 1 in both); (b) `xargs -P 8` over 24 lines of `cans say -o` with `pgrep` sampling (max 1), no swap (`vm_stat` pageouts delta 0). Record in `002_PLAN/inputs/measurements.md §Stream`, with every command. | measurements.md | numbers + commands recorded; `pgrep` max = 1 in every run |
+
+### 04_tape — the pipe demo and the README (P1-1, P1-2, P1-5)
+
+| # | Task | Files | Done when |
+|---|------|-------|-----------|
+| 01 | `pipe_tape` — `tapes/pipe.tape`: three boring technical lines into `lines.txt`, `cat lines.txt \| cans say --stream -o 'out/%03d.wav' --json`, `ls out/`. `just vhs pipe` → `docs/pipe.gif`. Real mouth; `Wait+Screen` if the installed vhs supports it, else generous `Sleep`. | `tapes/pipe.tape`, `.justfiles/vhs.just`, `docs/pipe.gif` | gif regenerates from `just vhs pipe`; frame checked |
+| 02 | `readme_scripting` — README "Scripting" section: the three loops from `design-pipes.md`, the flag table, the exit-code table (0/1/2/75/130), the cancel-between-requests line, `docs/pipe.gif` next to the booth gif. `usage` const in `main.go` updated. Professional-surface grep clean. | `README.md`, `cmd/cans/main.go` | grep (CONTEXT §Professional grep) empty; one footer |
+
+### 05_snapshot — the public tree (P1-3, P1-5)
+
+| # | Task | Files | Done when |
+|---|------|-------|-----------|
+| 01 | `snapshot` — copy the festival into `festivals/CV0001/` with **exactly the exclusion list D009 carries** (see `002_PLAN/decisions/D009_public_snapshot.md`; amended in `004_REVIEW`, so read it there rather than restating it here); add the one README line pointing at the tree next to the CA0001 line, if such a line exists. | `festivals/CV0001/`, `README.md` | tree readable; grep over `festivals/CV0001/` empty |
+| 02 | `recheck` — full professional grep over README, docs/, tapes/, festivals/; exactly one footer; `just test unit`, `go vet`, `gofmt -l`; fresh `CANS_HOME` doctor + `cans say -o` with the binary copied outside the checkout; `cans` runs with `fest` absent from PATH. | — | everything green, recorded in the task file |
+
+## 004_REVIEW
+
+`PHASE_GOAL.md` carries the nine-item bar from `design-recommend.md §Ship verification` plus identity (`git log --format=%an`), `fest validate`, and the PR. `BAR.md` holds the exact commands and their recorded output. Sign-off opens the PR from `cans-v2` to `main` under `veronica-agent`, body built from `BAR.md`.
+
+## Verification at every gate
+
+`gofmt -l .` empty · `go vet ./...` · `CANS_NOPLAY=1 go test ./...` (fake worker only) · `cans say "x"` unchanged · no new `go.mod` requires · files < 500 lines, functions < 50 · reviewer ≠ implementer.
diff --git a/festivals/CV0001/002_PLAN/plan/README.md b/festivals/CV0001/002_PLAN/plan/README.md
new file mode 100644
index 0000000..97963ea
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/plan/README.md
@@ -0,0 +1,81 @@
+# Implementation Plan
+
+Create `IMPLEMENTATION_PLAN.md` here with the detailed plan structure.
+
+## Plan Structure
+
+1. **Overview** — What will be implemented
+2. **Phases** — Implementation phases with goals
+3. **Sequences** — Sequences within each phase
+4. **Tasks** — Tasks within each sequence
+5. **Dependencies** — What blocks what
+
+## Plan Template
+
+Create `IMPLEMENTATION_PLAN.md`:
+
+```markdown
+# Implementation Plan
+
+## Overview
+
+[Brief description of what will be implemented]
+
+## Phase Breakdown
+
+### Phase 1: [Phase Name]
+
+**Type:** implementation | planning | research | review
+**Goal:** [What this phase accomplishes]
+
+#### Sequences
+
+1. **[Sequence Name]**
+ - **Goal:** [What this sequence accomplishes]
+ - **Tasks:**
+ - [ ] Task 1: [Description]
+ - [ ] Task 2: [Description]
+
+2. **[Sequence Name]**
+ - **Goal:** [What this sequence accomplishes]
+ - **Tasks:**
+ - [ ] Task 1: [Description]
+
+### Phase 2: [Phase Name]
+...
+
+## Dependencies
+
+| Item | Blocked By | Rationale |
+|------|------------|-----------|
+| [Item] | [Dependency] | [Why it must wait] |
+
+## Risks and Mitigations
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|------------|--------|------------|
+| [Risk] | Low/Medium/High | Low/Medium/High | [How to handle] |
+```
+
+## STRUCTURE.md
+
+Also create `STRUCTURE.md` with the festival hierarchy:
+
+```markdown
+# Festival Structure
+
+## Festival Goal
+[What the entire festival accomplishes]
+
+## Hierarchy
+
+- **Festival:** [Name]
+ - **Phase 1:** [Name] (type)
+ - Sequence A: [Name]
+ - Task 1
+ - Task 2
+ - Sequence B: [Name]
+ - Task 1
+ - **Phase 2:** [Name] (type)
+ - ...
+```
diff --git a/festivals/CV0001/002_PLAN/plan/STRUCTURE.md b/festivals/CV0001/002_PLAN/plan/STRUCTURE.md
new file mode 100644
index 0000000..538c682
--- /dev/null
+++ b/festivals/CV0001/002_PLAN/plan/STRUCTURE.md
@@ -0,0 +1,18 @@
+# Structure — CV0001 cans-v2
+
+```
+cans-v2-CV0001
+├── 001_INGEST (workflow) design pack + operator direction → output_specs
+├── 002_PLAN (workflow) this phase: gaps, decisions D001–D013, measurements, plan, scaffold
+├── 003_IMPLEMENT (sequences, on branch cans-v2)
+│ ├── 01_out say args, internal/say, -o, stdin, --json, exit codes
+│ ├── 02_lock internal/mouth flock, OpenWith, --nowait/--wait, booth holds it
+│ ├── 03_stream --stream loop, %03d template, Ctrl-C → 130, measurements
+│ ├── 04_tape tapes/pipe.tape + just vhs pipe, README scripting section
+│ └── 05_snapshot festivals/CV0001 snapshot, professional recheck, fresh-home doctor
+└── 004_REVIEW (freeform) the ship bar, identity, fest validate, the PR
+```
+
+Dependencies: `01 → 02 → 03 → 04 → 05`, strictly. `02_lock` before `03_stream` so the naive path is safe before the fast path is fast. `04_tape` needs `-o` and `--stream`. `05_snapshot` is last so it captures the finished tree. Review last; the PR opens from review.
+
+Each implementation sequence ends with the four gates: testing, review (a different agent), iterate, fest_commit.
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/01_parse_say.md b/festivals/CV0001/003_IMPLEMENT/01_out/01_parse_say.md
new file mode 100644
index 0000000..925e52b
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/01_parse_say.md
@@ -0,0 +1,42 @@
+---
+fest_type: task
+fest_id: 01_parse_say.md
+fest_name: parse_say
+fest_parent: 01_out
+fest_order: 1
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.481543-06:00
+fest_updated: 2026-08-21T05:19:29.19295-06:00
+fest_tracking: true
+---
+
+
+# Task: parse_say
+
+## Objective
+
+`parseSay(args []string) (say.Options, error)` in `cmd/cans/say_args.go` — the whole `say` grammar, settled once, with the `Options` struct it fills in `internal/say/options.go`.
+
+## Requirements
+
+- [x] Flags and text interleave, both orders (D004): `cans say "$line" -o out.wav` and `cans say -o out.wav "$line"` parse identically.
+- [x] Flags, and only these: `-o ` / `--out ` / `-o=` / `--out=`; `--json`; `--stream`; `--play`; `--nowait`; `--wait ` / `--wait=`; a bare `-` (stdin). `-h` / `--help` returns an error whose text is the `usage` const, like `parseKeep` does.
+- [x] Any other argument starting with `-` is `say: unknown flag ` (the caller maps it to exit 2).
+- [x] Positionals are joined with a single space into `Options.Text`.
+- [x] `--wait` parses with `time.ParseDuration`; `<= 0` or unparsable is an error. `--nowait` and `--wait` together is an error. `--play` without `-o` is an error (`say: --play needs -o`). `-` together with text is an error (`say: - and text together`).
+- [x] `Options` (in `internal/say/options.go`): `Text string`, `Stdin bool` (bare `-`), `StdinTTY bool` (set by `main`, not by the parser), `Out string`, `JSON bool`, `Stream bool`, `Play bool`, `Wait time.Duration` (default `-1` = wait forever; `--nowait` → `0`; `--wait d` → `d`). Add `func DefaultOptions() Options`.
+
+## Implementation
+
+1. Read `cmd/cans/main.go` `parseKeep` (line ~109). Copy its shape: a `for i` loop over `args`, `switch` on the arg, `i++` to consume a value, `strings.HasPrefix(a, "--wait=")` style for `=` forms, positionals collected in a slice.
+2. Create `internal/say/options.go` with the struct and `DefaultOptions()`. Package doc comment: `// Package say runs cans say: one-shot, file output, stdin, stream.`
+3. Create `cmd/cans/say_args.go` with `parseSay`. Start from `say.DefaultOptions()`. Validate at the end (the `--play`/`-o`, `--nowait`/`--wait`, `-`/text rules).
+4. Create `cmd/cans/say_args_test.go`, table-driven. Error cases first: unknown flag; `-h`; `--wait bogus`; `--wait 0s`; `--nowait --wait 1s`; `--play` alone; `-` with text. Then: text only; `-o` before text; `-o` after text; `-o=path`; `--out path`; `--json --stream`; `-` alone; `--wait 30s`; multiple positionals joined.
+5. Do not wire `main.go` yet — task 02 does. `go vet` is fine with an unused unexported function.
+
+## Done when
+
+- [x] `go test ./cmd/cans/ -run TestParseSay -v` green, every case above present
+- [x] `gofmt -l .` empty; `go vet ./...` clean
+- [x] `say_args.go` < 120 lines; no function > 50 lines
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/02_internal_say.md b/festivals/CV0001/003_IMPLEMENT/01_out/02_internal_say.md
new file mode 100644
index 0000000..1ab878c
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/02_internal_say.md
@@ -0,0 +1,48 @@
+---
+fest_type: task
+fest_id: 02_internal_say.md
+fest_name: internal_say
+fest_parent: 01_out
+fest_order: 2
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.482119-06:00
+fest_updated: 2026-08-21T05:37:13.353773-06:00
+fest_tracking: true
+---
+
+
+# Task: internal_say
+
+## Objective
+
+Create `internal/say.Run` and move the `case "say"` flow into it; add `tts.SayTo` so a caller can name the output path; `-o` writes there and never deletes, `--play` plays after writing. `cans say "x"` stays identical.
+
+## Requirements
+
+- [x] `func Run(ctx context.Context, o Options, stdin io.Reader, stdout, stderr io.Writer) int` in `internal/say/say.go`. Exit constants in `internal/say/exit.go`: `ExitOK = 0`, `ExitFail = 1`, `ExitUsage = 2`, `ExitBusy = 75`, `ExitInterrupted = 130` (75 and 130 are declared now, wired in 02_lock / 03_stream).
+- [x] `tts.SayTo(ctx, text, cur, out string) (Result, error)` and `(*Session).SayTo(ctx, text, cur, out string)`. `out == ""` → the temp path exactly as today. `out != ""` → `os.MkdirAll(filepath.Dir(out), 0o755)` (D012) then `audio.WritePCM16(out, …)`; `Result.Wav == out`. `Say` / `SayWith` / `(*Session).Say` become one-line wrappers that pass `""`.
+- [x] `CANS_SAY_BIN` path: when `out != ""`, copy the script's wav to `out` (small `copyFile` helper in `synth_bin.go`) and return `Wav: out`. The seam keeps working for tests and tapes.
+- [x] In `Run`, one-shot flow in this order: `doctor.Prepare(ctx, stderr)` (returns nil early when `CANS_SAY_BIN` is set — unchanged); empty `o.Text` → `say: missing text` on stderr, `ExitUsage` (stdin arrives in task 03); `keep.Load()`; `tts.SayTo(ctx, text, cur, o.Out)`; error → stderr, `ExitFail`.
+- [x] No `-o`: print `ttfa_ms=N` to stdout, `play.File`, `tts.RemoveTemp`, play error → `ExitFail`. Exactly today's behavior and order (P0-17).
+- [x] `-o`: print the path to stdout (JSON comes in task 03); if `o.Play`, `play.File(out)`; **never** `RemoveTemp`.
+- [x] `cmd/cans/main.go`: add `stdin io.Reader = os.Stdin` next to `stdout` / `stderr`; `case "say"` becomes: `o, err := parseSay(args[1:])` → on error print to stderr and return 2; `return say.Run(context.Background(), o, stdin, stdout, stderr)`.
+
+## Implementation
+
+1. `internal/tts/session.go`: rename the body of `Say` into `SayTo`, branch on `out`. Keep `audio.Clean` and the sample-rate default exactly where they are.
+2. `internal/tts/synth.go`: `SayTo` mirrors `SayWith` (`CANS_SAY_BIN` → `sayBin` then copy if `out != ""`; else `Open`, `defer Close`, `sess.SayTo`). `SayWith` = `SayTo(ctx, text, cur, "")`.
+3. `internal/say/say.go`: `Run` dispatches to `runOnce` (stream comes later). Keep `Run` under 30 lines; `runOnce` under 50.
+4. `cmd/cans/main.go`: delete the inlined flow; wire `parseSay` + `say.Run`. `main.go` shrinks.
+5. Tests, error cases first, in `internal/say/say_test.go`:
+ - `Out` under an unwritable path (a regular file used as a directory) → `ExitFail`, stderr non-empty, stdout empty.
+ - `CANS_SAY_BIN` fake script (copy the shape from `internal/tts/synth_test.go` `TestSayMockBin`): `Out == ""` → stdout is `ttfa_ms=12\n`; `Out = t.TempDir()/out/take.wav` → file exists with a valid header (`audio.HeaderOK`), stdout is the path + newline, original fake wav untouched.
+ - Fake worker (`CANS_WORKER_BIN`): build `internal/tts/testdata/fakeworker` into a temp dir with `go build` the way `synth_test.go` does (copy that ~10-line helper; `CANS_WORKER_MODELS` any temp dir; `CANS_HOME` temp; `CANS_NOPLAY=1`). `Out` set → wav written at `Out`, stdout is the path.
+ - `cmd/cans/main_test.go`: `run([]string{"say"})` → 2; `run([]string{"say", "--bogus"})` → 2 with stderr mentioning the flag.
+
+## Done when
+
+- [x] `CANS_NOPLAY=1 go test ./...` green (existing tts tests unchanged)
+- [x] `just build quick && ./bin/cans say "Put the cans on."` prints `ttfa_ms=N`, plays, leaves no `cans-*.wav` in `$TMPDIR` — same as `1e8cea2`
+- [x] `./bin/cans say -o /tmp/cans-out/take.wav "Put the cans on."` prints `/tmp/cans-out/take.wav`, the file plays with `afplay`, nothing on stderr
+- [x] `gofmt -l .` empty; `go vet` clean; every touched file < 500 lines, functions < 50
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/03_stdin_json_exit.md b/festivals/CV0001/003_IMPLEMENT/01_out/03_stdin_json_exit.md
new file mode 100644
index 0000000..c0d28c5
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/03_stdin_json_exit.md
@@ -0,0 +1,44 @@
+---
+fest_type: task
+fest_id: 03_stdin_json_exit.md
+fest_name: stdin_json_exit
+fest_parent: 01_out
+fest_order: 3
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.482892-06:00
+fest_updated: 2026-08-21T06:07:27.029893-06:00
+fest_tracking: true
+---
+
+
+# Task: stdin_json_exit
+
+## Objective
+
+stdin as one utterance, the TTY rule, `--json` records, and the exit-code / stream discipline — stdout is data, stderr is prose.
+
+## Requirements
+
+- [x] Text resolution in `Run`: if `o.Stdin` (bare `-`) **or** `o.Text == ""`: when `o.StdinTTY && !o.Stdin` → `say: missing text`, `ExitUsage` (P0-4, the usage error it is today, never a hang). Otherwise read stdin (`io.ReadAll(io.LimitReader(stdin, 4<<20))`), `strings.TrimSpace`; empty → `say: empty text`, `ExitUsage`. The whole input is one utterance (P0-3).
+- [x] `main` sets `o.StdinTTY` from a real isatty ioctl (`TIOCGETA`), not `Stat` `ModeCharDevice` — `/dev/null` is a char device but not a TTY, and `cans say < /dev/null` must be `say: empty text`. Tests set the field directly.
+- [x] `--json`: one record on stdout, `json.NewEncoder(stdout).Encode(r)` where `r` is the `tts.Result` (tags already `wav`, `ttfa_ms`, `sample_rate`) — D006. Without `-o` the wav is a temp file removed after playback; say so in the README later (04_tape), not in code.
+- [x] With `--json` and no `-o`: still play and `RemoveTemp` (v1 semantics; the record is for `ttfa_ms`). With `--json -o`: the record's `wav` is the `-o` path.
+- [x] Exit mapping: every usage problem → `ExitUsage` (2); every runtime problem → `ExitFail` (1); messages prefixed `say:` on stderr; stdout never carries a message (P0-15, P0-16).
+
+## Implementation
+
+1. `internal/say/input.go`: `func resolveText(o Options, stdin io.Reader) (string, int)` returning text and an exit code (0 on success). Keep the TTY rule and the empty rule here; table-test it.
+2. `internal/say/say.go`: call `resolveText` first; emit with a small `emit(stdout, o, r)` helper: JSON → encoder; `-o` → path; else `ttfa_ms=`.
+3. `cmd/cans/main.go`: set `o.StdinTTY` after `parseSay` (a 4-line helper `stdinIsTTY() bool`).
+4. Tests (error first) in `internal/say/input_test.go` and `say_test.go`:
+ - `Text == ""`, `StdinTTY = true` → 2, stderr `say: missing text`, stdin **not read** (use a reader whose `Read` fails the test).
+ - piped empty / whitespace stdin → 2, `say: empty text`.
+ - `Stdin = true` with `strings.NewReader("Put the cans on.\n")` → the fake say bin receives that text (have the fake script write `"$@"` to a file and assert on it).
+ - `--json` with fake say bin → stdout parses as JSON with `ttfa_ms == 12` and `sample_rate == 24000`; nothing else on stdout.
+ - `--json -o` → record `wav` equals the out path and the file exists.
+
+## Done when
+
+- [x] `echo "Put the cans on." | ./bin/cans say -o /tmp/cans-out/a.wav --json` prints exactly one JSON line; `./bin/cans say < /dev/null` exits 2 with `say: empty text`; `./bin/cans say` in a terminal exits 2 with `say: missing text`
+- [x] `CANS_NOPLAY=1 go test ./...` green; `gofmt -l .` empty; `go vet` clean
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/04_testing.md b/festivals/CV0001/003_IMPLEMENT/01_out/04_testing.md
new file mode 100644
index 0000000..ca1e577
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/04_testing.md
@@ -0,0 +1,137 @@
+---
+fest_type: gate
+fest_id: 04_testing.md
+fest_name: Testing and Verification
+fest_parent: 01_out
+fest_order: 4
+fest_status: completed
+fest_autonomy: medium
+fest_gate_id: testing
+fest_gate_type: testing
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.42617-06:00
+fest_updated: 2026-08-21T06:09:57.116443-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Testing and Verification
+
+Verify all functionality implemented in this sequence works correctly.
+
+## Test Categories
+
+### Unit Tests
+
+- [x] All unit tests pass
+- [x] New/modified code has test coverage
+- [x] Tests are meaningful (not just coverage padding)
+
+### Integration Tests
+
+- [x] Integration tests pass
+- [x] Components work together correctly
+
+### Error Handling
+
+- [x] Invalid inputs are rejected gracefully
+- [x] Error messages are clear and actionable
+- [x] Recovery paths work correctly
+
+## Verification
+
+- [x] Build completes without warnings
+- [x] No regressions introduced
+- [x] Coverage meets project requirements
+
+## cans-v2 commands (all from the `cans-v2` worktree; every one must be clean)
+
+```bash
+gofmt -l . # prints nothing
+go vet ./...
+CANS_NOPLAY=1 go test ./... # fake worker only — no real mouth
+git diff origin/main -- go.mod go.sum # empty: no new dependencies
+wc -l $(git diff --name-only origin/main -- '*.go') | sort -n | tail -5 # every file < 500
+./bin/cans say "Put the cans on." ; echo "exit=$?" # one-shot unchanged: ttfa_ms=N, plays, temp wav gone
+```
+
+Then the sequence's own checks from its task files. Record the output of each command in this gate file under **Results** before marking it complete.
+
+## Results
+
+Worktree: `projects/worktrees/cans/cans-v2`. Recorded 2026-08-21.
+
+```
+$ gofmt -l .
+(empty)
+
+$ go vet ./...
+(empty, exit 0)
+
+$ CANS_NOPLAY=1 go test ./...
+ok github.com/veronica-agent/cans/cmd/cans
+ok github.com/veronica-agent/cans/internal/audio
+ok github.com/veronica-agent/cans/internal/booth
+ok github.com/veronica-agent/cans/internal/doctor
+ok github.com/veronica-agent/cans/internal/keep
+ok github.com/veronica-agent/cans/internal/play
+ok github.com/veronica-agent/cans/internal/say
+ok github.com/veronica-agent/cans/internal/ship
+ok github.com/veronica-agent/cans/internal/tts
+
+$ git diff origin/main -- go.mod go.sum
+(empty)
+
+$ wc -l $(git diff --name-only origin/main -- '*.go') | sort -n
+ 76 internal/tts/synth.go
+ 83 internal/tts/session.go
+ 98 internal/tts/synth_bin.go
+ 130 cmd/cans/main.go
+ 142 cmd/cans/main_test.go
+ 529 total
+```
+
+`git diff --name-only origin/main` does not list untracked files. New files in this sequence, all < 500:
+
+```
+ 11 internal/say/exit.go
+ 24 cmd/cans/tty.go
+ 29 internal/say/options.go
+ 39 internal/say/input.go
+ 40 cmd/cans/tty_test.go
+ 79 internal/say/say.go
+ 90 internal/say/input_test.go
+ 98 cmd/cans/say_args.go
+ 118 cmd/cans/say_args_test.go
+ 242 internal/say/say_test.go
+```
+
+Largest functions (non-test): `parseSay` 48 lines, `run` 44 lines. None over 50.
+
+```
+$ ./bin/cans say "Put the cans on." ; echo "exit=$?"
+ttfa_ms=36796
+exit=0
+```
+
+No `cans-*.wav` in `$TMPDIR` before or after. `ttfa_ms` is total synth wall time (CONTEXT deferred item); behavior matches v1: print, play, delete temp.
+
+Sequence 03 Done-when:
+
+```
+$ ./bin/cans say < /dev/null ; echo exit=$?
+say: empty text
+exit=2
+
+$ ./bin/cans say # under a pty
+say: missing text
+exit=2
+
+$ echo "Put the cans on." | CANS_NOPLAY=1 CANS_SAY_BIN= ./bin/cans say -o /tmp/cans-out/a.wav --json
+{"wav":"/tmp/cans-out/a.wav","ttfa_ms":12,"sample_rate":24000}
+```
+
+`/dev/null` is a char device; `stdinIsTTY` uses `TIOCGETA`, not `Stat` `ModeCharDevice`, so the empty-text case is this one.
+
+`just test unit` green. `just build quick` green.
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/05_review.md b/festivals/CV0001/003_IMPLEMENT/01_out/05_review.md
new file mode 100644
index 0000000..22525d2
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/05_review.md
@@ -0,0 +1,78 @@
+---
+fest_type: gate
+fest_id: 05_review.md
+fest_name: Code Review
+fest_parent: 01_out
+fest_order: 5
+fest_status: completed
+fest_autonomy: low
+fest_gate_id: review
+fest_gate_type: review
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.426704-06:00
+fest_updated: 2026-08-21T06:25:00.793352-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Code Review
+
+Review all code changes in this sequence for quality, correctness, and standards compliance.
+
+## Review Checklist
+
+### Code Quality
+
+- [x] Code is readable and well-organized
+- [x] Functions are focused (single responsibility)
+- [x] Naming is clear and consistent
+- [x] No unnecessary complexity or duplication
+
+### Standards Compliance
+
+- [x] Linting passes without warnings
+- [x] Formatting is consistent
+- [x] Project conventions are followed
+
+### Error Handling & Security
+
+- [x] Errors are handled appropriately
+- [x] No secrets in code
+- [x] Input validation present where needed
+- [x] No obvious security issues
+
+### Alignment
+
+- [x] Changes align with sequence goal
+- [x] No scope creep beyond what was requested
+
+## Findings
+
+Cold review by a different agent (`01_out/.review-01_out.md`). `git diff origin/main` plus untracked `internal/say/` and `cmd/cans/say_args*.go` / `tty*.go`.
+
+Festival points: ctx PASS · stdout PASS · flags PASS · lock N/A (02_lock) · wrap PASS · size/deps PASS (`worker.go` still 196) · tests PASS · README N/A · `cans say "x"` PASS.
+
+**Critical Issues:** (must fix)
+
+None.
+
+**Suggestions:** (should consider)
+
+1. `internal/say/input.go:28` — 4 MiB `LimitReader` clips and still succeeds. Peek one extra byte; over cap → `say: stdin too large`, `ExitFail`, do not speak.
+2. `internal/tts/session.go:56` — `MkdirAll` runs after `synthesize`. Check/create the `-o` parent first so a bad path fails before a clone.
+3. `internal/say/say.go:18` — comments narrate `1e8cea2` / v1 / `--stream`. Keep the why (usage must not fetch the mouth); drop the history.
+
+## cans-v2 review points
+
+The reviewer is a **different agent** than the implementer and reads `git diff origin/main` cold. Check, and write a finding for each miss:
+
+- `context.Context` is the first parameter on anything that does I/O; `ctx.Err()` checked before long work; cancellation reaches the worker
+- stdout carries only `ttfa_ms=`, wav paths, or JSONL; everything else is on stderr
+- every flag is one of `-o/--out`, `--json`, `--stream`, `--play`, `--nowait`, `--wait`, `-` — nothing else exists
+- the lock is acquired **before** `StartWorker` and released **after** `Client.Close` returns; the lock file is never deleted
+- errors are wrapped with the failing operation (`fmt.Errorf("say: %w", err)`)
+- no new `go.mod` requires; files < 500 lines; functions < 50 lines; `internal/tts/worker.go` unchanged in length
+- tests run on the fake worker with `CANS_NOPLAY=1`; error cases first; no sleeps in assertions
+- any README / tape / fixture / help text added is boring and technical and passes the professional-surface grep (`CONTEXT.md §Professional grep`)
+- `cans say "x"` is byte-identical in behavior to `1e8cea2`
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/06_iterate.md b/festivals/CV0001/003_IMPLEMENT/01_out/06_iterate.md
new file mode 100644
index 0000000..22db129
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/06_iterate.md
@@ -0,0 +1,49 @@
+---
+fest_type: gate
+fest_id: 06_iterate.md
+fest_name: Review Results and Iterate
+fest_parent: 01_out
+fest_order: 6
+fest_status: completed
+fest_autonomy: medium
+fest_gate_id: iterate
+fest_gate_type: iterate
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.429279-06:00
+fest_updated: 2026-08-21T06:26:51.78683-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Review Results and Iterate
+
+Address all findings from testing and code review. Iterate until the sequence meets quality standards.
+
+## Findings to Address
+
+### From Testing
+
+- [x] None. Gate commands were green (`gofmt`, `vet`, `go test`, no go.mod drift, one-shot `ttfa_ms=N` + temp deleted).
+
+### From Code Review
+
+- [x] Stdin over 4 MiB is `say: stdin too large` / `ExitFail`; at-cap still succeeds (`input.go`, `TestResolveTextStdinTooLarge`).
+- [x] Named `-o` parent is created before `synthesize` / `sayBin` (`session.go`, `synth_bin.go`); `TestRunOutUnderAFileFails` asserts the fake bin did not run.
+- [x] Dropped `1e8cea2` / v1 / `--stream` narration in `say.go` and `input.go`.
+
+## Iteration
+
+For each finding:
+
+1. Fix the issue
+2. Re-run affected tests
+3. Verify linting passes
+
+## Definition of Done
+
+- [x] All critical findings fixed
+- [x] All tests pass after changes
+- [x] Linting passes
+- [x] Code review findings addressed
+- [x] Ready to commit
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/07_fest_commit.md b/festivals/CV0001/003_IMPLEMENT/01_out/07_fest_commit.md
new file mode 100644
index 0000000..433ea28
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/07_fest_commit.md
@@ -0,0 +1,70 @@
+---
+fest_type: gate
+fest_id: 07_fest_commit.md
+fest_name: Fest Commit Changes
+fest_parent: 01_out
+fest_order: 7
+fest_status: completed
+fest_autonomy: high
+fest_gate_id: fest-commit
+fest_gate_type: commit
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.432833-06:00
+fest_updated: 2026-08-21T06:27:44.797452-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Commit Sequence Changes
+
+Commit all changes from this sequence using the `fest commit` command.
+
+## Pre-Commit Checklist
+
+- [x] All tests pass
+- [x] Linting is clean
+- [x] No debug code or temporary files
+- [x] No secrets or credentials in staged changes
+
+## Commit Command
+
+You **MUST** use `fest commit` — not `git commit`. The `fest commit` command tags
+commits with task reference IDs for tracking and metrics.
+
+```bash
+fest commit -m ": "
+```
+
+**CRITICAL:** Do NOT use `git commit`, `git add && git commit`, or any other git
+commit workflow. Always use `fest commit` so task references are preserved.
+
+## Commit Message Format
+
+```
+:
+
+
+
+
+```
+
+**Types:** `feat`, `fix`, `refactor`, `test`, `docs`, `chore`
+
+The message should describe WHAT changed and WHY. Be specific about files,
+functions, or features that were added, modified, or removed.
+
+## Ethical Requirements
+
+The following practices are **prohibited** in commit messages:
+
+- NO "Co-authored-by" tags for AI assistants
+- NO AI tool attribution or advertisements
+- NO links to AI services or products
+
+## Definition of Done
+
+- [ ] Pre-commit checklist verified
+- [ ] Commit created with `fest commit` (not `git commit`)
+- [ ] Message describes what changed and why
+- [ ] No prohibited content in commit message
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/01_out/SEQUENCE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/01_out/SEQUENCE_GOAL.md
new file mode 100644
index 0000000..5313d81
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/01_out/SEQUENCE_GOAL.md
@@ -0,0 +1,23 @@
+---
+fest_type: sequence
+fest_id: 01_out
+fest_name: out
+fest_parent: 003_IMPLEMENT
+fest_order: 1
+fest_status: completed
+fest_created: 2026-08-21T05:04:55.63283-06:00
+fest_updated: 2026-08-21T06:27:44.798239-06:00
+fest_tracking: true
+fest_working_dir: projects/worktrees/cans/cans-v2
+---
+
+
+# Sequence Goal: 01_out
+
+**Primary Goal:** `cans say` writes a wav where it is told, reads one utterance from stdin, and can emit a JSON record — while `cans say "x"` stays byte-for-byte what it was at `1e8cea2`.
+
+Covers P0-1, P0-2, P0-3, P0-4, P0-9, P0-10, P0-15, P0-16, P0-17, P0-18, P0-20. Decisions D004, D006, D010, D012.
+
+Creates `internal/say` (the flow) and `cmd/cans/say_args.go` (the grammar); adds `tts.SayTo`. Nothing else moves. Exit codes in this sequence: 0, 1, 2.
+
+Dependencies: none. Unblocks everything else.
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/01_flock.md b/festivals/CV0001/003_IMPLEMENT/02_lock/01_flock.md
new file mode 100644
index 0000000..e03171e
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/01_flock.md
@@ -0,0 +1,55 @@
+---
+fest_type: task
+fest_id: 01_flock.md
+fest_name: flock
+fest_parent: 02_lock
+fest_order: 1
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.606176-06:00
+fest_updated: 2026-08-21T06:30:55.366718-06:00
+fest_tracking: true
+---
+
+
+# Task: flock
+
+## Objective
+
+`internal/mouth`: the mouth lock. One exclusive `flock` on a file, acquired with wait-forever / try-once / bounded semantics under a `context.Context`, released explicitly, dropped by the kernel if the holder dies.
+
+## Requirements
+
+- [x] API (D011):
+ ```go
+ package mouth
+ var ErrBusy = errors.New("mouth busy")
+ type Lock struct{ f *os.File }
+ // Path is CANS_HOME/mouth.lock.
+ func Path() string
+ // Acquire takes an exclusive flock on path. wait < 0 waits forever; wait == 0 tries once;
+ // wait > 0 gives up after wait. onWait, if non-nil, runs once the first time the lock is found held.
+ func Acquire(ctx context.Context, path string, wait time.Duration, onWait func()) (*Lock, error)
+ func (l *Lock) Release() error
+ ```
+- [x] `syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)` polled every 100 ms (D003). `EWOULDBLOCK` means held. Any other errno is wrapped: `fmt.Errorf("mouth: lock %s: %w", path, err)`.
+- [x] `ctx.Done()` while waiting → close the fd, return `ctx.Err()`. Deadline passed, or `wait == 0` and held → close the fd, return `ErrBusy` (unwrapped, so callers use `errors.Is`).
+- [x] The file is created `0644` if missing (parent dir `MkdirAll`), and **never deleted**. `Release` = `LOCK_UN` then `Close`; `Release` on a nil `*Lock` is a no-op returning nil.
+- [x] No new dependencies; `syscall` only.
+
+## Implementation
+
+1. `internal/mouth/lock.go`, under 120 lines. `Acquire` checks `ctx.Err()` first. Keep the poll loop as its own function (`tryUntil`) so `Acquire` stays under 50 lines.
+2. `Path()` uses `ship.Home()` (import `internal/ship`; no cycle — `ship` does not import `mouth`).
+3. Tests in `internal/mouth/lock_test.go` (error cases first; `t.TempDir()` for the path; no `time.Sleep` in assertions except the bounded-wait one):
+ - held, `wait == 0` → `ErrBusy` (two `Acquire` calls in one process on the same path **do** conflict under `flock` — separate open file descriptions).
+ - held, `wait = 150ms` → `ErrBusy`, elapsed ≥ 150 ms.
+ - held, `wait < 0`, ctx cancelled after 50 ms → `ctx.Err()`.
+ - `onWait` fires exactly once across several polls (an `atomic.Int32`, `wait = 350ms`).
+ - release, then `Acquire(wait == 0)` succeeds; the lock file still exists.
+ - cross-process `kill -9`: the re-exec helper pattern. `TestHelperHoldLock` returns immediately unless `MOUTH_HELPER=1`; when set it `Acquire`s `MOUTH_LOCK`, prints `held\n`, and sleeps. The parent runs `exec.Command(os.Args[0], "-test.run=TestHelperHoldLock")` with that env, waits for `held`, asserts `Acquire(wait == 0)` → `ErrBusy`, then `cmd.Process.Kill()`, `cmd.Wait()`, and asserts `Acquire(wait == 0)` succeeds.
+
+## Done when
+
+- [x] `go test ./internal/mouth/ -v` green with every case above
+- [x] `gofmt -l .` empty; `go vet` clean; `lock.go` < 120 lines; no function > 50 lines
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/02_session_lock.md b/festivals/CV0001/003_IMPLEMENT/02_lock/02_session_lock.md
new file mode 100644
index 0000000..a260c29
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/02_session_lock.md
@@ -0,0 +1,43 @@
+---
+fest_type: task
+fest_id: 02_session_lock.md
+fest_name: session_lock
+fest_parent: 02_lock
+fest_order: 2
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.60691-06:00
+fest_updated: 2026-08-21T06:33:42.282307-06:00
+fest_tracking: true
+---
+
+
+# Task: session_lock
+
+## Objective
+
+Every `tts.Session` is lock-guarded by construction: `OpenWith` acquires the mouth lock **before** `StartWorker`, the `Session` carries it, and `Close` releases it **after** the worker has exited.
+
+## Requirements
+
+- [x] `tts.Options{ Wait time.Duration; OnWait func() }`, `func DefaultOptions() Options` (`Wait: -1`, `OnWait: defaultOnWait`), and `func OpenWith(ctx context.Context, o Options) (*Session, error)`. `Open(ctx)` = `OpenWith(ctx, DefaultOptions())`. `defaultOnWait` writes `waiting for the mouth…` to `os.Stderr` (P0-12).
+- [x] Order inside `OpenWith`: `ctx.Err()` → `mouth.Acquire(ctx, mouth.Path(), o.Wait, o.OnWait)` → stat the worker binary → `StartWorker`. If anything after `Acquire` fails, `Release` before returning the error. `ErrBusy` is returned unwrapped (callers map it to 75).
+- [x] `Session{c *Client; lock *mouth.Lock}`. `Close`: `err := s.c.Close()` (shutdown + wait), then `s.lock.Release()`, return the worker error if any (D001: lock lifetime == session lifetime).
+- [x] `SayToWith(ctx, text, cur, out string, o Options)`; `SayTo` = `SayToWith(…, DefaultOptions())`. The `CANS_SAY_BIN` path takes no lock (no worker).
+- [x] `internal/tts/worker.go` is not touched.
+
+## Implementation
+
+1. `internal/tts/session.go`: add `Options`, `DefaultOptions()`, `OpenWith`; `Open` becomes a wrapper. Keep `OpenWith` under 50 lines by moving the worker start into `startSession(ctx, lock *mouth.Lock) (*Session, error)`.
+2. `internal/tts/synth.go`: `SayToWith`; existing funcs become wrappers.
+3. Tests in `internal/tts/session_test.go` (`CANS_HOME` = temp dir; `CANS_NOPLAY=1`; fake worker built as in `synth_test.go`):
+ - **ordering proof** (error case first): open `A := OpenWith(ctx, Options{Wait: -1})` on the fake worker; then set `CANS_WORKER_BIN` to a path that does not exist and call `OpenWith(ctx, Options{Wait: 0})` — it must return `mouth.ErrBusy`, **not** `native mouth missing`. That proves the lock is taken before the worker is looked at.
+ - `A.Close()`, then `OpenWith(Options{Wait: 0})` on the fake worker succeeds, and `Close` works.
+ - `OnWait` fires once while A is held and a second `OpenWith(Options{Wait: 300ms})` waits and returns `ErrBusy`.
+ - `$CANS_HOME/mouth.lock` exists after the first `Open`.
+
+## Done when
+
+- [x] `CANS_NOPLAY=1 go test ./internal/tts/ -v` green, existing tests unchanged
+- [x] `./bin/cans say "Put the cans on."` still behaves exactly as before and `ls ~/.cans/mouth.lock` exists afterwards
+- [x] `gofmt -l .` empty; `go vet` clean; `session.go` < 200 lines
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/03_flags_booth.md b/festivals/CV0001/003_IMPLEMENT/02_lock/03_flags_booth.md
new file mode 100644
index 0000000..16f2182
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/03_flags_booth.md
@@ -0,0 +1,40 @@
+---
+fest_type: task
+fest_id: 03_flags_booth.md
+fest_name: flags_booth
+fest_parent: 02_lock
+fest_order: 3
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.607551-06:00
+fest_updated: 2026-08-21T06:39:44.74907-06:00
+fest_tracking: true
+---
+
+
+# Task: flags_booth
+
+## Objective
+
+`--nowait` and `--wait` reach the lock from `cans say`; `mouth busy` is exit 75; the booth takes the lock for its whole run (D001).
+
+## Requirements
+
+- [x] `say.Run` builds `tts.Options{Wait: o.Wait, OnWait: func() { fmt.Fprintln(stderr, "waiting for the mouth…") }}` and calls `tts.SayToWith`. `errors.Is(err, mouth.ErrBusy)` → stderr `say: mouth busy`, return `ExitBusy` (75). P0-13: `--nowait` is `Wait == 0`; `--wait 30s` is `Wait == 30s`; default `-1` waits forever.
+- [x] `booth.Run`: replace `tts.Open(ctx)` with `tts.OpenWith(ctx, tts.Options{Wait: -1, OnWait: func() { fmt.Fprintln(os.Stderr, "waiting for the mouth…") }})` — **before** `tea.NewProgram`, so the line is visible in the terminal and the TUI opens only once the mouth is held. The existing `defer sess.Close()` releases at exit. `CANS_SAY_BIN` path unchanged (no session, no lock).
+- [x] Exit 75 is documented in the `usage` const in one line: `exit 75 when another cans holds the mouth and --nowait was set`.
+
+## Implementation
+
+1. `internal/say/say.go`: a `lockOpts(o Options, stderr io.Writer) tts.Options` helper; map `ErrBusy` in one place (`exitFor(err error) int`).
+2. `internal/booth/booth.go` `Run`: a three-line change. Do not touch the model.
+3. Tests (error first) in `internal/say/say_test.go`:
+ - hold the lock in-process: `lk, _ := mouth.Acquire(ctx, mouth.Path(), 0, nil)` (with `CANS_HOME` temp); `Run` with the fake worker and `Wait: 0` → 75, stderr contains `mouth busy`, stdout empty.
+ - `Wait: 200ms` → 75 and elapsed ≥ 200 ms; stderr contains `waiting for the mouth…` exactly once.
+ - `lk.Release()`, `Run` again with `Wait: 0` → 0.
+ - `cmd/cans/main_test.go`: `run([]string{"say", "--nowait", "x"})` against a held lock (temp `CANS_HOME`, fake worker) → 75.
+
+## Done when
+
+- [x] Manual, recorded in the testing gate: terminal A `./bin/cans` (booth, leave it open); terminal B `./bin/cans say --nowait "Put the cans on."` → prints `say: mouth busy`, `echo $?` → 75; terminal B `./bin/cans say "Put the cans on."` → prints `waiting for the mouth…` and speaks after the booth is closed with Esc.
+- [x] `CANS_NOPLAY=1 go test ./...` green; `gofmt -l .` empty; `go vet` clean
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/04_testing.md b/festivals/CV0001/003_IMPLEMENT/02_lock/04_testing.md
new file mode 100644
index 0000000..9cd54ba
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/04_testing.md
@@ -0,0 +1,120 @@
+---
+fest_type: gate
+fest_id: 04_testing.md
+fest_name: Testing and Verification
+fest_parent: 02_lock
+fest_order: 4
+fest_status: completed
+fest_autonomy: medium
+fest_gate_id: testing
+fest_gate_type: testing
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.435166-06:00
+fest_updated: 2026-08-21T06:40:57.097595-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Testing and Verification
+
+Verify all functionality implemented in this sequence works correctly.
+
+## Test Categories
+
+### Unit Tests
+
+- [x] All unit tests pass
+- [x] New/modified code has test coverage
+- [x] Tests are meaningful (not just coverage padding)
+
+### Integration Tests
+
+- [x] Integration tests pass
+- [x] Components work together correctly
+
+### Error Handling
+
+- [x] Invalid inputs are rejected gracefully
+- [x] Error messages are clear and actionable
+- [x] Recovery paths work correctly
+
+## Verification
+
+- [x] Build completes without warnings
+- [x] No regressions introduced
+- [x] Coverage meets project requirements
+
+## cans-v2 commands (all from the `cans-v2` worktree; every one must be clean)
+
+```bash
+gofmt -l . # prints nothing
+go vet ./...
+CANS_NOPLAY=1 go test ./... # fake worker only — no real mouth
+git diff origin/main -- go.mod go.sum # empty: no new dependencies
+wc -l $(git diff --name-only origin/main -- '*.go') | sort -n | tail -5 # every file < 500
+./bin/cans say "Put the cans on." ; echo "exit=$?" # one-shot unchanged: ttfa_ms=N, plays, temp wav gone
+```
+
+Then the sequence's own checks from its task files. Record the output of each command in this gate file under **Results** before marking it complete.
+
+## Results
+
+Worktree: `projects/worktrees/cans/cans-v2`. Recorded 2026-08-21.
+
+```
+$ gofmt -l .
+(empty)
+
+$ go vet ./...
+(empty, exit 0)
+
+$ CANS_NOPLAY=1 go test ./...
+ok github.com/veronica-agent/cans/cmd/cans
+ok github.com/veronica-agent/cans/internal/audio
+ok github.com/veronica-agent/cans/internal/booth
+ok github.com/veronica-agent/cans/internal/doctor
+ok github.com/veronica-agent/cans/internal/keep
+ok github.com/veronica-agent/cans/internal/mouth
+ok github.com/veronica-agent/cans/internal/play
+ok github.com/veronica-agent/cans/internal/say
+ok github.com/veronica-agent/cans/internal/ship
+ok github.com/veronica-agent/cans/internal/tts
+
+$ git diff origin/main -- go.mod go.sum
+(empty)
+
+$ wc -l $(git diff --name-only origin/main -- '*.go') | sort -n | tail -5
+ 131 internal/tts/session.go
+ 132 cmd/cans/main.go
+ 164 internal/booth/booth.go
+ 200 cmd/cans/main_test.go
+ 339 internal/say/say_test.go
+```
+
+New untracked: `internal/mouth/lock.go` 115, `lock_test.go` 151, `internal/tts/session_test.go` 76. `worker.go` still 196.
+
+```
+$ ./bin/cans say "Put the cans on." ; echo exit=$?
+ttfa_ms=5904
+exit=0
+$ ls -l ~/.cans/mouth.lock
+-rw-r--r-- 0 Aug 21 06:32 .../mouth.lock
+```
+
+Lock file remains after the process exits (never deleted).
+
+Sequence checks (real binary; lock held with `fcntl.flock` on `~/.cans/mouth.lock`, same as a live booth):
+
+```
+$ ./bin/cans say --nowait "Put the cans on."
+say: mouth busy
+exit=75
+
+$ ./bin/cans say --wait 200ms "Put the cans on."
+waiting for the mouth…
+say: mouth busy
+exit=75 elapsed 0.207s
+```
+
+Booth under a pty, then `--nowait`: stderr `say: mouth busy`, exit 75, stdout empty. `--nowait` does not print the waiting line.
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/05_review.md b/festivals/CV0001/003_IMPLEMENT/02_lock/05_review.md
new file mode 100644
index 0000000..c075e01
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/05_review.md
@@ -0,0 +1,75 @@
+---
+fest_type: gate
+fest_id: 05_review.md
+fest_name: Code Review
+fest_parent: 02_lock
+fest_order: 5
+fest_status: completed
+fest_autonomy: low
+fest_gate_id: review
+fest_gate_type: review
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.435924-06:00
+fest_updated: 2026-08-21T06:55:20.007767-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Code Review
+
+Review all code changes in this sequence for quality, correctness, and standards compliance.
+
+## Review Checklist
+
+### Code Quality
+
+- [x] Code is readable and well-organized
+- [x] Functions are focused (single responsibility)
+- [x] Naming is clear and consistent
+- [x] No unnecessary complexity or duplication
+
+### Standards Compliance
+
+- [x] Linting passes without warnings
+- [x] Formatting is consistent
+- [x] Project conventions are followed
+
+### Error Handling & Security
+
+- [x] Errors are handled appropriately
+- [x] No secrets in code
+- [x] Input validation present where needed
+- [x] No obvious security issues
+
+### Alignment
+
+- [x] Changes align with sequence goal
+- [x] No scope creep beyond what was requested
+
+## Findings
+
+Cold review by a different agent (`02_lock/.review-02_lock.md`). All nine festival points PASS. `worker.go` still 196.
+
+**Critical Issues:** (must fix)
+
+None.
+
+**Suggestions:** (should consider)
+
+1. `internal/mouth/lock_test.go:96` — `TestHelperHoldLock` dropped the `*Lock` (`_ = l`) then `select {}`. GC closing the fd would release the flock while the helper still lived. `defer runtime.KeepAlive(l)`.
+2. `internal/mouth/lock.go:75` — try flock before treating a positive wait as expired, so `--wait 1ns` on a free mouth still tries once. `onWait` only when the caller is about to block.
+
+## cans-v2 review points
+
+The reviewer is a **different agent** than the implementer and reads `git diff origin/main` cold. Check, and write a finding for each miss:
+
+- `context.Context` is the first parameter on anything that does I/O; `ctx.Err()` checked before long work; cancellation reaches the worker
+- stdout carries only `ttfa_ms=`, wav paths, or JSONL; everything else is on stderr
+- every flag is one of `-o/--out`, `--json`, `--stream`, `--play`, `--nowait`, `--wait`, `-` — nothing else exists
+- the lock is acquired **before** `StartWorker` and released **after** `Client.Close` returns; the lock file is never deleted
+- errors are wrapped with the failing operation (`fmt.Errorf("say: %w", err)`)
+- no new `go.mod` requires; files < 500 lines; functions < 50 lines; `internal/tts/worker.go` unchanged in length
+- tests run on the fake worker with `CANS_NOPLAY=1`; error cases first; no sleeps in assertions
+- any README / tape / fixture / help text added is boring and technical and passes the professional-surface grep (`CONTEXT.md §Professional grep`)
+- `cans say "x"` is byte-identical in behavior to `1e8cea2`
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/06_iterate.md b/festivals/CV0001/003_IMPLEMENT/02_lock/06_iterate.md
new file mode 100644
index 0000000..e74c6eb
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/06_iterate.md
@@ -0,0 +1,48 @@
+---
+fest_type: gate
+fest_id: 06_iterate.md
+fest_name: Review Results and Iterate
+fest_parent: 02_lock
+fest_order: 6
+fest_status: completed
+fest_autonomy: medium
+fest_gate_id: iterate
+fest_gate_type: iterate
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.47309-06:00
+fest_updated: 2026-08-21T06:55:20.063413-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Review Results and Iterate
+
+Address all findings from testing and code review. Iterate until the sequence meets quality standards.
+
+## Findings to Address
+
+### From Testing
+
+- [x] None. Gate commands were green; `--nowait` is 75 / `say: mouth busy`; `--wait 200ms` prints waiting once then busy.
+
+### From Code Review
+
+- [x] `TestHelperHoldLock` now `defer runtime.KeepAlive(l)` so GC cannot drop the flock.
+- [x] `tryUntil` flocks first, then wait==0 / deadline / onWait. Comment matches: onWait only when about to block.
+
+## Iteration
+
+For each finding:
+
+1. Fix the issue
+2. Re-run affected tests
+3. Verify linting passes
+
+## Definition of Done
+
+- [x] All critical findings fixed
+- [x] All tests pass after changes
+- [x] Linting passes
+- [x] Code review findings addressed
+- [x] Ready to commit
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/07_fest_commit.md b/festivals/CV0001/003_IMPLEMENT/02_lock/07_fest_commit.md
new file mode 100644
index 0000000..e4dc31b
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/07_fest_commit.md
@@ -0,0 +1,70 @@
+---
+fest_type: gate
+fest_id: 07_fest_commit.md
+fest_name: Fest Commit Changes
+fest_parent: 02_lock
+fest_order: 7
+fest_status: completed
+fest_autonomy: high
+fest_gate_id: fest-commit
+fest_gate_type: commit
+fest_managed: true
+fest_created: 2026-08-21T05:04:57.484726-06:00
+fest_updated: 2026-08-21T06:55:34.625317-06:00
+fest_tracking: true
+fest_version: "1.0"
+---
+
+
+# Gate: Commit Sequence Changes
+
+Commit all changes from this sequence using the `fest commit` command.
+
+## Pre-Commit Checklist
+
+- [x] All tests pass
+- [x] Linting is clean
+- [x] No debug code or temporary files
+- [x] No secrets or credentials in staged changes
+
+## Commit Command
+
+You **MUST** use `fest commit` — not `git commit`. The `fest commit` command tags
+commits with task reference IDs for tracking and metrics.
+
+```bash
+fest commit -m ": "
+```
+
+**CRITICAL:** Do NOT use `git commit`, `git add && git commit`, or any other git
+commit workflow. Always use `fest commit` so task references are preserved.
+
+## Commit Message Format
+
+```
+:
+
+
+
+
+```
+
+**Types:** `feat`, `fix`, `refactor`, `test`, `docs`, `chore`
+
+The message should describe WHAT changed and WHY. Be specific about files,
+functions, or features that were added, modified, or removed.
+
+## Ethical Requirements
+
+The following practices are **prohibited** in commit messages:
+
+- NO "Co-authored-by" tags for AI assistants
+- NO AI tool attribution or advertisements
+- NO links to AI services or products
+
+## Definition of Done
+
+- [x] Pre-commit checklist verified
+- [x] Commit created with `fest commit` (not `git commit`)
+- [x] Message describes what changed and why
+- [x] No prohibited content in commit message
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/02_lock/SEQUENCE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/02_lock/SEQUENCE_GOAL.md
new file mode 100644
index 0000000..234dc86
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/02_lock/SEQUENCE_GOAL.md
@@ -0,0 +1,23 @@
+---
+fest_type: sequence
+fest_id: 02_lock
+fest_name: lock
+fest_parent: 003_IMPLEMENT
+fest_order: 2
+fest_status: completed
+fest_created: 2026-08-21T05:04:55.867248-06:00
+fest_updated: 2026-08-21T06:55:34.626135-06:00
+fest_tracking: true
+fest_working_dir: projects/worktrees/cans/cans-v2
+---
+
+
+# Sequence Goal: 02_lock
+
+**Primary Goal:** Exactly one `qwen3-tts-worker` is resident, ever. A `flock` on `CANS_HOME/mouth.lock` is taken before the worker starts and released after it exits; the booth holds it for its whole run; `--nowait` exits 75 and `--wait` bounds the block.
+
+Covers P0-11, P0-12, P0-13, P0-14, P0-15 (75), P0-19 (`kill -9`), P0-20. Decisions D001, D003, D011.
+
+Creates `internal/mouth`; adds `tts.OpenWith`; the booth uses it. This is the only genuinely new machinery in v2.
+
+Dependencies: 01_out (the `say.Options` the flags land in). Must be committed before 03_stream starts — safe before fast.
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/03_stream/01_stream_loop.md b/festivals/CV0001/003_IMPLEMENT/03_stream/01_stream_loop.md
new file mode 100644
index 0000000..261d50e
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/03_stream/01_stream_loop.md
@@ -0,0 +1,46 @@
+---
+fest_type: task
+fest_id: 01_stream_loop.md
+fest_name: stream_loop
+fest_parent: 03_stream
+fest_order: 1
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.823666-06:00
+fest_updated: 2026-08-21T06:58:31.18615-06:00
+fest_tracking: true
+---
+
+
+# Task: stream_loop
+
+## Objective
+
+`--stream`: one `Session` for the run, one utterance per stdin line, a flushed record per line, blank lines skipped, bad lines reported and skipped, exit 1 at EOF if any failed.
+
+## Requirements
+
+- [x] `internal/say/stream.go`: `func runStream(ctx context.Context, o Options, stdin io.Reader, stdout, stderr io.Writer) int`; `Run` dispatches on `o.Stream`.
+- [x] `doctor.Prepare` → `keep.Load()` → `tts.OpenWith(ctx, lockOpts(...))` once (busy → 75; other → 1) → `defer sess.Close()`.
+- [x] `bufio.Scanner` over stdin with `sc.Buffer(make([]byte, 0, 64*1024), 1<<20)`. Two counters: `lineNo` (every stdin line, 1-based) and `idx` (spoken lines, 1-based). `strings.TrimSpace` each line; blank → `continue` without touching `idx` (D005).
+- [x] Per spoken line: `out := ""` when `o.Out == ""` else the path for `idx` (task 02 adds the template; until then use `o.Out` only when it has no `%`); `r, err := sess.SayTo(ctx, line, cur, out)`.
+- [x] Failure: stderr `line N: `; under `--json` write `{"line":N,"error":"…"}`; `failed++`; continue. Success: record per D006 — JSON `{"line":N,"wav":…,"ttfa_ms":…,"sample_rate":…}`; else `-o` → the path; else `ttfa_ms=N`. **Flush after every record** (wrap stdout in a `bufio.Writer`, `Flush()` each time).
+- [x] No `-o`: `play.File` then `RemoveTemp` per line (D007). `-o` + `--play`: play after writing.
+- [x] At EOF: `sc.Err()` → stderr, 1. `failed > 0` → 1. Else 0. Backpressure is inherent: the next `SayTo` is sent only after the previous returns — do not add a buffer or goroutine.
+- [x] Two record structs (`okRecord`, `errRecord`) so `ttfa_ms: 0` is never dropped by `omitempty`.
+
+## Implementation
+
+1. Keep `runStream` under 50 lines by splitting: `speakLine(...)` (one line → record or error) and `emitStream(...)`.
+2. Extend `internal/tts/testdata/fakeworker/main.go` by one branch: `if req.Text == "fail" { print an error record; continue }`. That is the only change to testdata.
+3. Tests (error first) in `internal/say/stream_test.go`, fake worker, `CANS_HOME` temp, `CANS_NOPLAY=1`:
+ - input `"a\nfail\n\nb\n"`, `Out` = a per-idx path (the template lands in task 02): `001.wav` and `002.wav` exist, no `003.wav`; stdout has two paths; stderr has `line 2: …`; exit 1.
+ - same with `JSON`: three records, `{"line":2,"error":…}` in the middle, `line` values 1, 2, 4.
+ - **one worker**: point `CANS_WORKER_BIN` at a tiny wrapper script that appends a line to a counter file then `exec`s the built fake worker; after a 5-line stream the counter file has exactly one line.
+ - busy: hold the lock, `Wait: 0` → 75 before any stdin is read.
+ - empty stdin → 0 with nothing on stdout.
+
+## Done when
+
+- [x] Tests green; `CANS_NOPLAY=1 go test ./...` green; `gofmt -l .` empty; `go vet` clean
+- [x] Manual, recorded in the testing gate: `printf 'Put the cans on.\nOne worker, one load.\nFiles land here.\n' | ./bin/cans say --stream -o '/tmp/cans-out/%03d.wav' --json` → three records; `pgrep -f qwen3-tts-worker | wc -l` during the run → 1
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/03_stream/02_out_template.md b/festivals/CV0001/003_IMPLEMENT/03_stream/02_out_template.md
new file mode 100644
index 0000000..c46bf3d
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/03_stream/02_out_template.md
@@ -0,0 +1,37 @@
+---
+fest_type: task
+fest_id: 02_out_template.md
+fest_name: out_template
+fest_parent: 03_stream
+fest_order: 2
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.824893-06:00
+fest_updated: 2026-08-21T07:00:07.01412-06:00
+fest_tracking: true
+---
+
+
+# Task: out_template
+
+## Objective
+
+`-o 'out/%03d.wav'` is a template in stream mode, validated up front; anything else is exit 2.
+
+## Requirements
+
+- [x] `internal/say/template.go`: `func checkOut(out string, stream bool) error` and `func outPath(out string, idx int) string`.
+- [x] Stream mode with `-o`: the template must contain **exactly one** verb and it must be integer-formatting — `%d`, `%3d`, `%03d`, `%-3d`. `%%` is a literal percent and does not count. Any other verb (`%s`, `%v`, `%f`), two verbs, or no verb → `say: -o needs one %d in --stream` (exit 2).
+- [x] One-shot: `-o` is a literal path. A verb in it → `say: -o template needs --stream` (exit 2). `%%` is written as `%`.
+- [x] `outPath` = `fmt.Sprintf(out, idx)` (one-shot: `strings.ReplaceAll(out, "%%", "%")`); parent dir created by `SayTo` (D012). Validation runs in `Run` **before** `doctor.Prepare` and before the lock is taken, so a typo never waits on the mouth.
+
+## Implementation
+
+1. Parse with a small scanner over the string rather than a regexp: walk runes; on `%` look at the next rune (`%` → literal; digits or `-` → consume, then require `d`; anything else → error); count verbs.
+2. Wire `checkOut` into `Run`; replace the per-idx path in `runStream` with `outPath`.
+3. Tests (error first) in `template_test.go`, table-driven: `%s`, `%03d-%d`, `out.wav` in stream (no verb), `%03d` in one-shot, `%q`; then `%03d` → `001`, `%d` → `1`, `out/%02d.wav` → `out/01.wav`, `%%` literal in one-shot, `x%%y%03d` in stream.
+
+## Done when
+
+- [x] `./bin/cans say --stream -o out.wav < lines.txt` → exit 2 with `say: -o needs one %d in --stream`, and the mouth was **not** started (no `waiting` line, instant)
+- [x] `CANS_NOPLAY=1 go test ./...` green; `gofmt -l .` empty; `go vet` clean
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/03_stream/03_cancel.md b/festivals/CV0001/003_IMPLEMENT/03_stream/03_cancel.md
new file mode 100644
index 0000000..6366db7
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/03_stream/03_cancel.md
@@ -0,0 +1,43 @@
+---
+fest_type: task
+fest_id: 03_cancel.md
+fest_name: cancel
+fest_parent: 03_stream
+fest_order: 3
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.825156-06:00
+fest_updated: 2026-08-21T07:02:25.162165-06:00
+fest_tracking: true
+---
+
+
+# Task: cancel
+
+## Objective
+
+Ctrl-C during a stream stops cleanly: finished wavs stay, the worker exits, the lock is released, stderr says where it stopped, exit 130 (D008).
+
+## Requirements
+
+- [x] `cmd/cans/main.go`: for `say`, `ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM); defer stop()`. The booth keeps its own Ctrl-C handling (bubbletea).
+- [x] `runStream` checks `ctx.Err()` before reading each next line. When the in-flight `SayTo` returns `ctx.Err()` (the worker client's `readLine` selects on `ctx.Done()`), do **not** count it as a failed line.
+- [x] On cancellation: stop reading stdin; `sess.Close()` runs (deferred — sends `shutdown`, waits for the worker, releases the lock); stderr `interrupted after line N` where N is the last **completed** stdin line; return `ExitInterrupted` (130). Completed wavs are left in place.
+- [x] One-shot: cancelled while waiting for the lock (`mouth.Acquire` returns `ctx.Err()`) → 130 with `say: interrupted` on stderr. Cancelled mid-synthesis → 130, temp wav removed if it exists.
+- [x] Cancel lands **between** requests at the worker: the worker has no mid-synth abort, so it finishes the utterance it is on before `shutdown` takes effect. The README states this (04_tape). Nothing in this task pretends otherwise.
+
+## Implementation
+
+1. `internal/say/stream.go`: a `ctx.Err()` check at the top of the loop, and an `errors.Is(err, context.Canceled)` branch in the per-line error path that breaks instead of counting.
+2. `cmd/cans/main.go`: the `NotifyContext` pair; pass `ctx` into `say.Run`.
+3. Test (the important one) in `stream_test.go`, fake worker:
+ - `pr, pw := io.Pipe()`; `stdout` is a mutex-guarded buffer. Goroutine: write `"a\n"`; poll the buffer until the first record appears (a loop with a 5 s cap, 10 ms steps); `cancel()`; write `"b\n"` and keep the pipe open.
+ - `Run` returns **130**; `001.wav` exists; `002.wav` does not; stderr contains `interrupted after line 1`.
+ - After `Run` returns: `mouth.Acquire(ctx, mouth.Path(), 0, nil)` succeeds (lock released) and the wrapper counter file still has one line (no restart).
+ - Close the pipe writer in `t.Cleanup`.
+4. One-shot cancel test: hold the lock, `Run` with `Wait: -1` and a ctx cancelled after 50 ms → 130.
+
+## Done when
+
+- [x] Tests green; `CANS_NOPLAY=1 go test ./...` green; `gofmt -l .` empty; `go vet` clean
+- [x] Manual, recorded in the testing gate: `seq 1 20 | sed 's/^/Line /' | ./bin/cans say --stream -o '/tmp/cans-out/%03d.wav'`, Ctrl-C after two files → `interrupted after line 2`, `echo $?` → 130, `ls /tmp/cans-out` shows 001 and 002, `pgrep -f qwen3-tts-worker` → nothing, and the next `./bin/cans say "x"` starts at once
\ No newline at end of file
diff --git a/festivals/CV0001/003_IMPLEMENT/03_stream/04_measure.md b/festivals/CV0001/003_IMPLEMENT/03_stream/04_measure.md
new file mode 100644
index 0000000..590bf66
--- /dev/null
+++ b/festivals/CV0001/003_IMPLEMENT/03_stream/04_measure.md
@@ -0,0 +1,60 @@
+---
+fest_type: task
+fest_id: 04_measure.md
+fest_name: measure
+fest_parent: 03_stream
+fest_order: 4
+fest_status: completed
+fest_autonomy: medium
+fest_created: 2026-08-21T05:04:56.825464-06:00
+fest_updated: 2026-08-21T17:49:28.619192-06:00
+fest_tracking: true
+---
+
+
+
+
+# Task: measure
+
+## Objective
+
+The numbers the README and the festival quote, taken on the real mouth with the machine otherwise idle, recorded with the commands that produced them (D013, P1-4).
+
+## Requirements
+
+- [x] Preconditions, checked and recorded: `uptime` 1-minute load below 16; `pgrep -fl qwen3-tts-worker` empty; `just build quick`. If the load is high, record `deferred: load X at