From 0f4e436d957466abfec1d8361370279a705a84b6 Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:28:01 -0600 Subject: [PATCH 1/9] [veronica:ea389d71-FE-CV0001] feat: cans say writes a wav, reads stdin, emits JSON parseSay accepts the full flag grammar in either order. internal/say.Run owns the one-shot flow: -o keeps the file, stdin is one utterance, --json prints a tts.Result line. Named -o parents are created before synthesis; stdin over 4MiB is an error. cans say "x" still prints ttfa_ms, plays, and deletes the temp wav. --- cmd/cans/main.go | 60 ++++----- cmd/cans/main_test.go | 26 ++++ cmd/cans/say_args.go | 98 +++++++++++++++ cmd/cans/say_args_test.go | 118 ++++++++++++++++++ cmd/cans/tty.go | 24 ++++ cmd/cans/tty_test.go | 40 ++++++ internal/say/exit.go | 11 ++ internal/say/input.go | 42 +++++++ internal/say/input_test.go | 127 +++++++++++++++++++ internal/say/options.go | 29 +++++ internal/say/say.go | 78 ++++++++++++ internal/say/say_test.go | 245 +++++++++++++++++++++++++++++++++++++ internal/tts/session.go | 14 ++- internal/tts/synth.go | 12 +- internal/tts/synth_bin.go | 41 +++++++ 15 files changed, 925 insertions(+), 40 deletions(-) create mode 100644 cmd/cans/say_args.go create mode 100644 cmd/cans/say_args_test.go create mode 100644 cmd/cans/tty.go create mode 100644 cmd/cans/tty_test.go create mode 100644 internal/say/exit.go create mode 100644 internal/say/input.go create mode 100644 internal/say/input_test.go create mode 100644 internal/say/options.go create mode 100644 internal/say/say.go create mode 100644 internal/say/say_test.go diff --git a/cmd/cans/main.go b/cmd/cans/main.go index 394ad92..56b2565 100644 --- a/cmd/cans/main.go +++ b/cmd/cans/main.go @@ -10,12 +10,12 @@ import ( "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 ) @@ -37,45 +37,17 @@ 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) + o, err := parseSay(args[1:]) 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 2 } - return 0 + o.StdinTTY = stdinIsTTY() + return say.Run(context.Background(), o, stdin, stdout, stderr) case "doctor": if err := doctor.Run(context.Background(), stdout, stderr); err != nil { return 1 @@ -106,6 +78,24 @@ func run(args []string) int { } } +// 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..7ef48b0 100644 --- a/cmd/cans/main_test.go +++ b/cmd/cans/main_test.go @@ -114,3 +114,29 @@ 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()) + } +} diff --git a/cmd/cans/say_args.go b/cmd/cans/say_args.go new file mode 100644 index 0000000..800f067 --- /dev/null +++ b/cmd/cans/say_args.go @@ -0,0 +1,98 @@ +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") + } + return nil +} diff --git a/cmd/cans/say_args_test.go b/cmd/cans/say_args_test.go new file mode 100644 index 0000000..5ed3240 --- /dev/null +++ b/cmd/cans/say_args_test.go @@ -0,0 +1,118 @@ +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 "}, + {"help long", []string{"--help"}, "cans say "}, + {"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"}, + {"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..64f7a74 --- /dev/null +++ b/cmd/cans/tty.go @@ -0,0 +1,24 @@ +package main + +import ( + "os" + "syscall" + "unsafe" +) + +// 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()) +} + +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_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/internal/say/exit.go b/internal/say/exit.go new file mode 100644 index 0000000..46f8187 --- /dev/null +++ b/internal/say/exit.go @@ -0,0 +1,11 @@ +package say + +// Exit codes. 75 is EX_TEMPFAIL, so xargs and retry loops read a busy mouth +// correctly; 130 is the shell's 128+SIGINT. +const ( + ExitOK = 0 + ExitFail = 1 + ExitUsage = 2 + ExitBusy = 75 + ExitInterrupted = 130 +) diff --git a/internal/say/input.go b/internal/say/input.go new file mode 100644 index 0000000..dc158c9 --- /dev/null +++ b/internal/say/input.go @@ -0,0 +1,42 @@ +package say + +import ( + "fmt" + "io" + "strings" +) + +// stdinLimit caps one stdin utterance. Over the cap is an error, not a clip. +const stdinLimit = 4 << 20 + +// resolveText picks the utterance: the argv text, or the whole of stdin when a +// bare `-` or an empty argv asked for it. The int is an exit code, 0 on +// success; the reason for anything else is already on stderr. +func resolveText(o Options, stdin io.Reader, stderr io.Writer) (string, int) { + if !o.Stdin && o.Text != "" { + return o.Text, ExitOK + } + if o.StdinTTY && !o.Stdin { + fmt.Fprintln(stderr, "say: missing text") + return "", ExitUsage + } + if stdin == nil { + fmt.Fprintln(stderr, "say: empty text") + return "", ExitUsage + } + b, err := io.ReadAll(io.LimitReader(stdin, stdinLimit+1)) + if err != nil { + fmt.Fprintf(stderr, "say: stdin: %v\n", err) + return "", ExitFail + } + if len(b) > stdinLimit { + fmt.Fprintln(stderr, "say: stdin too large") + return "", ExitFail + } + text := strings.TrimSpace(string(b)) + if text == "" { + fmt.Fprintln(stderr, "say: empty text") + return "", ExitUsage + } + return text, ExitOK +} diff --git a/internal/say/input_test.go b/internal/say/input_test.go new file mode 100644 index 0000000..ebb0711 --- /dev/null +++ b/internal/say/input_test.go @@ -0,0 +1,127 @@ +package say + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestResolveTextErrors(t *testing.T) { + tests := []struct { + name string + opts func(*Options) + stdin string + tty bool + want string + }{ + {"empty argv on a terminal", func(o *Options) { o.StdinTTY = true }, "", true, "say: missing text\n"}, + {"empty pipe", nil, "", false, "say: empty text\n"}, + {"whitespace pipe", nil, " \n\t \n", false, "say: empty text\n"}, + {"bare dash on an empty pipe", func(o *Options) { o.Stdin = true }, "", false, "say: empty text\n"}, + {"bare dash on a terminal reads it", func(o *Options) { o.Stdin, o.StdinTTY = true, true }, "", true, "say: empty text\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := DefaultOptions() + if tt.opts != nil { + tt.opts(&o) + } + var errBuf bytes.Buffer + var in io.Reader = strings.NewReader(tt.stdin) + if tt.tty && !o.Stdin { + in = failingReader{t} + } + got, code := resolveText(o, in, &errBuf) + if code != ExitUsage { + t.Fatalf("code %d, want %d", code, ExitUsage) + } + if got != "" { + t.Fatalf("text %q, want empty", got) + } + if errBuf.String() != tt.want { + t.Fatalf("stderr %q, want %q", errBuf.String(), tt.want) + } + }) + } +} + +func TestResolveText(t *testing.T) { + tests := []struct { + name string + opts func(*Options) + stdin string + want string + }{ + {"argv text wins", func(o *Options) { o.Text = "Put the cans on." }, "", "Put the cans on."}, + {"pipe is one utterance", nil, "Put the cans on.\n", "Put the cans on."}, + {"bare dash reads the pipe", func(o *Options) { o.Stdin = true }, "Put the cans on.\n", "Put the cans on."}, + {"whole pipe, newlines and all", nil, "line one\nline two\n", "line one\nline two"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := DefaultOptions() + if tt.opts != nil { + tt.opts(&o) + } + var errBuf bytes.Buffer + got, code := resolveText(o, strings.NewReader(tt.stdin), &errBuf) + if code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if got != tt.want { + t.Fatalf("text %q, want %q", got, tt.want) + } + if errBuf.Len() != 0 { + t.Fatalf("stderr %q", errBuf.String()) + } + }) + } +} + +func TestResolveTextDoesNotReadStdinForArgvText(t *testing.T) { + o := DefaultOptions() + o.Text = "Put the cans on." + var errBuf bytes.Buffer + got, code := resolveText(o, failingReader{t}, &errBuf) + if code != ExitOK || got != o.Text { + t.Fatalf("%q %d", got, code) + } +} + +func TestResolveTextStdinTooLarge(t *testing.T) { + o := DefaultOptions() + var errBuf bytes.Buffer + got, code := resolveText(o, io.LimitReader(fillReader{}, stdinLimit+1), &errBuf) + if code != ExitFail { + t.Fatalf("code %d, want %d", code, ExitFail) + } + if got != "" { + t.Fatalf("text %q, want empty", got) + } + if errBuf.String() != "say: stdin too large\n" { + t.Fatalf("stderr %q", errBuf.String()) + } +} + +func TestResolveTextStdinAtCap(t *testing.T) { + o := DefaultOptions() + var errBuf bytes.Buffer + got, code := resolveText(o, io.LimitReader(fillReader{}, stdinLimit), &errBuf) + if code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if len(got) != stdinLimit { + t.Fatalf("len %d, want %d", len(got), stdinLimit) + } +} + +// fillReader fills every Read with 'x' and never EOFs. +type fillReader struct{} + +func (fillReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 'x' + } + return len(p), nil +} diff --git a/internal/say/options.go b/internal/say/options.go new file mode 100644 index 0000000..8e2b221 --- /dev/null +++ b/internal/say/options.go @@ -0,0 +1,29 @@ +// Package say runs cans say: one-shot, file output, stdin, stream. +package say + +import "time" + +// WaitForever is the default Options.Wait: block until the mouth is free. +const WaitForever time.Duration = -1 + +// Options is one parsed `cans say` command line. +type Options struct { + // Text is the positional arguments joined with single spaces. + Text string + // Stdin is a bare `-`: read the utterance from stdin. + Stdin bool + // StdinTTY is set by main, never by the parser. + StdinTTY bool + // Out is the -o/--out path; empty means a temp wav. + Out string + JSON bool + Stream bool + Play bool + // Wait bounds the mouth lock: WaitForever, 0 (--nowait), or --wait d. + Wait time.Duration +} + +// DefaultOptions is a say with no flags: wait forever for the mouth. +func DefaultOptions() Options { + return Options{Wait: WaitForever} +} diff --git a/internal/say/say.go b/internal/say/say.go new file mode 100644 index 0000000..7d39795 --- /dev/null +++ b/internal/say/say.go @@ -0,0 +1,78 @@ +package say + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "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/tts" +) + +// Run speaks one `cans say` and returns the process exit code. +// stdout carries data only: ttfa_ms=, a wav path, or a JSON record. +func Run(ctx context.Context, o Options, stdin io.Reader, stdout, stderr io.Writer) int { + // Usage errors must not fetch the mouth. + text, code := resolveText(o, stdin, stderr) + if code != ExitOK { + return code + } + if err := doctor.Prepare(ctx, stderr); err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + return runOnce(ctx, o, text, stdout, stderr) +} + +// runOnce speaks one utterance: to o.Out when named, else a temp wav that is +// played then deleted. +func runOnce(ctx context.Context, o Options, text string, stdout, stderr io.Writer) int { + cur, err := keep.Load() + if err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + r, err := tts.SayTo(ctx, text, cur, o.Out) + if err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + if err := emit(stdout, o, r); err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + if o.Out == "" { + playErr := play.File(r.Wav) + tts.RemoveTemp(r.Wav) + if playErr != nil { + fmt.Fprintln(stderr, playErr) + return ExitFail + } + return ExitOK + } + if o.Play { + if err := play.File(r.Wav); err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + } + return ExitOK +} + +// emit writes the one record for an utterance: a JSON line, the wav path, or +// the v1 ttfa_ms line. stdout carries nothing else. +func emit(stdout io.Writer, o Options, r tts.Result) error { + switch { + case o.JSON: + return json.NewEncoder(stdout).Encode(r) + case o.Out != "": + _, err := fmt.Fprintln(stdout, r.Wav) + return err + default: + _, err := fmt.Fprintf(stdout, "ttfa_ms=%d\n", r.TTFAMs) + return err + } +} diff --git a/internal/say/say_test.go b/internal/say/say_test.go new file mode 100644 index 0000000..9d8a7a6 --- /dev/null +++ b/internal/say/say_test.go @@ -0,0 +1,245 @@ +package say + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/veronica-agent/cans/internal/audio" + "github.com/veronica-agent/cans/internal/tts" +) + +func TestRunMissingText(t *testing.T) { + o := DefaultOptions() + o.StdinTTY = true + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, failingReader{t}, &out, &errBuf) + if code != ExitUsage { + t.Fatalf("code %d, want %d", code, ExitUsage) + } + if errBuf.String() != "say: missing text\n" { + t.Fatalf("stderr %q", errBuf.String()) + } + if out.Len() != 0 { + t.Fatalf("stdout %q", out.String()) + } +} + +func TestRunOutUnderAFileFails(t *testing.T) { + fake := sayBinEnv(t) + blocker := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + o := DefaultOptions() + o.Text = "Put the cans on." + o.Out = filepath.Join(blocker, "take.wav") + var out, errBuf bytes.Buffer + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitFail { + t.Fatalf("code %d, want %d", code, ExitFail) + } + if errBuf.Len() == 0 { + t.Fatal("expected an error on stderr") + } + if out.Len() != 0 { + t.Fatalf("stdout %q", out.String()) + } + if _, err := os.Stat(fake.argv); err == nil { + t.Fatal("say bin ran before mkdir failed") + } +} + +func TestRunOneShotPrintsTTFA(t *testing.T) { + sayBinEnv(t) + o := DefaultOptions() + o.Text = "Put the cans on." + var out, errBuf bytes.Buffer + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if out.String() != "ttfa_ms=12\n" { + t.Fatalf("stdout %q", out.String()) + } + if errBuf.Len() != 0 { + t.Fatalf("stderr %q", errBuf.String()) + } +} + +func TestRunOutWritesAndKeeps(t *testing.T) { + fake := sayBinEnv(t) + o := DefaultOptions() + o.Text = "Put the cans on." + o.Out = filepath.Join(t.TempDir(), "out", "take.wav") + var out, errBuf bytes.Buffer + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if out.String() != o.Out+"\n" { + t.Fatalf("stdout %q, want %q", out.String(), o.Out+"\n") + } + if err := audio.HeaderOK(o.Out); err != nil { + t.Fatalf("written wav: %v", err) + } + if _, err := os.Stat(fake.spoken); err != nil { + t.Fatalf("say bin wav should survive: %v", err) + } +} + +func TestRunOnceFakeWorkerWritesOut(t *testing.T) { + bin := buildFakeWorker(t) + root := t.TempDir() + t.Setenv("CANS_HOME", t.TempDir()) + 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()) + writeRef(t, root) + o := DefaultOptions() + o.Out = filepath.Join(t.TempDir(), "out", "take.wav") + var out, errBuf bytes.Buffer + if code := runOnce(context.Background(), o, "Put the cans on.", &out, &errBuf); code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if out.String() != o.Out+"\n" { + t.Fatalf("stdout %q", out.String()) + } + if err := audio.HeaderOK(o.Out); err != nil { + t.Fatalf("written wav: %v", err) + } +} + +// failingReader fails the test if the say flow reads stdin it should not. +type failingReader struct{ t *testing.T } + +func (r failingReader) Read([]byte) (int, error) { + r.t.Fatal("stdin read when it should not be") + return 0, nil +} + +func writeRef(t *testing.T, root string) string { + t.Helper() + 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) + } + return ref +} + +// fakeSay is the CANS_SAY_BIN seam: the wav the script reports, and the file +// it writes its argv to. +type fakeSay struct { + spoken string + argv string +} + +// sayBinEnv points CANS_SAY_BIN at a script that records its argv and reports +// a fixed wav. +func sayBinEnv(t *testing.T) fakeSay { + t.Helper() + root := t.TempDir() + t.Setenv("CANS_HOME", t.TempDir()) + t.Setenv("CANS_ROOT", root) + t.Setenv("CANS_NOPLAY", "1") + writeRef(t, root) + dir := t.TempDir() + f := fakeSay{spoken: filepath.Join(dir, "spoken.wav"), argv: filepath.Join(dir, "argv.txt")} + if err := os.WriteFile(f.spoken, audio.Minimal(), 0o644); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "fake-say") + body := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + f.argv + + "\necho '{\"wav\":\"" + f.spoken + "\",\"ttfa_ms\":12,\"sample_rate\":24000}'\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CANS_SAY_BIN", script) + return f +} + +func buildFakeWorker(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + src := filepath.Join(dir, "..", "tts", "testdata", "fakeworker") + bin := filepath.Join(t.TempDir(), "fake-worker") + cmd := exec.Command("go", "build", "-o", bin, src) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("build fake worker: %s\n%s", err, out) + } + return bin +} + +func TestRunStdinIsOneUtterance(t *testing.T) { + fake := sayBinEnv(t) + o := DefaultOptions() + o.Stdin = true + var out, errBuf bytes.Buffer + in := strings.NewReader("Put the cans on.\n") + if code := Run(context.Background(), o, in, &out, &errBuf); code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + argv, err := os.ReadFile(fake.argv) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(argv), "--text\nPut the cans on.\n") { + t.Fatalf("say bin argv %q", string(argv)) + } +} + +func TestRunJSONRecord(t *testing.T) { + sayBinEnv(t) + o := DefaultOptions() + o.Text = "Put the cans on." + o.JSON = true + var out, errBuf bytes.Buffer + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 1 { + t.Fatalf("stdout %q, want one record", out.String()) + } + var r tts.Result + if err := json.Unmarshal([]byte(lines[0]), &r); err != nil { + t.Fatalf("stdout %q: %v", lines[0], err) + } + if r.TTFAMs != 12 || r.SampleRate != 24000 { + t.Fatalf("record %+v", r) + } +} + +func TestRunJSONWithOutCarriesTheOutPath(t *testing.T) { + sayBinEnv(t) + o := DefaultOptions() + o.Text = "Put the cans on." + o.JSON = true + o.Out = filepath.Join(t.TempDir(), "out", "take.wav") + var out, errBuf bytes.Buffer + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + var r tts.Result + if err := json.Unmarshal(out.Bytes(), &r); err != nil { + t.Fatalf("stdout %q: %v", out.String(), err) + } + if r.Wav != o.Out { + t.Fatalf("record wav %q, want %q", r.Wav, o.Out) + } + if err := audio.HeaderOK(o.Out); err != nil { + t.Fatalf("written wav: %v", err) + } +} diff --git a/internal/tts/session.go b/internal/tts/session.go index 87ab8be..8588428 100644 --- a/internal/tts/session.go +++ b/internal/tts/session.go @@ -35,8 +35,14 @@ func Open(ctx context.Context) (*Session, error) { return &Session{c: c}, nil } -// Say clones text using the frozen throat. +// Say clones text using the frozen throat into a temp wav. func (s *Session) Say(ctx context.Context, text string, cur keep.Current) (Result, error) { + return s.SayTo(ctx, text, cur, "") +} + +// SayTo clones text using the frozen throat. out == "" writes a temp wav; +// otherwise the wav is written at out, parent directories included. +func (s *Session) SayTo(ctx context.Context, text string, cur keep.Current, out string) (Result, error) { if s == nil || s.c == nil { return Result{}, fmt.Errorf("say: no worker") } @@ -47,11 +53,15 @@ func (s *Session) Say(ctx context.Context, text string, cur keep.Current) (Resul if strings.TrimSpace(cur.Wav) == "" { return Result{}, fmt.Errorf("say: empty ref wav") } + if out == "" { + out = filepath.Join(os.TempDir(), fmt.Sprintf("cans-%d.wav", time.Now().UnixNano())) + } else if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return Result{}, fmt.Errorf("say: %w", err) + } pcm, err := s.c.synthesize(ctx, "cans", text, cur.Wav) if err != nil { return Result{}, fmt.Errorf("say: %w", err) } - out := filepath.Join(os.TempDir(), fmt.Sprintf("cans-%d.wav", time.Now().UnixNano())) rate := pcm.sampleRate if rate <= 0 { rate = 24000 diff --git a/internal/tts/synth.go b/internal/tts/synth.go index 3c1d144..27a287f 100644 --- a/internal/tts/synth.go +++ b/internal/tts/synth.go @@ -33,20 +33,26 @@ func Say(ctx context.Context, text string) (Result, error) { return SayWith(ctx, text, cur) } -// SayWith clones text using a frozen throat. +// SayWith clones text using a frozen throat into a temp wav. func SayWith(ctx context.Context, text string, cur keep.Current) (Result, error) { + return SayTo(ctx, text, cur, "") +} + +// SayTo clones text using a frozen throat. out == "" writes a temp wav; +// otherwise the wav is written at out and is the caller's to delete. +func SayTo(ctx context.Context, text string, cur keep.Current, out string) (Result, error) { if err := ctx.Err(); err != nil { return Result{}, err } if os.Getenv("CANS_SAY_BIN") != "" { - return sayBin(text, cur) + return sayBinTo(text, cur, out) } sess, err := Open(ctx) if err != nil { return Result{}, err } defer sess.Close() - return sess.Say(ctx, text, cur) + return sess.SayTo(ctx, text, cur, out) } // RemoveTemp deletes a synth wav if it lives under the process temp dir. diff --git a/internal/tts/synth_bin.go b/internal/tts/synth_bin.go index f968323..78725db 100644 --- a/internal/tts/synth_bin.go +++ b/internal/tts/synth_bin.go @@ -4,8 +4,10 @@ import ( "bytes" "encoding/json" "fmt" + "io" "os" "os/exec" + "path/filepath" "strings" "github.com/veronica-agent/cans/internal/audio" @@ -60,3 +62,42 @@ func lastJSONLine(raw []byte) []byte { } return last } + +// sayBinTo runs the CANS_SAY_BIN seam and, when out is named, copies the +// script's wav there so the caller gets the path it asked for. +func sayBinTo(text string, cur keep.Current, out string) (Result, error) { + if out != "" { + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return Result{}, fmt.Errorf("say: %w", err) + } + } + r, err := sayBin(text, cur) + if err != nil || out == "" { + return r, err + } + if err := copyFile(r.Wav, out); err != nil { + return Result{}, fmt.Errorf("say: %w", err) + } + r.Wav = out + return r, nil +} + +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + f, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(f, in); err != nil { + f.Close() + return err + } + return f.Close() +} From 47d39d9ff5a02041ccef51805844e6ad4a69f5f7 Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:55:34 -0600 Subject: [PATCH 2/9] [veronica:ea389d71-FE-CV0001] feat: one mouth at a time via flock internal/mouth takes an exclusive flock on CANS_HOME/mouth.lock before the worker starts and releases it after Close. cans say --nowait exits 75 when the mouth is held; --wait bounds the block and prints waiting for the mouth. The booth holds the lock for the whole session. The lock file is never deleted. --- cmd/cans/main.go | 2 + cmd/cans/main_test.go | 58 +++++++++++++ internal/booth/booth.go | 5 +- internal/mouth/lock.go | 115 ++++++++++++++++++++++++++ internal/mouth/lock_test.go | 152 +++++++++++++++++++++++++++++++++++ internal/say/say.go | 23 +++++- internal/say/say_test.go | 94 ++++++++++++++++++++++ internal/tts/session.go | 62 ++++++++++++-- internal/tts/session_test.go | 76 ++++++++++++++++++ internal/tts/synth.go | 7 +- 10 files changed, 582 insertions(+), 12 deletions(-) create mode 100644 internal/mouth/lock.go create mode 100644 internal/mouth/lock_test.go create mode 100644 internal/tts/session_test.go diff --git a/cmd/cans/main.go b/cmd/cans/main.go index 56b2565..c51398c 100644 --- a/cmd/cans/main.go +++ b/cmd/cans/main.go @@ -29,6 +29,8 @@ Apple Silicon. The mouth is a native Qwen3-TTS worker cloning a wav. cans keep -text WORDS freeze this throat (both orders work) cans doctor set up the mouth, check the machine cans version print version + +exit 75 when another cans holds the mouth and --nowait was set ` func main() { diff --git a/cmd/cans/main_test.go b/cmd/cans/main_test.go index 7ef48b0..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) { @@ -140,3 +143,58 @@ func TestSayUnknownFlag(t *testing.T) { 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/internal/booth/booth.go b/internal/booth/booth.go index bcbf436..52328b0 100644 --- a/internal/booth/booth.go +++ b/internal/booth/booth.go @@ -148,7 +148,10 @@ func Run(ctx context.Context, quote string, throat keep.Current) error { lipgloss.SetHasDarkBackground(true) var sess *tts.Session if os.Getenv("CANS_SAY_BIN") == "" { - s, err := tts.Open(ctx) + s, err := tts.OpenWith(ctx, tts.Options{ + Wait: -1, + OnWait: func() { fmt.Fprintln(os.Stderr, "waiting for the mouth…") }, + }) if err != nil { return err } diff --git a/internal/mouth/lock.go b/internal/mouth/lock.go new file mode 100644 index 0000000..3c0e110 --- /dev/null +++ b/internal/mouth/lock.go @@ -0,0 +1,115 @@ +// Package mouth serializes the native worker: one flock, one resident mouth. +package mouth + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/veronica-agent/cans/internal/ship" +) + +// ErrBusy is a held mouth when the caller asked not to wait, or the wait ran out. +var ErrBusy = errors.New("mouth busy") + +const pollEvery = 100 * time.Millisecond + +// Lock is an exclusive flock on mouth.lock. The kernel drops it if we die. +type Lock struct{ f *os.File } + +// Path is CANS_HOME/mouth.lock. +func Path() string { + return filepath.Join(ship.Home(), "mouth.lock") +} + +// Acquire takes an exclusive flock on path. +// wait < 0 waits forever; wait == 0 tries once; wait > 0 gives up after wait. +// onWait fires once only when the caller is about to block. +func Acquire(ctx context.Context, path string, wait time.Duration, onWait func()) (*Lock, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("mouth: lock %s: %w", path, err) + } + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return nil, fmt.Errorf("mouth: lock %s: %w", path, err) + } + l := &Lock{f: f} + if err := tryUntil(ctx, path, l, wait, onWait); err != nil { + _ = f.Close() + return nil, err + } + return l, nil +} + +// Release drops the flock and closes the fd. The lock file is never deleted. +func (l *Lock) Release() error { + if l == nil || l.f == nil { + return nil + } + flockErr := syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN) + closeErr := l.f.Close() + l.f = nil + if flockErr != nil { + return fmt.Errorf("mouth: unlock: %w", flockErr) + } + return closeErr +} + +func tryUntil(ctx context.Context, path string, l *Lock, wait time.Duration, onWait func()) error { + var deadline time.Time + if wait > 0 { + deadline = time.Now().Add(wait) + } + called := false + for { + if err := ctx.Err(); err != nil { + return err + } + err := syscall.Flock(int(l.f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return nil + } + if err != syscall.EWOULDBLOCK && err != syscall.EAGAIN { + return fmt.Errorf("mouth: lock %s: %w", path, err) + } + if wait == 0 { + return ErrBusy + } + if wait > 0 && !time.Now().Before(deadline) { + return ErrBusy + } + if !called && onWait != nil { + onWait() + called = true + } + d := pollEvery + if wait > 0 { + if rem := time.Until(deadline); rem <= 0 { + return ErrBusy + } else if rem < d { + d = rem + } + } + if err := waitPoll(ctx, d); err != nil { + return err + } + } +} + +func waitPoll(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} diff --git a/internal/mouth/lock_test.go b/internal/mouth/lock_test.go new file mode 100644 index 0000000..3b7419e --- /dev/null +++ b/internal/mouth/lock_test.go @@ -0,0 +1,152 @@ +package mouth + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" +) + +func TestAcquireWaitZeroBusy(t *testing.T) { + path := filepath.Join(t.TempDir(), "mouth.lock") + held := mustAcquire(t, path, -1) + defer held.Release() + _, err := Acquire(context.Background(), path, 0, nil) + if !errors.Is(err, ErrBusy) { + t.Fatalf("err %v, want ErrBusy", err) + } +} + +func TestAcquireBoundedWaitBusy(t *testing.T) { + path := filepath.Join(t.TempDir(), "mouth.lock") + held := mustAcquire(t, path, -1) + defer held.Release() + start := time.Now() + _, err := Acquire(context.Background(), path, 150*time.Millisecond, nil) + elapsed := time.Since(start) + if !errors.Is(err, ErrBusy) { + t.Fatalf("err %v, want ErrBusy", err) + } + if elapsed < 150*time.Millisecond { + t.Fatalf("elapsed %v, want >= 150ms", elapsed) + } +} + +func TestAcquireCtxCancel(t *testing.T) { + path := filepath.Join(t.TempDir(), "mouth.lock") + held := mustAcquire(t, path, -1) + defer held.Release() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err := Acquire(ctx, path, -1, nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err %v, want ctx deadline", err) + } +} + +func TestOnWaitFiresOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "mouth.lock") + held := mustAcquire(t, path, -1) + defer held.Release() + var n atomic.Int32 + _, err := Acquire(context.Background(), path, 350*time.Millisecond, func() { n.Add(1) }) + if !errors.Is(err, ErrBusy) { + t.Fatalf("err %v, want ErrBusy", err) + } + if n.Load() != 1 { + t.Fatalf("onWait %d, want 1", n.Load()) + } +} + +func TestReleaseThenReacquireKeepsFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "mouth.lock") + held := mustAcquire(t, path, 0) + if err := held.Release(); err != nil { + t.Fatal(err) + } + again := mustAcquire(t, path, 0) + defer again.Release() + if _, err := os.Stat(path); err != nil { + t.Fatalf("lock file missing: %v", err) + } +} + +func TestReleaseNil(t *testing.T) { + var l *Lock + if err := l.Release(); err != nil { + t.Fatal(err) + } +} + +func TestHelperHoldLock(t *testing.T) { + if os.Getenv("MOUTH_HELPER") != "1" { + return + } + l, err := Acquire(context.Background(), os.Getenv("MOUTH_LOCK"), -1, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "helper acquire: %v\n", err) + os.Exit(1) + } + fmt.Println("held") + _ = os.Stdout.Sync() + defer runtime.KeepAlive(l) + select {} +} + +func TestKillDropsLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "mouth.lock") + cmd := exec.Command(os.Args[0], "-test.run=^TestHelperHoldLock$") + cmd.Env = append(os.Environ(), "MOUTH_HELPER=1", "MOUTH_LOCK="+path) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + line, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil || line != "held\n" { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("helper hello %q err %v", line, err) + } + _, err = Acquire(context.Background(), path, 0, nil) + if !errors.Is(err, ErrBusy) { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatalf("while held: %v", err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = cmd.Wait() + l, err := Acquire(context.Background(), path, 0, nil) + if err != nil { + t.Fatalf("after kill: %v", err) + } + defer l.Release() +} + +func TestPathUsesHome(t *testing.T) { + home := t.TempDir() + t.Setenv("CANS_HOME", home) + if Path() != filepath.Join(home, "mouth.lock") { + t.Fatalf("Path() %q", Path()) + } +} + +func mustAcquire(t *testing.T, path string, wait time.Duration) *Lock { + t.Helper() + l, err := Acquire(context.Background(), path, wait, nil) + if err != nil { + t.Fatal(err) + } + return l +} diff --git a/internal/say/say.go b/internal/say/say.go index 7d39795..45586dd 100644 --- a/internal/say/say.go +++ b/internal/say/say.go @@ -3,11 +3,13 @@ package say import ( "context" "encoding/json" + "errors" "fmt" "io" "github.com/veronica-agent/cans/internal/doctor" "github.com/veronica-agent/cans/internal/keep" + "github.com/veronica-agent/cans/internal/mouth" "github.com/veronica-agent/cans/internal/play" "github.com/veronica-agent/cans/internal/tts" ) @@ -35,10 +37,9 @@ func runOnce(ctx context.Context, o Options, text string, stdout, stderr io.Writ fmt.Fprintln(stderr, err) return ExitFail } - r, err := tts.SayTo(ctx, text, cur, o.Out) + r, err := tts.SayToWith(ctx, text, cur, o.Out, lockOpts(o, stderr)) if err != nil { - fmt.Fprintln(stderr, err) - return ExitFail + return exitFor(err, stderr) } if err := emit(stdout, o, r); err != nil { fmt.Fprintln(stderr, err) @@ -76,3 +77,19 @@ func emit(stdout io.Writer, o Options, r tts.Result) error { return err } } + +func lockOpts(o Options, stderr io.Writer) tts.Options { + return tts.Options{ + Wait: o.Wait, + OnWait: func() { fmt.Fprintln(stderr, "waiting for the mouth…") }, + } +} + +func exitFor(err error, stderr io.Writer) int { + if errors.Is(err, mouth.ErrBusy) { + fmt.Fprintln(stderr, "say: mouth busy") + return ExitBusy + } + fmt.Fprintln(stderr, err) + return ExitFail +} diff --git a/internal/say/say_test.go b/internal/say/say_test.go index 9d8a7a6..6431390 100644 --- a/internal/say/say_test.go +++ b/internal/say/say_test.go @@ -10,7 +10,10 @@ import ( "strings" "testing" + "time" + "github.com/veronica-agent/cans/internal/audio" + "github.com/veronica-agent/cans/internal/mouth" "github.com/veronica-agent/cans/internal/tts" ) @@ -222,6 +225,97 @@ func TestRunJSONRecord(t *testing.T) { } } +func TestRunNowaitBusy(t *testing.T) { + fakeWorkerEnv(t) + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatal(err) + } + defer lk.Release() + o := DefaultOptions() + o.Text = "Put the cans on." + o.Wait = 0 + var out, errBuf bytes.Buffer + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitBusy { + t.Fatalf("code %d stderr %q, want %d", code, errBuf.String(), ExitBusy) + } + if !strings.Contains(errBuf.String(), "mouth busy") { + t.Fatalf("stderr %q", errBuf.String()) + } + if strings.Contains(errBuf.String(), "waiting for the mouth") { + t.Fatalf("nowait waited: %q", errBuf.String()) + } + if out.Len() != 0 { + t.Fatalf("stdout %q", out.String()) + } +} + +func TestRunWaitBusyPrintsWaiting(t *testing.T) { + fakeWorkerEnv(t) + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatal(err) + } + defer lk.Release() + o := DefaultOptions() + o.Text = "Put the cans on." + o.Wait = 200 * time.Millisecond + var out, errBuf bytes.Buffer + start := time.Now() + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitBusy { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if elapsed := time.Since(start); elapsed < 200*time.Millisecond { + t.Fatalf("elapsed %v", elapsed) + } + if n := strings.Count(errBuf.String(), "waiting for the mouth…"); n != 1 { + t.Fatalf("waiting lines %d in %q", n, errBuf.String()) + } + if out.Len() != 0 { + t.Fatalf("stdout %q", out.String()) + } +} + +func TestRunAfterReleaseSucceeds(t *testing.T) { + fakeWorkerEnv(t) + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatal(err) + } + if err := lk.Release(); err != nil { + t.Fatal(err) + } + o := DefaultOptions() + o.Text = "Put the cans on." + o.Wait = 0 + var out, errBuf bytes.Buffer + if code := Run(context.Background(), o, nil, &out, &errBuf); code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } +} + +func fakeWorkerEnv(t *testing.T) { + t.Helper() + bin := buildFakeWorker(t) + 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") + writeRef(t, root) + if err := os.WriteFile(filepath.Join(root, "character.toml"), []byte("name = \"veronica\"\n"), 0o644); err != nil { + t.Fatal(err) + } + dylib := filepath.Join(filepath.Dir(bin), "libqwen3tts.0.dylib") + if err := os.WriteFile(dylib, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } +} + func TestRunJSONWithOutCarriesTheOutPath(t *testing.T) { sayBinEnv(t) o := DefaultOptions() diff --git a/internal/tts/session.go b/internal/tts/session.go index 8588428..8f17a71 100644 --- a/internal/tts/session.go +++ b/internal/tts/session.go @@ -10,19 +10,55 @@ import ( "github.com/veronica-agent/cans/internal/audio" "github.com/veronica-agent/cans/internal/keep" + "github.com/veronica-agent/cans/internal/mouth" "github.com/veronica-agent/cans/internal/ship" ) -// Session is a warm worker for one booth (or one say). +// Session is a warm worker for one booth (or one say). The mouth lock lives +// as long as the session does. type Session struct { - c *Client + c *Client + lock *mouth.Lock } -// Open starts the native worker. +// Options bound how long Open waits for the mouth. +type Options struct { + Wait time.Duration + OnWait func() +} + +// DefaultOptions wait forever and print a stderr line while blocked. +func DefaultOptions() Options { + return Options{Wait: -1, OnWait: defaultOnWait} +} + +func defaultOnWait() { + fmt.Fprintln(os.Stderr, "waiting for the mouth…") +} + +// Open starts the native worker, waiting forever for the mouth if needed. func Open(ctx context.Context) (*Session, error) { + return OpenWith(ctx, DefaultOptions()) +} + +// OpenWith acquires the mouth lock, then starts the worker. +func OpenWith(ctx context.Context, o Options) (*Session, error) { if err := ctx.Err(); err != nil { return nil, err } + lk, err := mouth.Acquire(ctx, mouth.Path(), o.Wait, o.OnWait) + if err != nil { + return nil, err + } + sess, err := startSession(ctx, lk) + if err != nil { + _ = lk.Release() + return nil, err + } + return sess, nil +} + +func startSession(ctx context.Context, lock *mouth.Lock) (*Session, error) { bin := ship.WorkerBin() models := ship.WorkerModels() if _, err := os.Stat(bin); err != nil { @@ -32,7 +68,7 @@ func Open(ctx context.Context) (*Session, error) { if err != nil { return nil, fmt.Errorf("say: worker: %w", err) } - return &Session{c: c}, nil + return &Session{c: c, lock: lock}, nil } // Say clones text using the frozen throat into a temp wav. @@ -74,10 +110,22 @@ func (s *Session) SayTo(ctx context.Context, text string, cur keep.Current, out return Result{Wav: out, TTFAMs: ms, SampleRate: rate}, nil } -// Close shuts the worker down. +// Close shuts the worker down, then releases the mouth lock. func (s *Session) Close() error { - if s == nil || s.c == nil { + if s == nil { return nil } - return s.c.Close() + var err error + if s.c != nil { + err = s.c.Close() + s.c = nil + } + if s.lock != nil { + rerr := s.lock.Release() + s.lock = nil + if err == nil { + err = rerr + } + } + return err } diff --git a/internal/tts/session_test.go b/internal/tts/session_test.go new file mode 100644 index 0000000..ec5fd70 --- /dev/null +++ b/internal/tts/session_test.go @@ -0,0 +1,76 @@ +package tts + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/veronica-agent/cans/internal/mouth" +) + +func TestOpenWithLockBeforeWorker(t *testing.T) { + bin := buildFakeWorker(t) + home := t.TempDir() + t.Setenv("CANS_HOME", home) + t.Setenv("CANS_WORKER_BIN", bin) + t.Setenv("CANS_WORKER_MODELS", t.TempDir()) + t.Setenv("CANS_NOPLAY", "1") + t.Setenv("CANS_SAY_BIN", "") + + a, err := OpenWith(context.Background(), Options{Wait: -1}) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, "mouth.lock")); err != nil { + t.Fatalf("lock file: %v", err) + } + + t.Setenv("CANS_WORKER_BIN", filepath.Join(t.TempDir(), "missing")) + _, err = OpenWith(context.Background(), Options{Wait: 0}) + if !errors.Is(err, mouth.ErrBusy) { + t.Fatalf("want ErrBusy, got %v", err) + } + + if err := a.Close(); err != nil { + t.Fatal(err) + } + t.Setenv("CANS_WORKER_BIN", bin) + b, err := OpenWith(context.Background(), Options{Wait: 0}) + if err != nil { + t.Fatal(err) + } + if err := b.Close(); err != nil { + t.Fatal(err) + } +} + +func TestOpenWithOnWaitOnce(t *testing.T) { + bin := buildFakeWorker(t) + t.Setenv("CANS_HOME", t.TempDir()) + t.Setenv("CANS_WORKER_BIN", bin) + t.Setenv("CANS_WORKER_MODELS", t.TempDir()) + t.Setenv("CANS_NOPLAY", "1") + t.Setenv("CANS_SAY_BIN", "") + + a, err := OpenWith(context.Background(), Options{Wait: -1}) + if err != nil { + t.Fatal(err) + } + defer a.Close() + + var n atomic.Int32 + _, err = OpenWith(context.Background(), Options{ + Wait: 300 * time.Millisecond, + OnWait: func() { n.Add(1) }, + }) + if !errors.Is(err, mouth.ErrBusy) { + t.Fatalf("want ErrBusy, got %v", err) + } + if n.Load() != 1 { + t.Fatalf("onWait %d, want 1", n.Load()) + } +} diff --git a/internal/tts/synth.go b/internal/tts/synth.go index 27a287f..a9d28bb 100644 --- a/internal/tts/synth.go +++ b/internal/tts/synth.go @@ -41,13 +41,18 @@ func SayWith(ctx context.Context, text string, cur keep.Current) (Result, error) // SayTo clones text using a frozen throat. out == "" writes a temp wav; // otherwise the wav is written at out and is the caller's to delete. func SayTo(ctx context.Context, text string, cur keep.Current, out string) (Result, error) { + return SayToWith(ctx, text, cur, out, DefaultOptions()) +} + +// SayToWith is SayTo with lock wait options. CANS_SAY_BIN takes no lock. +func SayToWith(ctx context.Context, text string, cur keep.Current, out string, o Options) (Result, error) { if err := ctx.Err(); err != nil { return Result{}, err } if os.Getenv("CANS_SAY_BIN") != "" { return sayBinTo(text, cur, out) } - sess, err := Open(ctx) + sess, err := OpenWith(ctx, o) if err != nil { return Result{}, err } From c736f46c9f95fc7f660e0972e7cc0153b3313a0f Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:51:09 -0600 Subject: [PATCH 3/9] [veronica:ea389d71-WI-a2e393] fix: compile the TTY check on linux CI TIOCGETA is darwin-only. Linux CI uses TCGETS so `go test ./...` builds on ubuntu. /dev/null is still not a TTY. --- cmd/cans/tty.go | 12 +----------- cmd/cans/tty_darwin.go | 14 ++++++++++++++ cmd/cans/tty_linux.go | 14 ++++++++++++++ cmd/cans/tty_other.go | 5 +++++ 4 files changed, 34 insertions(+), 11 deletions(-) create mode 100644 cmd/cans/tty_darwin.go create mode 100644 cmd/cans/tty_linux.go create mode 100644 cmd/cans/tty_other.go diff --git a/cmd/cans/tty.go b/cmd/cans/tty.go index 64f7a74..520ce6e 100644 --- a/cmd/cans/tty.go +++ b/cmd/cans/tty.go @@ -1,10 +1,6 @@ package main -import ( - "os" - "syscall" - "unsafe" -) +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 @@ -16,9 +12,3 @@ func stdinIsTTY() bool { } return fdIsTTY(f.Fd()) } - -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_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 } From c443de950929ed5e37c35a2048d51ffc5216640c Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:39:42 -0600 Subject: [PATCH 4/9] [veronica:ea389d71-FE-CV0001] feat: cans say --stream, one session per document What changed: - --stream speaks one utterance per stdin line over a single Session: one GGUF load for the whole document, backpressure inherent (next synthesize only after the previous final), no buffer, no goroutine - -o 'out/%03d.wav' is a template in stream mode, validated before the mouth is touched; one-shot -o stays a literal path - blank lines are skipped without consuming an index; a failed line is reported on stderr (and as {line,error} under --json), the stream continues, exit 1 at EOF if any line failed - --json records carry the 1-based stdin line; every record is flushed - Ctrl-C: the loop stops, a mid-utterance worker is SIGTERMed (SIGKILL after 2s), finished wavs stay, the lock is released, exit 130 with 'interrupted after line N'; a second Ctrl-C ends the process at once - --stream together with argv text is a usage error - fake worker gains a 'fail' and a 'block' branch so failure, cancel and one-worker tests run without the real mouth Why: a script walking a document paid a model load per line and nothing stopped a loop from overloading the machine. The lock made it safe; this makes it fast. Cancel terminates rather than waits (D014) because a line can run 17-30s when the mouth misses end-of-speech. --- cmd/cans/main.go | 30 +- cmd/cans/say_args.go | 3 + cmd/cans/say_args_test.go | 1 + internal/say/say.go | 45 ++- internal/say/say_test.go | 79 +++++ internal/say/stream.go | 167 +++++++++++ internal/say/stream_test.go | 352 +++++++++++++++++++++++ internal/say/template.go | 64 +++++ internal/say/template_test.go | 65 +++++ internal/tts/session.go | 22 +- internal/tts/testdata/fakeworker/main.go | 19 ++ internal/tts/worker.go | 5 + 12 files changed, 830 insertions(+), 22 deletions(-) create mode 100644 internal/say/stream.go create mode 100644 internal/say/stream_test.go create mode 100644 internal/say/template.go create mode 100644 internal/say/template_test.go diff --git a/cmd/cans/main.go b/cmd/cans/main.go index c51398c..b988bb7 100644 --- a/cmd/cans/main.go +++ b/cmd/cans/main.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "os" + "os/signal" "strings" + "syscall" "github.com/veronica-agent/cans/internal/booth" "github.com/veronica-agent/cans/internal/doctor" @@ -43,13 +45,7 @@ func run(args []string) int { } switch args[0] { case "say": - o, err := parseSay(args[1:]) - if err != nil { - fmt.Fprintln(stderr, err) - return 2 - } - o.StdinTTY = stdinIsTTY() - return say.Run(context.Background(), o, stdin, stdout, stderr) + return runSay(args[1:]) case "doctor": if err := doctor.Run(context.Background(), stdout, stderr); err != nil { return 1 @@ -80,6 +76,26 @@ 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 { diff --git a/cmd/cans/say_args.go b/cmd/cans/say_args.go index 800f067..d0ee0d2 100644 --- a/cmd/cans/say_args.go +++ b/cmd/cans/say_args.go @@ -94,5 +94,8 @@ func validateSay(o say.Options, sawWait, sawNoWait bool) error { 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 index 5ed3240..1c254b3 100644 --- a/cmd/cans/say_args_test.go +++ b/cmd/cans/say_args_test.go @@ -24,6 +24,7 @@ func TestParseSayErrors(t *testing.T) { {"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"}, } diff --git a/internal/say/say.go b/internal/say/say.go index 45586dd..419128b 100644 --- a/internal/say/say.go +++ b/internal/say/say.go @@ -17,6 +17,13 @@ import ( // Run speaks one `cans say` and returns the process exit code. // stdout carries data only: ttfa_ms=, a wav path, or a JSON record. func Run(ctx context.Context, o Options, stdin io.Reader, stdout, stderr io.Writer) int { + if err := checkOut(o.Out, o.Stream); err != nil { + fmt.Fprintln(stderr, err) + return ExitUsage + } + if o.Stream { + return runStream(ctx, o, stdin, stdout, stderr) + } // Usage errors must not fetch the mouth. text, code := resolveText(o, stdin, stderr) if code != ExitOK { @@ -37,7 +44,7 @@ func runOnce(ctx context.Context, o Options, text string, stdout, stderr io.Writ fmt.Fprintln(stderr, err) return ExitFail } - r, err := tts.SayToWith(ctx, text, cur, o.Out, lockOpts(o, stderr)) + r, err := tts.SayToWith(ctx, text, cur, outPath(o.Out, 0), lockOpts(o, stderr)) if err != nil { return exitFor(err, stderr) } @@ -45,22 +52,26 @@ func runOnce(ctx context.Context, o Options, text string, stdout, stderr io.Writ fmt.Fprintln(stderr, err) return ExitFail } + if err := playTail(o, r.Wav); err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + return ExitOK +} + +// playTail is the tail every spoken utterance shares. With no -o the wav is a +// temp file: it is played, then removed whether or not playing worked. With -o +// the wav is the caller's and is only played on --play. +func playTail(o Options, wav string) error { if o.Out == "" { - playErr := play.File(r.Wav) - tts.RemoveTemp(r.Wav) - if playErr != nil { - fmt.Fprintln(stderr, playErr) - return ExitFail - } - return ExitOK + err := play.File(wav) + tts.RemoveTemp(wav) + return err } if o.Play { - if err := play.File(r.Wav); err != nil { - fmt.Fprintln(stderr, err) - return ExitFail - } + return play.File(wav) } - return ExitOK + return nil } // emit writes the one record for an utterance: a JSON line, the wav path, or @@ -90,6 +101,14 @@ func exitFor(err error, stderr io.Writer) int { fmt.Fprintln(stderr, "say: mouth busy") return ExitBusy } + if interrupted(err) { + fmt.Fprintln(stderr, "say: interrupted") + return ExitInterrupted + } fmt.Fprintln(stderr, err) return ExitFail } + +func interrupted(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} diff --git a/internal/say/say_test.go b/internal/say/say_test.go index 6431390..794c568 100644 --- a/internal/say/say_test.go +++ b/internal/say/say_test.go @@ -117,6 +117,19 @@ func TestRunOnceFakeWorkerWritesOut(t *testing.T) { } } +// waitFor polls cond until it holds, failing the test after five seconds. It is +// a bounded poll, not a sleep: the test never proceeds on hope. +func waitFor(t *testing.T, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal(msg) + } + time.Sleep(10 * time.Millisecond) + } +} + // failingReader fails the test if the say flow reads stdin it should not. type failingReader struct{ t *testing.T } @@ -276,6 +289,72 @@ func TestRunWaitBusyPrintsWaiting(t *testing.T) { } } +func TestRunInterruptedWaiting(t *testing.T) { + fakeWorkerEnv(t) + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatal(err) + } + defer lk.Release() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + o := DefaultOptions() + o.Text = "Put the cans on." + o.Wait = -1 + var out, errBuf bytes.Buffer + code := Run(ctx, o, nil, &out, &errBuf) + if code != ExitInterrupted { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if !strings.Contains(errBuf.String(), "say: interrupted") { + t.Fatalf("stderr %q", errBuf.String()) + } +} + +// TestRunOnceCancelledMidSynthesis is D014 for one-shot: the worker is held +// inside synthesis, so cancel lands mid-utterance rather than at the lock. +func TestRunOnceCancelledMidSynthesis(t *testing.T) { + fakeWorkerEnv(t) + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + marker := filepath.Join(tmp, "blocked") + t.Setenv("CANS_FAKE_BLOCK_FILE", marker) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + o := DefaultOptions() + var out, errBuf bytes.Buffer + + done := make(chan int, 1) + go func() { done <- runOnce(ctx, o, "block", &out, &errBuf) }() + waitFor(t, func() bool { + _, err := os.Stat(marker) + return err == nil + }, "worker never reached synthesis") + cancel() + code := <-done + if code != ExitInterrupted { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if !strings.Contains(errBuf.String(), "say: interrupted") { + t.Fatalf("stderr %q", errBuf.String()) + } + if out.Len() != 0 { + t.Fatalf("stdout %q", out.String()) + } + left, err := filepath.Glob(filepath.Join(tmp, "cans-*.wav")) + if err != nil { + t.Fatal(err) + } + if len(left) != 0 { + t.Fatalf("temp wavs left: %v", left) + } + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatalf("lock after cancel: %v", err) + } + lk.Release() +} + func TestRunAfterReleaseSucceeds(t *testing.T) { fakeWorkerEnv(t) lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) diff --git a/internal/say/stream.go b/internal/say/stream.go new file mode 100644 index 0000000..4ed2c6b --- /dev/null +++ b/internal/say/stream.go @@ -0,0 +1,167 @@ +package say + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/veronica-agent/cans/internal/doctor" + "github.com/veronica-agent/cans/internal/keep" + "github.com/veronica-agent/cans/internal/tts" +) + +var errLineFailed = errors.New("line failed") + +type okRecord struct { + Line int `json:"line"` + Wav string `json:"wav"` + TTFAMs int `json:"ttfa_ms"` + SampleRate int `json:"sample_rate"` +} + +type errRecord struct { + Line int `json:"line"` + Error string `json:"error"` +} + +// streamer is the fixed half of a stream: the worker, the throat and the +// writers, all of which outlive every line. +type streamer struct { + sess *tts.Session + cur keep.Current + o Options + stdout *bufio.Writer + stderr io.Writer +} + +func runStream(ctx context.Context, o Options, stdin io.Reader, stdout, stderr io.Writer) int { + if err := doctor.Prepare(ctx, stderr); err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + cur, err := keep.Load() + if err != nil { + fmt.Fprintln(stderr, err) + return ExitFail + } + sess, err := tts.OpenWith(ctx, lockOpts(o, stderr)) + if err != nil { + return exitFor(err, stderr) + } + defer sess.Close() + outw := bufio.NewWriter(stdout) + defer outw.Flush() + s := &streamer{sess: sess, cur: cur, o: o, stdout: outw, stderr: stderr} + return s.scan(ctx, stdin) +} + +// scan speaks stdin one line at a time. last is the last line fully processed, +// spoken or reported as failed; idx counts only the lines that were spoken, so +// an -o template stays dense (D005). +func (s *streamer) scan(ctx context.Context, stdin io.Reader) int { + sc := bufio.NewScanner(stdin) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + lineNo, idx, failed, last := 0, 0, 0, 0 + for { + if ctx.Err() != nil { + return s.interruptedAfter(last) + } + if !sc.Scan() { + break + } + if ctx.Err() != nil { + return s.interruptedAfter(last) + } + lineNo++ + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + err := s.speak(ctx, line, lineNo, idx+1) + if interrupted(err) { + return s.interruptedAfter(last) + } + last = lineNo + if err != nil { + failed++ + continue + } + idx++ + } + // Ctrl-C while blocked in Scan: the producer got the same signal and + // closed the pipe, so Scan returned false — still an interrupt, not EOF. + if ctx.Err() != nil { + return s.interruptedAfter(last) + } + if err := sc.Err(); err != nil { + fmt.Fprintf(s.stderr, "say: stdin: %v\n", err) + return ExitFail + } + if failed > 0 { + return ExitFail + } + return ExitOK +} + +// speak says one stdin line. It returns the cancellation error unwrapped so the +// caller can tell an interrupt from a line that failed. +func (s *streamer) speak(ctx context.Context, line string, lineNo, idx int) error { + r, err := s.sess.SayTo(ctx, line, s.cur, outPath(s.o.Out, idx)) + if err != nil { + if interrupted(err) { + return err + } + fmt.Fprintf(s.stderr, "line %d: %v\n", lineNo, err) + _ = s.emit(errRecord{Line: lineNo, Error: err.Error()}) + return errLineFailed + } + if err := s.emit(okRecord{Line: lineNo, Wav: r.Wav, TTFAMs: r.TTFAMs, SampleRate: r.SampleRate}); err != nil { + fmt.Fprintln(s.stderr, err) + return errLineFailed + } + if err := playTail(s.o, r.Wav); err != nil { + fmt.Fprintln(s.stderr, err) + return errLineFailed + } + return nil +} + +// interruptedAfter reports the Ctrl-C (D014): the in-flight line is dropped, so +// the last line named is the last one fully processed. +func (s *streamer) interruptedAfter(line int) int { + if line == 0 { + fmt.Fprintln(s.stderr, "interrupted before the first line") + } else { + fmt.Fprintf(s.stderr, "interrupted after line %d\n", line) + } + return ExitInterrupted +} + +// emit writes the one record for a line and flushes, so a reader downstream +// sees it before the next line is spoken. +func (s *streamer) emit(rec any) error { + var err error + switch r := rec.(type) { + case errRecord: + if s.o.JSON { + err = json.NewEncoder(s.stdout).Encode(r) + } + case okRecord: + switch { + case s.o.JSON: + err = json.NewEncoder(s.stdout).Encode(r) + case s.o.Out != "": + _, err = fmt.Fprintln(s.stdout, r.Wav) + default: + _, err = fmt.Fprintf(s.stdout, "ttfa_ms=%d\n", r.TTFAMs) + } + } + if err != nil { + return err + } + return s.stdout.Flush() +} diff --git a/internal/say/stream_test.go b/internal/say/stream_test.go new file mode 100644 index 0000000..51cc338 --- /dev/null +++ b/internal/say/stream_test.go @@ -0,0 +1,352 @@ +package say + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/veronica-agent/cans/internal/mouth" +) + +func TestStreamFailContinues(t *testing.T) { + fakeWorkerEnv(t) + dir := t.TempDir() + o := DefaultOptions() + o.Stream = true + o.Out = filepath.Join(dir, "%03d.wav") + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, strings.NewReader("a\nfail\n\nb\n"), &out, &errBuf) + if code != ExitFail { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if _, err := os.Stat(filepath.Join(dir, "001.wav")); err != nil { + t.Fatalf("001.wav: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "002.wav")); err != nil { + t.Fatalf("002.wav: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "003.wav")); err == nil { + t.Fatal("003.wav should not exist") + } + paths := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(paths) != 2 { + t.Fatalf("stdout %q", out.String()) + } + if !strings.Contains(errBuf.String(), "line 2:") { + t.Fatalf("stderr %q", errBuf.String()) + } +} + +func TestStreamJSONRecords(t *testing.T) { + fakeWorkerEnv(t) + dir := t.TempDir() + o := DefaultOptions() + o.Stream = true + o.JSON = true + o.Out = filepath.Join(dir, "%03d.wav") + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, strings.NewReader("a\nfail\n\nb\n"), &out, &errBuf) + if code != ExitFail { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("stdout %q", out.String()) + } + var recs []map[string]any + for i, line := range lines { + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("record %d %q: %v", i, line, err) + } + recs = append(recs, rec) + } + if recs[0]["line"] != float64(1) || recs[1]["line"] != float64(2) || recs[2]["line"] != float64(4) { + t.Fatalf("lines %+v", recs) + } + if recs[1]["error"] == nil { + t.Fatalf("middle record %+v", recs[1]) + } +} + +func TestStreamOneWorker(t *testing.T) { + fakeWorkerEnv(t) + dir := t.TempDir() + count := countingWorker(t, dir) + o := DefaultOptions() + o.Stream = true + o.Out = filepath.Join(dir, "%03d.wav") + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, strings.NewReader("a\nb\nc\nd\ne\n"), &out, &errBuf) + if code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + wantOneStart(t, count) +} + +func TestStreamBusyDoesNotReadStdin(t *testing.T) { + fakeWorkerEnv(t) + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatal(err) + } + defer lk.Release() + o := DefaultOptions() + o.Stream = true + o.Wait = 0 + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, failingReader{t}, &out, &errBuf) + if code != ExitBusy { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } +} + +type syncBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (w *syncBuffer) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.b.Write(p) +} + +func (w *syncBuffer) String() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.b.String() +} + +func TestStreamCancel(t *testing.T) { + fakeWorkerEnv(t) + dir := t.TempDir() + count := countingWorker(t, dir) + + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close(); _ = pr.Close() }) + var out syncBuffer + var errBuf bytes.Buffer + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + o := DefaultOptions() + o.Stream = true + o.Out = filepath.Join(dir, "%03d.wav") + + done := make(chan int, 1) + go func() { + done <- Run(ctx, o, pr, &out, &errBuf) + }() + if _, err := pw.Write([]byte("a\n")); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return out.String() != "" }, "no first record") + cancel() + // Off the test goroutine: once Run has returned nothing reads the pipe, + // so this write would block forever. + go func() { _, _ = pw.Write([]byte("b\n")) }() + code := <-done + _ = pw.CloseWithError(io.EOF) + if code != ExitInterrupted { + t.Fatalf("code %d stderr %q stdout %q", code, errBuf.String(), out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "001.wav")); err != nil { + t.Fatalf("001.wav: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "002.wav")); err == nil { + t.Fatal("002.wav should not exist") + } + if !strings.Contains(errBuf.String(), "interrupted after line 1") { + t.Fatalf("stderr %q", errBuf.String()) + } + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatalf("lock after cancel: %v", err) + } + lk.Release() + wantOneStart(t, count) +} + +func TestStreamEmptyStdinOK(t *testing.T) { + fakeWorkerEnv(t) + o := DefaultOptions() + o.Stream = true + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, strings.NewReader(""), &out, &errBuf) + if code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if out.Len() != 0 { + t.Fatalf("stdout %q", out.String()) + } +} + +func TestStreamLongLineReportsStdin(t *testing.T) { + fakeWorkerEnv(t) + dir := t.TempDir() + o := DefaultOptions() + o.Stream = true + o.Out = filepath.Join(dir, "%03d.wav") + long := strings.Repeat("x", 1<<20+1) + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, strings.NewReader("a\n"+long+"\n"), &out, &errBuf) + if code != ExitFail { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if !strings.Contains(errBuf.String(), "say: stdin: ") { + t.Fatalf("stderr %q", errBuf.String()) + } + if _, err := os.Stat(filepath.Join(dir, "001.wav")); err != nil { + t.Fatalf("the line before the bad one should survive: %v", err) + } +} + +// TestStreamCancelBeforeAnyLine holds the worker mid-synthesis on the first +// line, so cancel lands before anything is spoken (D014). +func TestStreamCancelBeforeAnyLine(t *testing.T) { + fakeWorkerEnv(t) + dir := t.TempDir() + t.Setenv("CANS_FAKE_BLOCK_FILE", filepath.Join(dir, "blocked")) + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close(); _ = pr.Close() }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + o := DefaultOptions() + o.Stream = true + o.Out = filepath.Join(dir, "%03d.wav") + var out syncBuffer + var errBuf bytes.Buffer + + done := make(chan int, 1) + go func() { done <- Run(ctx, o, pr, &out, &errBuf) }() + if _, err := pw.Write([]byte("block\n")); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + _, err := os.Stat(filepath.Join(dir, "blocked")) + return err == nil + }, "worker never reached synthesis") + cancel() + code := <-done + if code != ExitInterrupted { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if !strings.Contains(errBuf.String(), "interrupted before the first line") { + t.Fatalf("stderr %q", errBuf.String()) + } + if out.String() != "" { + t.Fatalf("stdout %q", out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "001.wav")); err == nil { + t.Fatal("001.wav should not exist") + } + lk, err := mouth.Acquire(context.Background(), mouth.Path(), 0, nil) + if err != nil { + t.Fatalf("lock after cancel: %v", err) + } + lk.Release() +} + +// TestStreamCancelAfterFailedLine pins the other half of D014's N: a line that +// was reported as failed still counts as fully processed. +func TestStreamCancelAfterFailedLine(t *testing.T) { + fakeWorkerEnv(t) + dir := t.TempDir() + t.Setenv("CANS_FAKE_BLOCK_FILE", filepath.Join(dir, "blocked")) + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close(); _ = pr.Close() }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + o := DefaultOptions() + o.Stream = true + o.Out = filepath.Join(dir, "%03d.wav") + var out syncBuffer + var errBuf syncBuffer + + done := make(chan int, 1) + go func() { done <- Run(ctx, o, pr, &out, &errBuf) }() + if _, err := pw.Write([]byte("fail\nblock\n")); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { + _, err := os.Stat(filepath.Join(dir, "blocked")) + return err == nil + }, "worker never reached synthesis") + cancel() + if code := <-done; code != ExitInterrupted { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if !strings.Contains(errBuf.String(), "line 1:") { + t.Fatalf("stderr %q", errBuf.String()) + } + if !strings.Contains(errBuf.String(), "interrupted after line 1") { + t.Fatalf("stderr %q", errBuf.String()) + } +} + +// TestStreamNoOutPlaysEachLine is D007: no -o means play every line and keep no +// wav behind. CANS_NOPLAY makes play a header check. +func TestStreamNoOutPlaysEachLine(t *testing.T) { + fakeWorkerEnv(t) + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + o := DefaultOptions() + o.Stream = true + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, strings.NewReader("a\n\nb\n"), &out, &errBuf) + if code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if errBuf.Len() != 0 { + t.Fatalf("stderr %q", errBuf.String()) + } + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("stdout %q", out.String()) + } + for i, line := range lines { + if !strings.HasPrefix(line, "ttfa_ms=") { + t.Fatalf("line %d %q", i, line) + } + } + left, err := filepath.Glob(filepath.Join(tmp, "cans-*.wav")) + if err != nil { + t.Fatal(err) + } + if len(left) != 0 { + t.Fatalf("temp wavs left: %v", left) + } +} + +// countingWorker wraps the fake worker in a script that appends one line per +// start, and returns the path of that counter. +func countingWorker(t *testing.T, dir string) string { + t.Helper() + bin := os.Getenv("CANS_WORKER_BIN") + count := filepath.Join(dir, "starts") + wrap := filepath.Join(filepath.Dir(bin), "wrap") + body := "#!/bin/sh\nprintf 'x\\n' >> " + count + "\nexec " + bin + " \"$@\"\n" + if err := os.WriteFile(wrap, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CANS_WORKER_BIN", wrap) + return count +} + +// wantOneStart asserts the mouth was opened exactly once: one worker, ever. +func wantOneStart(t *testing.T, count string) { + t.Helper() + got, err := os.ReadFile(count) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(got), "\n") != 1 { + t.Fatalf("starts %q", got) + } +} diff --git a/internal/say/template.go b/internal/say/template.go new file mode 100644 index 0000000..97c34b1 --- /dev/null +++ b/internal/say/template.go @@ -0,0 +1,64 @@ +package say + +import ( + "fmt" + "strings" +) + +func checkOut(out string, stream bool) error { + if out == "" { + return nil + } + n, bad := countIntVerbs(out) + if stream { + if bad || n != 1 { + return fmt.Errorf("say: -o needs one %%d in --stream") + } + return nil + } + if bad || n > 0 { + return fmt.Errorf("say: -o template needs --stream") + } + return nil +} + +func outPath(out string, idx int) string { + if out == "" { + return "" + } + n, bad := countIntVerbs(out) + if !bad && n == 1 { + return fmt.Sprintf(out, idx) + } + return strings.ReplaceAll(out, "%%", "%") +} + +func countIntVerbs(s string) (n int, bad bool) { + r := []rune(s) + for i := 0; i < len(r); i++ { + if r[i] != '%' { + continue + } + if i+1 >= len(r) { + return n, true + } + i++ + if r[i] == '%' { + continue + } + if r[i] == '-' { + i++ + if i >= len(r) { + return n, true + } + } + for i < len(r) && r[i] >= '0' && r[i] <= '9' { + i++ + } + if i >= len(r) || r[i] != 'd' { + return n, true + } + n++ + } + return n, false +} diff --git a/internal/say/template_test.go b/internal/say/template_test.go new file mode 100644 index 0000000..23ff277 --- /dev/null +++ b/internal/say/template_test.go @@ -0,0 +1,65 @@ +package say + +import ( + "bytes" + "context" + "testing" +) + +func TestCheckOutErrors(t *testing.T) { + tests := []struct { + name string + out string + stream bool + want string + }{ + {"stream %s", "%s", true, "say: -o needs one %d in --stream"}, + {"stream two verbs", "%03d-%d", true, "say: -o needs one %d in --stream"}, + {"stream no verb", "out.wav", true, "say: -o needs one %d in --stream"}, + {"one-shot %03d", "%03d", false, "say: -o template needs --stream"}, + {"one-shot %q", "%q", false, "say: -o template needs --stream"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkOut(tt.out, tt.stream) + if err == nil || err.Error() != tt.want { + t.Fatalf("err %v, want %q", err, tt.want) + } + }) + } +} + +func TestOutPath(t *testing.T) { + tests := []struct { + out string + idx int + want string + }{ + {"%03d", 1, "001"}, + {"%d", 1, "1"}, + {"out/%02d.wav", 1, "out/01.wav"}, + {"foo%%bar.wav", 0, "foo%bar.wav"}, + {"x%%y%03d", 1, "x%y001"}, + } + for _, tt := range tests { + t.Run(tt.out, func(t *testing.T) { + if got := outPath(tt.out, tt.idx); got != tt.want { + t.Fatalf("outPath(%q,%d)=%q want %q", tt.out, tt.idx, got, tt.want) + } + }) + } +} + +func TestStreamOutWithoutVerbIsUsage(t *testing.T) { + o := DefaultOptions() + o.Stream = true + o.Out = "out.wav" + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, failingReader{t}, &out, &errBuf) + if code != ExitUsage { + t.Fatalf("code %d", code) + } + if errBuf.String() != "say: -o needs one %d in --stream\n" { + t.Fatalf("stderr %q", errBuf.String()) + } +} diff --git a/internal/tts/session.go b/internal/tts/session.go index 8f17a71..abcee1c 100644 --- a/internal/tts/session.go +++ b/internal/tts/session.go @@ -19,6 +19,9 @@ import ( type Session struct { c *Client lock *mouth.Lock + // done is the session context's Done channel, kept so Close can tell an + // expected terminate-on-cancel (D014) from a real worker failure. + done <-chan struct{} } // Options bound how long Open waits for the mouth. @@ -68,7 +71,7 @@ func startSession(ctx context.Context, lock *mouth.Lock) (*Session, error) { if err != nil { return nil, fmt.Errorf("say: worker: %w", err) } - return &Session{c: c, lock: lock}, nil + return &Session{c: c, lock: lock, done: ctx.Done()}, nil } // Say clones text using the frozen throat into a temp wav. @@ -110,7 +113,9 @@ func (s *Session) SayTo(ctx context.Context, text string, cur keep.Current, out return Result{Wav: out, TTFAMs: ms, SampleRate: rate}, nil } -// Close shuts the worker down, then releases the mouth lock. +// Close shuts the worker down, then releases the mouth lock. After a cancel +// the worker is signalled rather than asked (D014), so the refused shutdown +// write and the `signal: terminated` exit are expected, not failures. func (s *Session) Close() error { if s == nil { return nil @@ -120,6 +125,9 @@ func (s *Session) Close() error { err = s.c.Close() s.c = nil } + if s.cancelled() { + err = nil + } if s.lock != nil { rerr := s.lock.Release() s.lock = nil @@ -129,3 +137,13 @@ func (s *Session) Close() error { } return err } + +// cancelled reports whether the context the session was opened with is done. +func (s *Session) cancelled() bool { + select { + case <-s.done: + return true + default: + return false + } +} diff --git a/internal/tts/testdata/fakeworker/main.go b/internal/tts/testdata/fakeworker/main.go index f0ea53f..66fb082 100644 --- a/internal/tts/testdata/fakeworker/main.go +++ b/internal/tts/testdata/fakeworker/main.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "encoding/json" "fmt" + "io" "math" "os" ) @@ -31,6 +32,14 @@ func main() { fmt.Printf("{\"type\":\"error\",\"id\":%q,\"message\":\"missing text\"}\n", req.ID) continue } + if req.Text == "fail" { + fmt.Printf("{\"type\":\"error\",\"id\":%q,\"message\":\"fail\"}\n", req.ID) + continue + } + if req.Text == "block" { + block() + return + } fmt.Printf("{\"type\":\"pcm_meta\",\"id\":%q,\"sample_rate\":24000,\"format\":\"f32le\",\"n_samples\":1}\n", req.ID) var b [4]byte binary.LittleEndian.PutUint32(b[:], math.Float32bits(0)) @@ -38,3 +47,13 @@ func main() { fmt.Printf("\n{\"type\":\"final\",\"id\":%q}\n", req.ID) } } + +// block answers nothing: it reports that synthesis started by creating +// CANS_FAKE_BLOCK_FILE, then waits in a read until the parent signals it. It +// stands in for the mouth mid-utterance, which cannot be aborted. +func block() { + if p := os.Getenv("CANS_FAKE_BLOCK_FILE"); p != "" { + _ = os.WriteFile(p, []byte("x"), 0o644) + } + _, _ = io.Copy(io.Discard, os.Stdin) +} diff --git a/internal/tts/worker.go b/internal/tts/worker.go index a42fe2d..3fa576a 100644 --- a/internal/tts/worker.go +++ b/internal/tts/worker.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "time" ) @@ -44,6 +45,10 @@ func StartWorker(ctx context.Context, workerBin, modelDir string) (*Client, erro return nil, err } cmd := exec.CommandContext(ctx, workerBin, modelDir) + // D014: cancel terminates the worker rather than SIGKILLing it, and + // WaitDelay bounds a worker that ignores SIGTERM or leaves pipes open. + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + cmd.WaitDelay = 2 * time.Second libDir := filepath.Dir(workerBin) cmd.Env = withLibPath(os.Environ(), libDir) stdin, err := cmd.StdinPipe() From 5e8123d8154d2d5d022d099694851e8c374f56a7 Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:33:20 -0600 Subject: [PATCH 5/9] [veronica:ea389d71-FE-CV0001] feat: pipe tape and README scripting section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: tapes/pipe.tape plus a `just vhs pipe` recipe (build quick, then vhs record), and docs/pipe.gif cut from it on the real mouth — three technical lines into lines.txt, `cans say --stream -o 'out/%03d.wav' --json`, `ls out`. The gif was recorded at 1-minute load ~21, so the ttfa_ms on screen reads 31-35 s and the run stretches to 123 s; 004_REVIEW re-cuts it under load < 16 from the same tape. README gains a "## Scripting" section: the three loops from the design pack, a flag table for the seven flags, an exit table (0/1/2/75/130), the D014 Ctrl-C line and the temp-wav line. The usage const gains the three say forms, drops its duplicate bare say line, and now says exit 75 covers an expired --wait. Why: --stream is only worth shipping if a script author can see what it is for — a document piped in, wavs landing where the script points, one model load for the whole thing. The public tree reads that section before it reads the code, so the examples have to run: the review caught all three loops carrying bugs inherited from the design pack (a subshell counter that wrote out/001.wav every iteration, a manifest of temp wavs deleted before it was written, and awk paragraph mode speaking one wav per source line). Fixed here, verified in a shell. --- .justfiles/vhs.just | 5 ++++ README.md | 49 ++++++++++++++++++++++++++++++++++++++ cmd/cans/main.go | 7 ++++-- cmd/cans/say_args_test.go | 4 ++-- docs/pipe.gif | Bin 0 -> 168370 bytes tapes/pipe.tape | 48 +++++++++++++++++++++++++++++++++++++ 6 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 docs/pipe.gif create mode 100644 tapes/pipe.tape 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..459ca47 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. + +

+ A script piping lines into cans +

+ +`--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"`. | +| `--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. + --- Built with [Festival](https://fest.build) diff --git a/cmd/cans/main.go b/cmd/cans/main.go index b988bb7..e8df393 100644 --- a/cmd/cans/main.go +++ b/cmd/cans/main.go @@ -27,12 +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 -exit 75 when another cans holds the mouth and --nowait was set + 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() { diff --git a/cmd/cans/say_args_test.go b/cmd/cans/say_args_test.go index 1c254b3..10ac308 100644 --- a/cmd/cans/say_args_test.go +++ b/cmd/cans/say_args_test.go @@ -16,8 +16,8 @@ func TestParseSayErrors(t *testing.T) { }{ {"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 "}, - {"help long", []string{"--help"}, "cans say "}, + {"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"}, diff --git a/docs/pipe.gif b/docs/pipe.gif new file mode 100644 index 0000000000000000000000000000000000000000..543c8220acecc502d727986b8f32ea5439e1f4d0 GIT binary patch literal 168370 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%0AyxFa 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 lines.txt` +Sleep 350ms +Enter +Sleep 900ms +Type `cat lines.txt | cans say --stream -o 'out/%03d.wav' --json` +Sleep 350ms +Enter +Wait+Screen@300s /003\.wav/ +Sleep 900ms +Type `ls out` +Sleep 350ms +Enter +Wait+Screen@30s /(?m)^001\.wav/ +Sleep 2500ms From 9136cbe5d6752c011a7ad4601644ffa6436e5278 Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:14:55 -0600 Subject: [PATCH 6/9] =?UTF-8?q?[veronica:ea389d71-FE-CV0001]=20feat:=20fes?= =?UTF-8?q?tivals/CV0001=20=E2=80=94=20the=20v2=20plan=20as=20a=20readable?= =?UTF-8?q?=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: an 86-file snapshot of festival CV0001 into festivals/CV0001/, next to CA0001 — goal, rules, overview, TODO, the INGEST output specs, the plan (STRUCTURE, IMPLEMENTATION_PLAN, D001-D014, measurements), and all five implementation sequences with their gates and recorded results. Campaign-private inputs and runtime state are excluded per D009: CONTEXT.md, 001_INGEST/input_specs/, .fest/, .workflow/, .festival-checksums.json, and the reviewers' hidden .review-* notes. Absolute home paths were scrubbed to ~ in the source before the copy, and the campaign-private grep patterns were replaced with references rather than quoted verbatim. Also recorded in the tree: the full surface recheck — ten packages green on the fake worker, gofmt and go vet silent, go.mod/go.sum undiffed, fresh-home doctor and say -o against the real mouth with a verified 24 kHz wav header, the professional-surface grep, goreleaser check, and just vhs pipe. Why: the festival tree is the second readable plan in the repo. A stranger can follow how cans grew a stream, a lock and a wav path without fest installed and without seeing anything the campaign keeps private. --- festivals/CV0001/001_INGEST/GATES.md | 58 +++ festivals/CV0001/001_INGEST/PHASE_GOAL.md | 66 +++ festivals/CV0001/001_INGEST/WORKFLOW.md | 115 +++++ .../001_INGEST/output_specs/PRESENTATION.md | 40 ++ .../CV0001/001_INGEST/output_specs/README.md | 18 + .../001_INGEST/output_specs/constraints.md | 38 ++ .../CV0001/001_INGEST/output_specs/context.md | 63 +++ .../CV0001/001_INGEST/output_specs/purpose.md | 57 +++ .../001_INGEST/output_specs/requirements.md | 70 ++++ festivals/CV0001/002_PLAN/GATES.md | 58 +++ festivals/CV0001/002_PLAN/PHASE_GOAL.md | 77 ++++ festivals/CV0001/002_PLAN/WORKFLOW.md | 176 ++++++++ .../decisions/D001_lock_lifetime_booth.md | 7 + .../CV0001/002_PLAN/decisions/D002_one_pr.md | 7 + .../002_PLAN/decisions/D003_flock_stdlib.md | 7 + .../002_PLAN/decisions/D004_flag_grammar.md | 7 + .../002_PLAN/decisions/D005_blank_lines.md | 7 + .../CV0001/002_PLAN/decisions/D006_records.md | 11 + .../002_PLAN/decisions/D007_stream_plays.md | 5 + .../002_PLAN/decisions/D008_interrupt_130.md | 7 + .../decisions/D009_public_snapshot.md | 7 + .../002_PLAN/decisions/D010_internal_say.md | 7 + .../002_PLAN/decisions/D011_internal_mouth.md | 7 + .../002_PLAN/decisions/D012_mkdir_out.md | 7 + .../002_PLAN/decisions/D013_measurements.md | 7 + .../decisions/D014_cancel_terminates.md | 9 + festivals/CV0001/002_PLAN/decisions/INDEX.md | 18 + festivals/CV0001/002_PLAN/inputs/README.md | 35 ++ festivals/CV0001/002_PLAN/inputs/gaps.md | 14 + .../CV0001/002_PLAN/inputs/measurements.md | 394 ++++++++++++++++++ .../002_PLAN/plan/IMPLEMENTATION_PLAN.md | 68 +++ festivals/CV0001/002_PLAN/plan/README.md | 81 ++++ festivals/CV0001/002_PLAN/plan/STRUCTURE.md | 18 + .../003_IMPLEMENT/01_out/01_parse_say.md | 42 ++ .../003_IMPLEMENT/01_out/02_internal_say.md | 48 +++ .../01_out/03_stdin_json_exit.md | 44 ++ .../CV0001/003_IMPLEMENT/01_out/04_testing.md | 137 ++++++ .../CV0001/003_IMPLEMENT/01_out/05_review.md | 78 ++++ .../CV0001/003_IMPLEMENT/01_out/06_iterate.md | 49 +++ .../003_IMPLEMENT/01_out/07_fest_commit.md | 70 ++++ .../003_IMPLEMENT/01_out/SEQUENCE_GOAL.md | 23 + .../CV0001/003_IMPLEMENT/02_lock/01_flock.md | 55 +++ .../003_IMPLEMENT/02_lock/02_session_lock.md | 43 ++ .../003_IMPLEMENT/02_lock/03_flags_booth.md | 40 ++ .../003_IMPLEMENT/02_lock/04_testing.md | 120 ++++++ .../CV0001/003_IMPLEMENT/02_lock/05_review.md | 75 ++++ .../003_IMPLEMENT/02_lock/06_iterate.md | 48 +++ .../003_IMPLEMENT/02_lock/07_fest_commit.md | 70 ++++ .../003_IMPLEMENT/02_lock/SEQUENCE_GOAL.md | 23 + .../003_IMPLEMENT/03_stream/01_stream_loop.md | 46 ++ .../03_stream/02_out_template.md | 37 ++ .../003_IMPLEMENT/03_stream/03_cancel.md | 43 ++ .../003_IMPLEMENT/03_stream/04_measure.md | 60 +++ .../003_IMPLEMENT/03_stream/05_testing.md | 242 +++++++++++ .../003_IMPLEMENT/03_stream/06_review.md | 95 +++++ .../003_IMPLEMENT/03_stream/07_iterate.md | 98 +++++ .../003_IMPLEMENT/03_stream/08_fest_commit.md | 70 ++++ .../003_IMPLEMENT/03_stream/SEQUENCE_GOAL.md | 21 + .../003_IMPLEMENT/04_tape/01_pipe_tape.md | 52 +++ .../04_tape/02_readme_scripting.md | 47 +++ .../003_IMPLEMENT/04_tape/03_testing.md | 172 ++++++++ .../CV0001/003_IMPLEMENT/04_tape/04_review.md | 121 ++++++ .../003_IMPLEMENT/04_tape/05_iterate.md | 113 +++++ .../003_IMPLEMENT/04_tape/06_fest_commit.md | 97 +++++ .../003_IMPLEMENT/04_tape/SEQUENCE_GOAL.md | 21 + .../003_IMPLEMENT/05_snapshot/01_snapshot.md | 130 ++++++ .../003_IMPLEMENT/05_snapshot/02_recheck.md | 192 +++++++++ .../003_IMPLEMENT/05_snapshot/03_testing.md | 157 +++++++ .../003_IMPLEMENT/05_snapshot/04_review.md | 121 ++++++ .../003_IMPLEMENT/05_snapshot/05_iterate.md | 104 +++++ .../05_snapshot/06_fest_commit.md | 68 +++ .../05_snapshot/SEQUENCE_GOAL.md | 19 + festivals/CV0001/003_IMPLEMENT/GATES.md | 73 ++++ festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md | 85 ++++ festivals/CV0001/004_REVIEW/BAR.md | 37 ++ festivals/CV0001/004_REVIEW/GATES.md | 58 +++ festivals/CV0001/004_REVIEW/PHASE_GOAL.md | 100 +++++ festivals/CV0001/FESTIVAL_GOAL.md | 60 +++ festivals/CV0001/FESTIVAL_OVERVIEW.md | 58 +++ festivals/CV0001/FESTIVAL_RULES.md | 33 ++ festivals/CV0001/TODO.md | 57 +++ festivals/CV0001/fest.yaml | 61 +++ .../QUALITY_GATE_FEST_COMMIT.md | 73 ++++ .../implementation/QUALITY_GATE_ITERATE.md | 50 +++ .../implementation/QUALITY_GATE_REVIEW.md | 73 ++++ .../implementation/QUALITY_GATE_TESTING.md | 66 +++ 86 files changed, 5541 insertions(+) create mode 100644 festivals/CV0001/001_INGEST/GATES.md create mode 100644 festivals/CV0001/001_INGEST/PHASE_GOAL.md create mode 100644 festivals/CV0001/001_INGEST/WORKFLOW.md create mode 100644 festivals/CV0001/001_INGEST/output_specs/PRESENTATION.md create mode 100644 festivals/CV0001/001_INGEST/output_specs/README.md create mode 100644 festivals/CV0001/001_INGEST/output_specs/constraints.md create mode 100644 festivals/CV0001/001_INGEST/output_specs/context.md create mode 100644 festivals/CV0001/001_INGEST/output_specs/purpose.md create mode 100644 festivals/CV0001/001_INGEST/output_specs/requirements.md create mode 100644 festivals/CV0001/002_PLAN/GATES.md create mode 100644 festivals/CV0001/002_PLAN/PHASE_GOAL.md create mode 100644 festivals/CV0001/002_PLAN/WORKFLOW.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D001_lock_lifetime_booth.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D002_one_pr.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D003_flock_stdlib.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D004_flag_grammar.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D005_blank_lines.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D006_records.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D007_stream_plays.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D008_interrupt_130.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D009_public_snapshot.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D010_internal_say.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D011_internal_mouth.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D012_mkdir_out.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D013_measurements.md create mode 100644 festivals/CV0001/002_PLAN/decisions/D014_cancel_terminates.md create mode 100644 festivals/CV0001/002_PLAN/decisions/INDEX.md create mode 100644 festivals/CV0001/002_PLAN/inputs/README.md create mode 100644 festivals/CV0001/002_PLAN/inputs/gaps.md create mode 100644 festivals/CV0001/002_PLAN/inputs/measurements.md create mode 100644 festivals/CV0001/002_PLAN/plan/IMPLEMENTATION_PLAN.md create mode 100644 festivals/CV0001/002_PLAN/plan/README.md create mode 100644 festivals/CV0001/002_PLAN/plan/STRUCTURE.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/01_parse_say.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/02_internal_say.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/03_stdin_json_exit.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/04_testing.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/05_review.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/06_iterate.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/07_fest_commit.md create mode 100644 festivals/CV0001/003_IMPLEMENT/01_out/SEQUENCE_GOAL.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/01_flock.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/02_session_lock.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/03_flags_booth.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/04_testing.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/05_review.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/06_iterate.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/07_fest_commit.md create mode 100644 festivals/CV0001/003_IMPLEMENT/02_lock/SEQUENCE_GOAL.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/01_stream_loop.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/02_out_template.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/03_cancel.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/04_measure.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/05_testing.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/06_review.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/07_iterate.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/08_fest_commit.md create mode 100644 festivals/CV0001/003_IMPLEMENT/03_stream/SEQUENCE_GOAL.md create mode 100644 festivals/CV0001/003_IMPLEMENT/04_tape/01_pipe_tape.md create mode 100644 festivals/CV0001/003_IMPLEMENT/04_tape/02_readme_scripting.md create mode 100644 festivals/CV0001/003_IMPLEMENT/04_tape/03_testing.md create mode 100644 festivals/CV0001/003_IMPLEMENT/04_tape/04_review.md create mode 100644 festivals/CV0001/003_IMPLEMENT/04_tape/05_iterate.md create mode 100644 festivals/CV0001/003_IMPLEMENT/04_tape/06_fest_commit.md create mode 100644 festivals/CV0001/003_IMPLEMENT/04_tape/SEQUENCE_GOAL.md create mode 100644 festivals/CV0001/003_IMPLEMENT/05_snapshot/01_snapshot.md create mode 100644 festivals/CV0001/003_IMPLEMENT/05_snapshot/02_recheck.md create mode 100644 festivals/CV0001/003_IMPLEMENT/05_snapshot/03_testing.md create mode 100644 festivals/CV0001/003_IMPLEMENT/05_snapshot/04_review.md create mode 100644 festivals/CV0001/003_IMPLEMENT/05_snapshot/05_iterate.md create mode 100644 festivals/CV0001/003_IMPLEMENT/05_snapshot/06_fest_commit.md create mode 100644 festivals/CV0001/003_IMPLEMENT/05_snapshot/SEQUENCE_GOAL.md create mode 100644 festivals/CV0001/003_IMPLEMENT/GATES.md create mode 100644 festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md create mode 100644 festivals/CV0001/004_REVIEW/BAR.md create mode 100644 festivals/CV0001/004_REVIEW/GATES.md create mode 100644 festivals/CV0001/004_REVIEW/PHASE_GOAL.md create mode 100644 festivals/CV0001/FESTIVAL_GOAL.md create mode 100644 festivals/CV0001/FESTIVAL_OVERVIEW.md create mode 100644 festivals/CV0001/FESTIVAL_RULES.md create mode 100644 festivals/CV0001/TODO.md create mode 100644 festivals/CV0001/fest.yaml create mode 100644 festivals/CV0001/gates/implementation/QUALITY_GATE_FEST_COMMIT.md create mode 100644 festivals/CV0001/gates/implementation/QUALITY_GATE_ITERATE.md create mode 100644 festivals/CV0001/gates/implementation/QUALITY_GATE_REVIEW.md create mode 100644 festivals/CV0001/gates/implementation/QUALITY_GATE_TESTING.md 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 " +``` + +**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/03_stream/SEQUENCE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/03_stream/SEQUENCE_GOAL.md new file mode 100644 index 0000000..9b9803c --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/03_stream/SEQUENCE_GOAL.md @@ -0,0 +1,21 @@ +--- +fest_type: sequence +fest_id: 03_stream +fest_name: stream +fest_parent: 003_IMPLEMENT +fest_order: 3 +fest_status: completed +fest_created: 2026-08-21T05:04:55.99338-06:00 +fest_updated: 2026-08-21T17:49:28.620777-06:00 +fest_tracking: true +fest_working_dir: projects/worktrees/cans/cans-v2 +--- + + +# Sequence Goal: 03_stream + +**Primary Goal:** `cans say --stream` speaks one utterance per stdin line over one warm `Session`, writes `-o 'out/%03d.wav'`, flushes a record per line, keeps going past a bad line, exits 130 on Ctrl-C with finished wavs intact — and the numbers that prove it are recorded. + +Covers P0-5, P0-6, P0-7, P0-8, P0-9, P0-19, P0-20, P1-4. Decisions D005, D006, D007, D008, D013. + +Dependencies: 02_lock committed (the stream holds the lock for its run). \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/04_tape/01_pipe_tape.md b/festivals/CV0001/003_IMPLEMENT/04_tape/01_pipe_tape.md new file mode 100644 index 0000000..e0215bf --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/04_tape/01_pipe_tape.md @@ -0,0 +1,52 @@ +--- +fest_type: task +fest_id: 01_pipe_tape.md +fest_name: pipe_tape +fest_parent: 04_tape +fest_order: 1 +fest_status: completed +fest_autonomy: medium +fest_created: 2026-08-21T05:04:57.054275-06:00 +fest_updated: 2026-08-21T16:16:00.050873-06:00 +fest_tracking: true +--- + + +# Task: pipe_tape + +## Objective + +`tapes/pipe.tape` → `docs/pipe.gif` via `just vhs pipe`: a terminal, a script the user owns, and files landing in `out/`. No narration, no pitch. + +## Requirements + +- [x] Model the tape on `tapes/booth.tape` (same theme block, `Set Width 680`, font size, `Env TERM` / `COLORTERM` / `CLICOLOR_FORCE`). Output `docs/pipe.gif` only (no mp4 — there is no audio to mux). +- [x] The visible script, typed: `printf 'Put the cans on.\nOne worker, one model load.\nFiles land where the script points.\n' > lines.txt`; `cat lines.txt | cans say --stream -o 'out/%03d.wav' --json`; `ls out`. Nothing else. Those three lines **are** the example text — do not change them. +- [x] Real mouth (no `CANS_SAY_BIN`, no fake worker). Hidden preamble as in `tapes/demo.tape.in`: `export PATH="/bin:$PATH" PS1="> "`, and `cd "$(mktemp -d)"` so `lines.txt` and `out/` never land in the repo. +- [x] Timing: if `vhs manual` shows a `Wait` command in the installed version, use `Wait+Screen /003.wav/` (with a timeout) before `ls out`; otherwise `Sleep` generously (30 s is fine). +- [x] `.justfiles/vhs.just`: `pipe:` recipe → `just build quick`, `just vhs record tapes/pipe.tape`. Keep `booth:` and `demo:` untouched. +- [x] `docs/pipe.gif` under 2 MB. Check a late frame: `ffmpeg -y -sseof -0.5 -i docs/pipe.gif -frames:v 1 frame.png` (in scratch) and look at it — the three JSON records and `ls out` must be readable. + +## Implementation + +1. `cp tapes/booth.tape tapes/pipe.tape`, change `Output`, replace the typed section, keep the `Hide` / `Show` preamble pattern from `demo.tape.in`. +2. Add the recipe; run `just vhs pipe`; inspect the frame; iterate on `Sleep` / `Wait` until the last frame shows the `ls out` listing. +3. Run the professional-surface grep (`CONTEXT.md §Professional grep`) over `tapes/` and `docs/` — it must print nothing. + +## Done when + +- [x] `just vhs pipe` regenerates `docs/pipe.gif` from a clean checkout with the mouth installed +- [x] Frame check done and recorded in the testing gate (describe what the last frame shows) +- [x] `git status` shows only `tapes/pipe.tape`, `.justfiles/vhs.just`, `docs/pipe.gif` — no `lines.txt`, no `out/` + +## Result + +`just vhs pipe` → `docs/pipe.gif`, **164 KB** (limit 2 MB), 123.4 s, 680×380, 12 fps, real mouth (no `CANS_SAY_BIN`, no fake worker). Recorded 16:12:31–16:14:58 on 2026-08-21, first attempt. Both `Wait+Screen` guards fired (`vhs` 0.11.0): `Wait+Screen@300s /003\.wav/` before `ls out`, `Wait+Screen@30s /(?m)^001\.wav/` on the listing. + +**Load at record time: 1-minute load 20.88 mean / 16.26 min / 27.93 max over 33 five-second samples — above the 16 bar for the whole run.** The box was never under 16 while the tape ran. Consequence, visible in the gif: `ttfa_ms` reads **31377 / 34570 / 35202** for three short lines, against a 5652 ms quiet-box baseline for the same one-shot text, and the gif is 123 s long instead of ~40 s. Nothing is wrong with the tape or the code — the numbers on screen are the loaded box. **004_REVIEW should re-cut this gif with `just vhs pipe` when the 1-minute load is under 16**; the tape is deterministic and needs no edit to do it. + +Frame check (`ffmpeg -y -sseof -0.5 -i docs/pipe.gif -frames:v 1 frame.png`, in scratch): the last frame shows the typed `printf … > lines.txt` (wrapped over two rows), the typed `cat lines.txt | cans say --stream -o 'out/%03d.wav' --json`, three JSON records — `{"line":1,"wav":"out/001.wav","ttfa_ms":31377,"sample_rate":24000}` and the same for lines 2 and 3, each wrapping its trailing `00}` onto a second row at 70 columns — then `> ls out` and the listing `001.wav 002.wav 003.wav`, then the prompt back. All readable. + +Professional-surface grep over `README.md docs/ tapes/`: both patterns print nothing; `rg -c 'fest.build' README.md` prints `1`. + +`git status --short` after the record: `M .justfiles/vhs.just`, `?? docs/pipe.gif`, `?? tapes/pipe.tape` — no `lines.txt`, no `out/` (the tape's hidden preamble does `cd "$(mktemp -d)"`). \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/04_tape/02_readme_scripting.md b/festivals/CV0001/003_IMPLEMENT/04_tape/02_readme_scripting.md new file mode 100644 index 0000000..abbe1c6 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/04_tape/02_readme_scripting.md @@ -0,0 +1,47 @@ +--- +fest_type: task +fest_id: 02_readme_scripting.md +fest_name: readme_scripting +fest_parent: 04_tape +fest_order: 2 +fest_status: completed +fest_autonomy: medium +fest_created: 2026-08-21T05:04:57.057442-06:00 +fest_updated: 2026-08-21T16:18:13.148031-06:00 +fest_tracking: true +--- + + +# Task: readme_scripting + +## Objective + +A README "Scripting" section and an updated `usage` const: the loops, the flags, the exit codes, the one honest line about Ctrl-C (per D014, not D008) — boring, technical, and clean on the grep. + +## Requirements + +- [x] New `## Scripting` section after `## Commands`, in this order: one sentence (`The script owns the document. cans speaks what it is handed.`); `docs/pipe.gif` embedded at width 680 like the booth gif; the three loops from `design-pipes.md §Loops this makes possible` with boring text (measurement-style lines or the three tape lines — never product copy); a flag table (`-o`, `--stream`, `--json`, `--play`, `--nowait`, `--wait`, `-`); an exit-code table (`0`, `1`, `2`, `75`, `130`); the line `Ctrl-C stops the stream: the line being spoken is dropped, finished wavs stay, exit 130. A second Ctrl-C stops at once.` (D014); the line `Without -o the wav is a temp file removed after playback.` +- [x] Keep the section under 60 lines. No new headings elsewhere. The footer `Built with [Festival](https://fest.build)` stays exactly once — do not add a second Festival line and do not reword the first. +- [x] `cmd/cans/main.go` `usage` const: add `cans say [-o out.wav] [--json] [--play] [--nowait|--wait 30s] `, `echo text | cans say`, and `cans say --stream -o 'out/%03d.wav' < lines.txt` lines; keep the const under 20 lines. +- [x] `docs/phrases` is campaign-private — do not reference it from the README. Run the professional-surface grep from `CONTEXT.md §Professional grep`; it must print nothing; `rg -c 'fest.build' README.md` must print `1`. + +## Implementation + +1. Write the section; keep the existing README voice (short lines, no exclamation marks, no "we"). +2. Update `usage`; run `./bin/cans --help` and `./bin/cans say -h` and read them. +3. Run the greps; fix anything they catch. + +## Done when + +- [x] Grep empty; footer count 1; README read top to bottom once +- [x] `CANS_NOPLAY=1 go test ./...` green (the `usage` change may touch `main_test.go` expectations) + +## Result + +`## Scripting` added as the last section of `README.md`, after `## Commands` and its two closing paragraphs, immediately above the `---` footer. **46 lines** (limit 60). Contents in the required order: the one sentence, `docs/pipe.gif` at `width="680"` in the same centred `

` shape as the booth gif, one mechanism line (`one model load for the whole document` — no number, no margin, since `04_measure` is still blocked), the three loops from `design-pipes.md §Loops this makes possible` verbatim in shape (`chapter.md`, `lines.txt`, `manifest.txt`), a 7-row flag table, a 5-row exit-code table, the stdout/stderr line, the D014 Ctrl-C line word for word, and the temp-file line. + +`ttfa_ms` is described as "the worker's total synthesis time for that line", which is what `worker_pcm.go` actually stamps — the deferred semantics item in `CONTEXT.md` says the festival keeps the field's meaning and says so. + +`cmd/cans/main.go` `usage`: three lines added after the command list — `cans say [-o out.wav] [--json] [--play] [--nowait|--wait 30s] `, `echo text | cans say`, `cans say --stream -o 'out/%03d.wav' < lines.txt`. The const is **16 lines** (limit 20). `usage` is only ever passed to `fmt.Fprint` and `fmt.Errorf("%s", …)`, so the `%03d` is not a format hazard. `./bin/cans --help` (exit 0) and `./bin/cans say -h` (exit 2, usage on stderr — pre-existing `parseSay` behaviour, untouched) both print it. + +Checks: `rg -i '' README.md | rg -v ''` — nothing. `rg -i '' README.md docs/ tapes/` — nothing. `rg -c 'fest.build' README.md` — `1`. `rg -n '\bwe\b|!' README.md` — nothing. No reference to `docs/phrases`. `gofmt -l .` empty, `go vet ./...` clean, `CANS_NOPLAY=1 go test ./...` green in all ten packages (`cmd/cans` re-ran at 0.538s after the `usage` change; no test asserted on the const's text). \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/04_tape/03_testing.md b/festivals/CV0001/003_IMPLEMENT/04_tape/03_testing.md new file mode 100644 index 0000000..98e4913 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/04_tape/03_testing.md @@ -0,0 +1,172 @@ +--- +fest_type: gate +fest_id: 03_testing.md +fest_name: Testing and Verification +fest_parent: 04_tape +fest_order: 3 +fest_status: completed +fest_autonomy: medium +fest_gate_id: testing +fest_gate_type: testing +fest_managed: true +fest_created: 2026-08-21T05:04:57.519171-06:00 +fest_updated: 2026-08-21T16:22:32.212624-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 + +Sequence `04_tape` adds no Go logic — one tape, one just recipe, one gif, a README section and three lines in the `usage` const. The gate is therefore the full regression bar plus the sequence's own checks. + +### cans-v2 commands + +``` +$ gofmt -l . +(no output) + +$ go vet ./... +(no output) + +$ CANS_NOPLAY=1 go test -count=1 ./... +ok github.com/veronica-agent/cans/cmd/cans 2.016s +ok github.com/veronica-agent/cans/internal/audio 0.494s +ok github.com/veronica-agent/cans/internal/booth 0.200s +ok github.com/veronica-agent/cans/internal/doctor 1.174s +ok github.com/veronica-agent/cans/internal/keep 0.670s +ok github.com/veronica-agent/cans/internal/mouth 1.396s +ok github.com/veronica-agent/cans/internal/play 0.985s +ok github.com/veronica-agent/cans/internal/say 7.831s +ok github.com/veronica-agent/cans/internal/ship 1.733s +ok github.com/veronica-agent/cans/internal/tts 3.862s + +$ git diff origin/main -- go.mod go.sum +(no output) + +$ wc -l $(git diff --name-only origin/main -- '*.go') | sort -n | tail -5 + 200 cmd/cans/main_test.go + 201 internal/tts/worker.go + 352 internal/say/stream_test.go + 418 internal/say/say_test.go + 3148 total +``` + +Every changed Go file is under 500 lines; the largest is `internal/say/say_test.go` at 418. `internal/tts/worker.go` is 201, which is the +5 over the rules' 196 that `03_stream`'s gate already flagged for the reviewer — `04_tape` did not touch it. + +### One-shot regression, real mouth, run alone + +Three runs, each with `pgrep -fl 'cans/native/bin/qwen3-tts-worker'` empty before it and no other real-mouth command in flight. Sampler is an external script (`pgrep -fl` once a second) so the pattern is not in the sampler's own argv. + +``` +$ ./bin/cans say "Put the cans on." ; echo "exit=$?" +ttfa_ms=26911 exit=0 wall=36s 1-min load before 15.31 +ttfa_ms=5990 exit=0 wall=14s 1-min load before 7.76 +ttfa_ms=27704 exit=0 wall=35s 1-min load before 8.32 +``` + +v1 behaviour is unchanged: `ttfa_ms=N` alone on stdout, nothing else, exit 0, the line plays, and the temp wav is gone afterwards (`find $TMPDIR -maxdepth 1 -name 'cans-say*' -newermt '2026-08-21 16:00'` returns nothing; the only `cans-say.*` in `$TMPDIR` is dated Aug 19). + +Run 2 lands on the `03_stream` baseline exactly (5 652 ms / 13.1 s). Runs 1 and 3 are 26.9 s and 27.7 s for the same four-word line — the mouth's end-of-speech variance already recorded in `CONTEXT.md §Deferred`, not a `04_tape` regression: run 3 sat at 1-min load 8.32, so load does not explain it. Two of three quiet-box one-shots costing ~27 s is worth the operator knowing. + +**Worker count during run 3: max 1** across 34 one-second samples — a single process, PID 78262, `~/.cans/native/bin/qwen3-tts-worker ~/.cans/native/models`. (An earlier inline sampler reported max 2; that was the sampler's own shell self-matching `pgrep -f`, since the pattern sat in its argv. macOS `pgrep` also has no `-c`, so `pgrep -fc` silently fails — use `pgrep -f … | wc -l` from a script file.) + +### Professional-surface grep (CONTEXT.md §Professional grep) + +``` +$ rg -i '' README.md | rg -v '' +(no output) + +$ rg -i '' README.md docs/ tapes/ +(no output) + +$ rg -c 'fest.build' README.md +1 + +$ rg -n '\bwe\b|!' README.md +(no output) +``` + +`festivals/CV0001/` is not in the tree yet — that path is created by `05_snapshot`, which runs the same grep over it. + +### Tape and gif + +``` +$ ls -l docs/pipe.gif +-rw-r--r--@ 1 user staff 168370 Aug 21 16:14 docs/pipe.gif # 164 KB, limit 2 MB +$ ffprobe -v error -show_entries format=duration -of default=nw=1 docs/pipe.gif +duration=123.440000 +``` + +Recorded with `just vhs pipe` at 16:12:31–16:14:58, first attempt, real mouth. Both `Wait+Screen` guards fired without timing out. + +**Load at record time: 1-minute load mean 20.88, min 16.26, max 27.93 over 33 five-second samples — above the 16 bar for the entire recording.** What that costs, on screen: the three records read `ttfa_ms` 31377 / 34570 / 35202, and the gif runs 123 s instead of roughly 40 s. The tape and the code are correct; the numbers are the loaded box. **004_REVIEW should re-cut the gif with `just vhs pipe` once the 1-minute load holds under 16** — the tape is deterministic and needs no edit. + +Frame check (`ffmpeg -y -sseof -0.5 -i docs/pipe.gif -frames:v 1 frame.png`, written to scratch, viewed): the last frame shows the typed `printf … > lines.txt` wrapped over two rows, the typed `cat lines.txt | cans say --stream -o 'out/%03d.wav' --json`, then three JSON records — `{"line":1,"wav":"out/001.wav","ttfa_ms":31377,"sample_rate":24000}` and the same shape for lines 2 and 3, each wrapping a trailing `00}` at 70 columns — then `> ls out`, the listing `001.wav 002.wav 003.wav`, and the prompt back. All of it readable. + +### Working tree + +``` +$ git status --short + M .justfiles/vhs.just + M README.md + M cmd/cans/main.go +?? docs/pipe.gif +?? tapes/pipe.tape + +$ just vhs +Available recipes: + booth + demo # the worker (cans doctor puts both in ~/.cans/native/bin). + doctor + pipe # Real mouth, no audio: a script pipes lines in, wavs land in out/. + record tape +``` + +Exactly the five expected paths. No `lines.txt` and no `out/` — the tape's hidden preamble does `cd "$(mktemp -d)"`. No test file changed: the three added lines needed no test edit. **Correction, made in `05_iterate.md`:** this sentence originally claimed nothing asserts on the text of the `usage` const. That is wrong — `cmd/cans/say_args_test.go:19-20` asserts the `-h`/`--help` error contains a literal usage line. It only mattered once `05_iterate.md` dropped the bare `cans say ` line on the review's advice, which broke both rows; they were retargeted to `"cans say [-o out.wav]"` there. The grep behind the original claim covered `main_test.go` only. `just vhs pipe` is listed. + +### Notes on the gate's own categories + +Unit and integration tests, error handling and recovery are covered by the suites above, all of which are unchanged by this sequence and green. The new surface — a tape, a recipe, a gif, README prose and three `usage` lines — is verified by the frame check, the recipe listing, the greps and the `--help` reads in `02_readme_scripting.md`, since none of it is testable code. \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/04_tape/04_review.md b/festivals/CV0001/003_IMPLEMENT/04_tape/04_review.md new file mode 100644 index 0000000..c810e2e --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/04_tape/04_review.md @@ -0,0 +1,121 @@ +--- +fest_type: gate +fest_id: 04_review.md +fest_name: Code Review +fest_parent: 04_tape +fest_order: 4 +fest_status: completed +fest_autonomy: low +fest_gate_id: review +fest_gate_type: review +fest_managed: true +fest_created: 2026-08-21T05:04:57.532466-06:00 +fest_updated: 2026-08-21T16:28:55.3774-06:00 +fest_tracking: true +fest_version: "1.0" +--- + + +# Gate: Code Review + +Reviewed cold by a second agent: `git diff HEAD` plus untracked `tapes/pipe.tape` and `docs/pipe.gif`. Nothing was executed against the real mouth. + +## 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 + +The boxes cover the Go change — three lines in the `usage` const, clean. The Criticals are in the +README's shell examples: shipped documentation that does not do what its comment says. + +## Findings + +**Critical Issues:** (must fix) + +- `README.md:87` — `cans say "$line" -o "out/$(printf %03d $((++i))).wav"` writes `out/001.wav` on + every iteration. `$((++i))` is evaluated inside the `$( … )` subshell, so `i` never changes in the + parent shell. Verified: `while IFS= read -r line; do echo "out/$(printf %03d $((++i))).wav"; done` + over a 3-line file prints `out/001.wav` three times. Anyone who copies the loop silently overwrites + every wav but the last — the exact failure `-o` exists to prevent. Fix: increment in the parent — + `i=0` before the loop, `i=$((i+1))` inside, `-o "$(printf 'out/%03d.wav' "$i")"`. +- `README.md:91` — `cans say --stream --json < lines.txt | jq -r 'select(.error==null) | .wav' > + manifest.txt` writes a manifest of files that no longer exist. With no `-o`, `playTail` + (`internal/say/say.go:65-70`) plays the temp wav and calls `tts.RemoveTemp` immediately after the + record is emitted (`internal/say/stream.go:122-129`), so every path in `manifest.txt` is deleted + before the next line starts — and every line is played through the speakers, which a build step + does not want. Fix: add the output template — + `cans say --stream --json -o 'out/%03d.wav' < lines.txt | jq -r 'select(.error==null) | .wav' > manifest.txt`. +- `README.md:82-83` — `awk -v RS='' '{print}' chapter.md | cans say --stream -o 'out/%03d.wav'` does + not give "one wav per paragraph". In paragraph mode `$0` keeps the paragraph's embedded newlines, + so `print` emits them and `--stream` speaks one wav per *source* line. Verified: a 2-line paragraph + plus a 1-line paragraph yields 3 stdin lines, so wrapped prose fragments into per-line wavs. Fix: + flatten the record — `awk -v RS='' '{gsub(/\n/," "); print}' chapter.md | cans say --stream -o 'out/%03d.wav'`. + +All three are verbatim from `design-pipes.md:99-110`; the pack carries the same bugs. Fix them here. + +**Suggestions:** (should consider) + +- `cmd/cans/main.go:39` — `exit 75 when another cans holds the mouth and --nowait was set` is now + incomplete: line 35 advertises `--wait 30s`, and an expired `--wait` returns 75 too + (`internal/mouth/lock.go` → `ErrBusy` → `say.ExitBusy`, `internal/say/say.go:99-103`). The README + exit table gets it right ("refused or ran out"); make `usage` match. +- `cmd/cans/main.go:30,35` — `cans say ` is listed twice, bare and with flags; drop the bare one. +- `tapes/pipe.tape:13` — `Set Height 380` leaves ~130 px of empty terminal under the returned prompt + in the last frame. `Set Height 300`, as in `booth.tape:12`, frames the run and trims bytes. +- Carry-forward, already recorded, not a defect: `docs/pipe.gif` was cut at 1-min load 16.3–27.9, so + the visible `ttfa_ms` reads **31377 / 34570 / 35202** and the gif runs 123 s. `01_pipe_tape.md` and + `03_testing.md` both record this and assign the re-cut to `004_REVIEW`. Re-cut with `just vhs pipe` + under load < 16 before the PR; the tape is deterministic and needs no edit. + +## Verified + +- **README**: `## Scripting` at line 71, after `## Commands`; **46 lines** (< 60). Order as specified: + sentence, `docs/pipe.gif` at `width="680"` in the booth gif's centred `

`, mechanism line, three + loops, 7-row flag table, 5-row exit table (`0/1/2/75/130`), stdout/stderr line, the D014 Ctrl-C and + temp-wav lines **verbatim**. No new headings. +- **Tables match the code**: `--play` needs `-o` (`say_args.go:91-93`); `--stream` + argv text is + usage (`:97-99`); `-` + text is usage (`:94-96`); 75 on `--nowait` and on expired `--wait` + (`exit.go:9`, `say.go:100-102`); 130 on interrupt (`say.go:104-107`, `stream.go:135-142`); stream + exits 1 when any line failed (`stream.go:104-106`); `-o` under `--stream` needs exactly one `%d` + (`template.go:12-18`). No flag outside the seven exists. +- **No speed number or margin** anywhere in the new section — only the one-worker invariant. The one + number in `README.md` is the pre-existing 1.6 GB download at line 27. +- **Greps** (`CONTEXT.md §Professional grep`) over `README.md docs/ tapes/`: patterns 1 and 2 print + nothing; `rg -c 'fest.build' README.md` prints `1`; `rg -n '\bwe\b|!' README.md` and + `rg -n 'docs/phrases' …` print nothing. The Festival footer appears exactly once. +- **`usage` const**: `cmd/cans/main.go:25-40`, **16 lines** (< 20), boring; `%03d` is safe because + `usage` only reaches `fmt.Fprint` and `fmt.Errorf("%s", …)`. +- **`tapes/pipe.tape`**: three typed lines exact; real mouth (no `CANS_SAY_BIN`, no `CANS_NOPLAY`, no + fake worker); preamble exports `PATH` from `$PWD/bin` **before** `cd "$(mktemp -d)"`; both + `Wait+Screen` guards present; `Output docs/pipe.gif` only; theme line byte-identical to `booth.tape:17`. +- **`.justfiles/vhs.just`**: `pipe:` added at 35-38 (`just build quick`, `just vhs record`); + `booth:` and `demo:` untouched — the diff is +5 lines, nothing else. +- **`docs/pipe.gif`**: 168 370 B (164 KB, limit 2 MB), 680×380, 123.44 s. Late frame extracted to + scratch and read: three JSON records, `> ls out`, `001.wav 002.wav 003.wav`, prompt returned. +- **Working tree**: exactly the five expected paths. No `lines.txt`, no `out/`. +- **Go**: `gofmt -l .` empty, `go vet ./...` clean, `CANS_NOPLAY=1 go test ./...` green in all ten + packages. Only `usage` changed, so `cans say "x"` is byte-identical to `1e8cea2`. + +**Verdict: iterate.** Fix the three README examples in `05_iterate.md`, then commit. \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/04_tape/05_iterate.md b/festivals/CV0001/003_IMPLEMENT/04_tape/05_iterate.md new file mode 100644 index 0000000..6fb0018 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/04_tape/05_iterate.md @@ -0,0 +1,113 @@ +--- +fest_type: gate +fest_id: 05_iterate.md +fest_name: Review Results and Iterate +fest_parent: 04_tape +fest_order: 5 +fest_status: completed +fest_autonomy: medium +fest_gate_id: iterate +fest_gate_type: iterate +fest_managed: true +fest_created: 2026-08-21T05:04:57.537114-06:00 +fest_updated: 2026-08-21T16:32:54.885927-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] No defects. `03_testing.md` raised two carry-forwards, both already recorded and neither a `04_tape` fix: the gif was cut at 1-min load 16.3–27.9 (re-cut assigned to `004_REVIEW`), and two of three quiet-box one-shots cost ~27 s against a 5 652 ms baseline (the mouth's end-of-speech variance, deferred in `CONTEXT.md`, engine-side). + +### From Code Review + +**Critical** + +- [x] `README.md:87` — `$((++i))` inside `$( … )` never advanced `i`, so every iteration wrote `out/001.wav`. Replaced with a parent-shell counter: `i=0` before the loop, `i=$((i+1))` as the first statement inside, and `-o "$(printf 'out/%03d.wav' "$i")"`. Proof below. +- [x] `README.md:91` — the manifest example had no `-o`, so `playTail` played each line and `tts.RemoveTemp` deleted the wav before the next line, leaving `manifest.txt` full of dead paths. Added `-o 'out/%03d.wav'` so the recorded paths exist and nothing goes to the speakers. +- [x] `README.md:82-83` — `awk -v RS='' '{print}'` kept the paragraph's embedded newlines, so `--stream` spoke one wav per *source* line, not per paragraph. Now `awk -v RS='' '{gsub(/\n/," "); print}'`. Proof below. + +**Suggestions** + +- [x] `cmd/cans/main.go:39` — exit-75 line now reads `exit 75 when another cans holds the mouth and --nowait was set or --wait ran out`, matching `mouth.ErrBusy` → `say.ExitBusy` on both paths and the README's "refused or ran out". +- [x] `cmd/cans/main.go:30` — dropped the bare `cans say speak one line`; the flagged synopsis at line 10 of the const is the only `say` entry now. Const is **14 lines** (limit 20). +- [x] `tapes/pipe.tape:13` — `Set Height 380` → `Set Height 300`, same as `booth.tape:12`. +- [x] Carry-forward acknowledged, not actioned here: `docs/pipe.gif` stays as recorded (680×380, 123 s, `ttfa_ms` 31377/34570/35202). Not re-recorded — another agent holds the real mouth, and `004_REVIEW` owns the re-cut under load < 16. + +## Iteration + +### Not in the review, found while fixing it + +Dropping the bare `cans say ` line broke a test the review had not flagged and that `03_testing.md` wrongly reported as absent: `cmd/cans/say_args_test.go:19-20` asserted the `-h` / `--help` error contains the literal `"cans say "`. + +``` +--- FAIL: TestParseSayErrors/help_short + say_args_test.go:38: error "cans — put the cans on. …" does not contain "cans say " +--- FAIL: TestParseSayErrors/help_long +FAIL github.com/veronica-agent/cans/cmd/cans 2.172s +``` + +Fixed by moving both rows onto the surviving synopsis — `"cans say [-o out.wav]"` — which keeps the test's intent (`-h` prints the say usage) and is the line the review asked to keep. My statement in `03_testing.md` that "nothing asserts on the text of the `usage` const" was wrong: I had grepped `main_test.go` only. The claim in `03_testing.md` should be read as corrected here. + +### Proof for the two shell findings + +Run in scratch against the **verbatim** block now in `README.md`, with a `cans` stub that echoes only its `-o` argument (nothing touched the real mouth): + +Fixture — `chapter.md` is two paragraphs, the first wrapped over two source lines; `lines.txt` is the three tape lines. + +``` +== (a) BROKEN: $((++i)) inside $( ) == +out/001.wav +out/001.wav +out/001.wav + +== (a) FIXED (shipped text) == +out/001.wav +out/002.wav +out/003.wav + +== (c) BROKEN: awk -v RS='' '{print}' == +A paragraph that the author +wrapped over two source lines. +A second paragraph on one line. +stdin lines = 3 # 3 wavs for 2 paragraphs + +== (c) FIXED (shipped text): gsub flattens the record == +A paragraph that the author wrapped over two source lines. +A second paragraph on one line. +stdin lines = 2 # 2 wavs for 2 paragraphs +``` + +Finding (b) needs no shell proof: it is the presence of `-o 'out/%03d.wav'`, which is what stops `playTail` from taking the temp-wav branch (`internal/say/say.go:65-70`). + +### Checks after the changes + +``` +$ gofmt -l . (no output) +$ go vet ./... (no output) +$ CANS_NOPLAY=1 go test -count=1 ./... ok in all ten packages +$ rg -i '' README.md | rg -v '' + (no output) +$ rg -i '' README.md docs/ tapes/ + (no output) +$ rg -c 'fest.build' README.md 1 +$ rg -n '\bwe\b|!' README.md (no output) +$ rg -n 'docs/phrases' README.md (no output) +``` + +`## Scripting` is now **48 lines** (was 46; the parent-shell counter adds two), still under 60. No build, no `bin/cans`, no `just vhs`, no worker — another agent holds the real mouth. + +## 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/04_tape/06_fest_commit.md b/festivals/CV0001/003_IMPLEMENT/04_tape/06_fest_commit.md new file mode 100644 index 0000000..b5aaa9b --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/04_tape/06_fest_commit.md @@ -0,0 +1,97 @@ +--- +fest_type: gate +fest_id: 06_fest_commit.md +fest_name: Fest Commit Changes +fest_parent: 04_tape +fest_order: 6 +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.555796-06:00 +fest_updated: 2026-08-21T16:33:40.573977-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 + +## Result + +``` +$ cd festivals/active/cans-v2-CV0001/003_IMPLEMENT/04_tape +$ gh auth switch -u veronica-agent +✓ Switched active account for github.com to veronica-agent +$ fest commit -m "feat: pipe tape and README scripting section" (+ What/Why body) +Hash 5e8123d +Task FE-CV0001 +Campaign [veronica:ea389d71-FE-CV0001] +Root Commit bbd6979 +``` + +Project commit `5e8123d` on `cans-v2` in `projects/worktrees/cans/cans-v2`, campaign-root commit `bbd6979` for the festival files. Author `Veronica <318153306+veronica-agent@users.noreply.github.com>`. No Co-authored-by, no AI attribution. + +Pushed **from the worktree**, which is what updates PR #14: + +``` +$ git push +To github-veronica-agent:veronica-agent/cans.git + c443de9..5e8123d cans-v2 -> cans-v2 +``` + +Pre-commit: `gofmt -l .` empty, `go vet ./...` clean, `CANS_NOPLAY=1 go test -count=1 ./...` green in all ten packages, no debug code, no temp files (`lines.txt` / `out/` never land in the repo — the tape does `cd "$(mktemp -d)"`), no secrets. Six paths in the commit: `tapes/pipe.tape`, `docs/pipe.gif`, `.justfiles/vhs.just`, `README.md`, `cmd/cans/main.go`, `cmd/cans/say_args_test.go`. Worktree clean afterwards. + +Not built and not run: another agent holds the real mouth, so this gate ran tests only — no `just build quick`, no `bin/cans`, no `just vhs`, no worker. `docs/pipe.gif` is committed as recorded (680×380, 123 s, `ttfa_ms` 31377/34570/35202) while `tapes/pipe.tape` now says `Set Height 300`; the two agree again after `004_REVIEW` re-cuts under load < 16. \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/04_tape/SEQUENCE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/04_tape/SEQUENCE_GOAL.md new file mode 100644 index 0000000..4b0c225 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/04_tape/SEQUENCE_GOAL.md @@ -0,0 +1,21 @@ +--- +fest_type: sequence +fest_id: 04_tape +fest_name: tape +fest_parent: 003_IMPLEMENT +fest_order: 4 +fest_status: completed +fest_created: 2026-08-21T05:04:56.225219-06:00 +fest_updated: 2026-08-21T16:33:40.57447-06:00 +fest_tracking: true +fest_working_dir: projects/worktrees/cans/cans-v2 +--- + + +# Sequence Goal: 04_tape + +**Primary Goal:** The honest demo: a second VHS tape of a script piping lines in and wavs landing in `out/`, and a README scripting section that shows the loops, the flags, and the exit codes — all of it boring, technical, and clean on the professional-surface grep. + +Covers P1-1, P1-2, P1-5. + +Dependencies: 03_stream (the tape uses `--stream -o`). \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/05_snapshot/01_snapshot.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/01_snapshot.md new file mode 100644 index 0000000..99f956a --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/01_snapshot.md @@ -0,0 +1,130 @@ +--- +fest_type: task +fest_id: 01_snapshot.md +fest_name: snapshot +fest_parent: 05_snapshot +fest_order: 1 +fest_status: completed +fest_autonomy: medium +fest_created: 2026-08-21T05:04:57.276998-06:00 +fest_updated: 2026-08-21T17:55:14.197889-06:00 +fest_tracking: true +--- + + +# Task: snapshot + +## Objective + +Copy this festival into the repo as `festivals/CV0001/` — the second readable plan — with D009's exclusions, and prove the copy is clean. + +## Requirements + +- [x] Source: this festival's directory in the campaign (`festivals/active/cans-v2-CV0001` by the time this runs — confirm with `pwd` from `fest next`). Destination: `/festivals/CV0001/`. +- [x] Command (record the exact one you ran): `rsync -a --delete --delete-excluded --exclude CONTEXT.md --exclude '001_INGEST/input_specs' --exclude .fest --exclude .workflow --exclude .festival-checksums.json --exclude '.review-*' / /festivals/CV0001/`. `--exclude '.review-*'` and `--delete-excluded` were added in `05_iterate`: D009 was amended in `004_REVIEW` to exclude the reviewers' hidden scratch notes, and plain `--exclude` will not remove a copy that is already there (rsync protects excluded files at the destination from `--delete`). +- [x] Run the professional-surface grep from `CONTEXT.md §Professional grep` over `festivals/CV0001/` — it must print nothing. If it does, the offending text is fixed in the **campaign festival** (the source), not in the copy, and the rsync is re-run. +- [x] `fest validate festivals/CV0001` passes from the worktree (the snapshot carries `fest.yaml`). +- [x] No README change: the tree is discoverable next to `festivals/CA0001/`, and the footer already points at Festival. Do not add chrome. +- [x] `git check-ignore -v festivals/CV0001/fest.yaml` prints nothing (the tree is not ignored). + +## Implementation + +1. Run the rsync; `find festivals/CV0001 -type f | wc -l` and eyeball the tree against `002_PLAN/plan/STRUCTURE.md`. +2. Run the grep and `fest validate`. +3. `004_REVIEW` re-runs the same rsync once its own statuses are set, so record the exact command in this task file for reuse. + +## Results + +### Step 0 — scrub the source before copying (not in the original task text) + +The public tree must pass grep #2, whose pattern includes the operator's own name, and the festival's +recorded results carried absolute home paths. Scrubbed **in the campaign source**, per the +task's rule that fixes happen in the source and the rsync is re-run. + +```bash +$ cd ~/Dev/AI/veronica-campaign/festivals/active/cans-v2-CV0001 +$ find . -name '*.md' -not -path './.fest/*' -not -path './001_INGEST/input_specs/*' \ + -not -name 'CONTEXT.md' -print0 | xargs -0 sed -i '' 's|/Users/|~|g' +# '' above stands for the literal account name in the absolute home prefix on this Mac; +# the real sed had it spelled out. It is written this way here so this file passes grep #2. +``` + +Home-path hits in public-bound `.md` (everything but `CONTEXT.md` and `001_INGEST/input_specs/`): +**6 matching lines / 3 files before → 0 after** — `002_PLAN/inputs/measurements.md` 4, +`003_IMPLEMENT/03_stream/05_testing.md` 1, `003_IMPLEMENT/04_tape/03_testing.md` 1 (that last +line held two occurrences). Two further lines in the same files matched only the pattern below +and were handled by rewording. + +Grep #2 over the source minus `CONTEXT.md`, `001_INGEST/input_specs/`, `.fest/`: +**13 hits / 7 files before → 7 hits / 6 files after the sed → 0 after rewording.** +Nothing was deleted; each hit was neutralised in place: + +| File | Was | Now | +|------|-----|-----| +| `FESTIVAL_RULES.md` | the remote's display name spelled out, first word matching the pattern | `Display name stays the one the campaign identity lock already sets — do not change it.` | +| `001_INGEST/output_specs/requirements.md` | a `design-surface.md` section heading citation naming the operator | `design-surface.md §Two findings for the operator` | +| `003_IMPLEMENT/03_stream/05_testing.md` | `git describe`'s modified-tree suffix on the `ship.Version` string in the `just build quick` line | `ship.Version=v0.1.0-25-gc736f46-` | +| `003_IMPLEMENT/04_tape/02_readme_scripting.md`, `03_testing.md`, `05_iterate.md` | the two grep patterns quoted verbatim in the recorded commands | `` / `` — the patterns are campaign-private and must not ship | +| `003_IMPLEMENT/04_tape/03_testing.md` | the account-name owner column of an `ls -l docs/pipe.gif` line | `-rw-r--r--@ 1 user staff 168370 …` | + +Every result (counts, exit codes, timings) is unchanged; only the wording is. + +### The rsync (exact command — `004_REVIEW` re-runs this one) + +```bash +$ cd ~/Dev/AI/veronica-campaign +$ rsync -a --delete --delete-excluded \ + --exclude CONTEXT.md --exclude '001_INGEST/input_specs' \ + --exclude .fest --exclude .workflow --exclude .festival-checksums.json \ + --exclude '.review-*' \ + festivals/active/cans-v2-CV0001/ projects/worktrees/cans/cans-v2/festivals/CV0001/ +exit=0 + +# Six exclusions, matching D009 as amended in `004_REVIEW`. `--delete-excluded` is load-bearing, +# not decoration: rsync protects excluded files that already exist at the destination from +# `--delete`, so adding `--exclude '.review-*'` on its own left both scratch files in the copy +# and `fest validate` still at 90. With it, the command is idempotent from any destination state +# and self-corrects a copy made under an older exclusion list. This is the command to re-run +# before the PR. +``` + +### Proof + +``` +$ find festivals/CV0001 -type f | wc -l + 86 +# 88 before `05_iterate`; the two `.review-*` scratch files are now excluded. + +$ find festivals/CV0001 -maxdepth 2 | sort # matches 002_PLAN/plan/STRUCTURE.md +festivals/CV0001/001_INGEST/{GATES.md,output_specs,PHASE_GOAL.md,WORKFLOW.md} # no input_specs +festivals/CV0001/002_PLAN/{decisions,GATES.md,inputs,PHASE_GOAL.md,plan,WORKFLOW.md} +festivals/CV0001/003_IMPLEMENT/{01_out,02_lock,03_stream,04_tape,05_snapshot,GATES.md,PHASE_GOAL.md} +festivals/CV0001/004_REVIEW/{BAR.md,GATES.md,PHASE_GOAL.md} +festivals/CV0001/{fest.yaml,FESTIVAL_GOAL.md,FESTIVAL_OVERVIEW.md,FESTIVAL_RULES.md,gates/implementation,TODO.md} +# CONTEXT.md, 001_INGEST/input_specs/, .fest/, .festival-checksums.json all absent + +$ rg -i '' README.md | rg -v '' +(no output) +$ rg -i '' README.md docs/ tapes/ festivals/CV0001/ +(no output) +$ rg -c 'fest.build' README.md +1 + +$ fest validate festivals/CV0001 +✓ STRUCTURE / ✓ COMPLETENESS / ✓ Task Files / ✓ QUALITY GATES / ✓ Markers +Score 100/100 +# 100 after `--exclude '.review-*'` landed. Before it, the two hidden reviewer scratch files +# (`.review-01_out.md`, `.review-02_lock.md`) shipped and cost 10 points on filename shape. + +$ git check-ignore -v festivals/CV0001/fest.yaml +(no output — exit 1, the tree is not ignored) + +$ git status --short +?? festivals/CV0001/ +``` + +README untouched; no chrome added. + +## Done when + +- [x] `festivals/CV0001/` present, grep empty, `fest validate` green, `git status` shows only the new tree \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/05_snapshot/02_recheck.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/02_recheck.md new file mode 100644 index 0000000..aacd333 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/02_recheck.md @@ -0,0 +1,192 @@ +--- +fest_type: task +fest_id: 02_recheck.md +fest_name: recheck +fest_parent: 05_snapshot +fest_order: 2 +fest_status: completed +fest_autonomy: medium +fest_created: 2026-08-21T05:04:57.277515-06:00 +fest_updated: 2026-08-21T17:58:18.492058-06:00 +fest_tracking: true +--- + + +# Task: recheck + +## Objective + +The whole surface, once more, before review: grep, footer, tests, build hygiene, fresh-home doctor, and `cans` without `fest`. + +## Requirements + +- [x] Professional-surface grep (`CONTEXT.md §Professional grep`) over `README.md`, `docs/`, `tapes/`, `festivals/` — empty. `rg -c 'fest.build' README.md` → `1`. +- [x] `gofmt -l .` empty; `go vet ./...`; `CANS_NOPLAY=1 go test ./...` green; `git diff origin/main -- go.mod go.sum` empty; every `.go` file < 500 lines (`wc -l $(git ls-files '*.go') | sort -n | tail -3`). +- [x] Fresh home: `tmp=$(mktemp -d); cp bin/cans $tmp/; cd $tmp; CANS_HOME=$tmp/home CANS_WORKER_BIN=$HOME/.cans/native/bin/qwen3-tts-worker CANS_WORKER_MODELS=$HOME/.cans/native/models ./cans doctor` → all ok; then `./cans say -o $tmp/take.wav "Put the cans on."` → prints the path, file has a valid header (`ffprobe` or `afplay`); `ls $tmp/home` shows `mouth.lock` and `shipped/`, nothing else. +- [x] `cans` without `fest`: `PATH=/usr/bin:/bin ./bin/cans version` works (no runtime dependency on the `fest` binary — trivially true, but recorded). +- [x] `just --list` still lists `vhs pipe`; `just dist check` passes (`goreleaser check`). + +## Implementation + +Run each block, paste the output into this task file under **Results**, fix anything red in place (small fixes only — anything larger goes back to the owning sequence as an iterate finding). + +## Results + +All from the `cans-v2` worktree. Box was quiet for every real-mouth step: load +`7.39 8.72 8.36` at 17:56 (all < 16) and `pgrep -fl 'cans/native/bin/qwen3-tts-worker'` empty +before each one; steps run one at a time. + +### 1. Professional-surface grep + +``` +$ rg -i '' README.md | rg -v '' +(no output — exit 1) + +$ rg -i '' README.md docs/ tapes/ festivals/ +… 24 hits, 11 files, EVERY ONE under festivals/CA0001/ … +exit=0 + +$ rg -i '' festivals/CV0001/ +(no output — exit 1) # this festival's snapshot is clean + +$ rg -i -l '' README.md docs/ tapes/ festivals/ | sed 's|/.*||' | sort | uniq -c + 11 festivals # zero in README.md, docs/, tapes/ + +$ rg -c 'fest.build' README.md +1 +``` + +**`README.md`, `docs/`, `tapes/` and `festivals/CV0001/` are clean.** The only hits in the whole +public tree are pre-existing, committed content in **`festivals/CA0001/`** — the previous +festival's snapshot — and they are **not this sequence's to fix** (see the finding below). + +### 2. Build hygiene + +``` +$ gofmt -l . +(no output — exit 0) + +$ go vet ./... +(no output — exit 0) + +$ CANS_NOPLAY=1 go test ./... +ok github.com/veronica-agent/cans/cmd/cans 0.610s +ok github.com/veronica-agent/cans/internal/audio (cached) +ok github.com/veronica-agent/cans/internal/booth (cached) +ok github.com/veronica-agent/cans/internal/doctor (cached) +ok github.com/veronica-agent/cans/internal/keep (cached) +ok github.com/veronica-agent/cans/internal/mouth (cached) +ok github.com/veronica-agent/cans/internal/play (cached) +ok github.com/veronica-agent/cans/internal/say (cached) +ok github.com/veronica-agent/cans/internal/ship (cached) +ok github.com/veronica-agent/cans/internal/tts (cached) +exit=0 # all ten packages + +$ git diff origin/main -- go.mod go.sum +(empty — no new dependencies) + +$ wc -l $(git ls-files '*.go') | sort -n | tail -3 + 352 internal/say/stream_test.go + 418 internal/say/say_test.go + 5360 total +# five largest single files: 201 internal/tts/worker.go, 217 internal/ship/ship_test.go, +# 236 internal/tts/synth_test.go, 352 internal/say/stream_test.go, 418 internal/say/say_test.go +# every file < 500. worker.go is 201, still 5 over the 196 the rules pin — a reviewer's note +# from 03_stream, unchanged by this sequence. +``` + +### 3. Fresh home (real mouth) + +Built first, then copied out of the worktree so nothing in `~/.cans` could be picked up +implicitly. `$tmp` is a scratch dir; `CANS_HOME` is `$tmp/home`, which does not exist at the +start. + +``` +$ just build quick +go build -trimpath -ldflags "-s -w -X …/internal/ship.Version=v0.1.0-27-g5e8123d" -o bin/cans ./cmd/cans +# clean tag, no modified-tree suffix + +$ tmp=/freshhome; rm -rf $tmp; mkdir -p $tmp; cp bin/cans $tmp/; cd $tmp +$ CANS_HOME=$tmp/home \ + CANS_WORKER_BIN=$HOME/.cans/native/bin/qwen3-tts-worker \ + CANS_WORKER_MODELS=$HOME/.cans/native/models ./cans doctor + machine ok darwin/arm64 + worker ok ~/.cans/native/bin/qwen3-tts-worker + payload ok /freshhome/home/shipped + throat ok /freshhome/home/shipped/voices/veronica/ref.wav + play ok /usr/bin/afplay +put the cans on. +exit=0 # five rows, all ok; payload and throat unpacked into the new home + +$ ./cans say -o $tmp/take.wav "Put the cans on." # same env as above +/freshhome/take.wav +exit=0 wall=14s # prints the path, nothing else on stdout + +$ ffprobe -v error -show_entries format=format_name,duration,size \ + -show_entries stream=codec_name,sample_rate,channels -of default=nw=1 take.wav +codec_name=pcm_s16le +sample_rate=24000 +channels=1 +format_name=wav +duration=1.087542 +size=52246 +exit=0 # valid header, real audio — not the 1 484-byte near-silent fault + +$ ls -A $CANS_HOME +mouth.lock +shipped + # exactly the two, nothing else +``` + +### 4. `cans` without `fest` + +``` +$ env -i PATH=/usr/bin:/bin HOME=$HOME ./bin/cans version +cans v0.1.0-27-g5e8123d +exit=0 + +$ env -i PATH=/usr/bin:/bin sh -c 'command -v fest || echo "fest not on PATH"' +fest not on PATH +``` + +### 5. `just` + +``` +$ just --list | grep -i 'vhs\|pipe' + vhs ... # Record the booth with VHS +$ just --list vhs +Available recipes: + booth + demo # the worker (cans doctor puts both in ~/.cans/native/bin). + doctor + pipe # Real mouth, no audio: a script pipes lines in, wavs land in out/. + record tape + # `just vhs pipe` is still there + +$ just dist check +cd … && goreleaser check + • checking path=.goreleaser.yaml + • 1 configuration file(s) validated + • thanks for using GoReleaser! +exit=0 +``` + +### Finding for the operator — `festivals/CA0001/` fails the phrase lock in a public repo + +`veronica-agent/cans` is **public** (`gh repo view --json visibility` → `PUBLIC`). The committed +`festivals/CA0001/` snapshot still carries 24 phrase-lock hits across 11 files, 10 of them the +tracked `festivals/CA0001/001_INGEST/input_specs/` explore pack — the exact directory **D009 +excludes** from this festival's snapshot. Shapes: the account display name spelled out; a market +table rejecting a chat-partner product framing; the retired v1 `cans say` example line; and, in +`explore-recommend.md` / `seed.md`, an **earlier form of the professional-grep pattern itself**, +which `CONTEXT.md` marks campaign-private. + +`e5a5197` ("strip private campaign guts from the public festival tree") already scrubbed that tree +once and deliberately left these, so some are intentional public facts rather than leaks. Either +way it is a decision about **another festival's shipped output**, larger than this task's +"small fixes only", and outside the `cans-v2` diff. Recorded here and raised to `004_REVIEW`; +nothing in `CA0001` was touched. + +## Done when + +- [x] Every block above is green and its output is recorded here \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/05_snapshot/03_testing.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/03_testing.md new file mode 100644 index 0000000..3db8bb9 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/03_testing.md @@ -0,0 +1,157 @@ +--- +fest_type: gate +fest_id: 03_testing.md +fest_name: Testing and Verification +fest_parent: 05_snapshot +fest_order: 3 +fest_status: completed +fest_autonomy: medium +fest_gate_id: testing +fest_gate_type: testing +fest_managed: true +fest_created: 2026-08-21T05:04:57.560643-06:00 +fest_updated: 2026-08-21T18:00:17.972843-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 + +`05_snapshot` adds **no Go code** — it is the `festivals/CV0001/` snapshot plus the surface +recheck. So the code categories above are the full-suite regression bar: the whole test suite on +the fake worker, plus the one-shot on the real mouth to prove `cans say` still behaves as at +`1e8cea2`. Nothing under `internal/` or `cmd/` changed in this sequence. + +Box quiet for the real-mouth step: load `6.76 8.13 8.17` at 17:58 (all < 16) and +`pgrep -fl 'cans/native/bin/qwen3-tts-worker'` empty before it. Run alone. + +``` +$ gofmt -l . +(no output — exit 0) + +$ go vet ./... +(no output — exit 0) + +$ CANS_NOPLAY=1 go test -count=1 ./... +ok github.com/veronica-agent/cans/cmd/cans 1.940s +ok github.com/veronica-agent/cans/internal/audio 0.344s +ok github.com/veronica-agent/cans/internal/booth 0.679s +ok github.com/veronica-agent/cans/internal/doctor 0.519s +ok github.com/veronica-agent/cans/internal/keep 0.989s +ok github.com/veronica-agent/cans/internal/mouth 1.398s +ok github.com/veronica-agent/cans/internal/play 1.139s +ok github.com/veronica-agent/cans/internal/say 7.092s +ok github.com/veronica-agent/cans/internal/ship 1.507s +ok github.com/veronica-agent/cans/internal/tts 3.570s +exit=0 +# -count=1, no cache: all ten packages green on the fake worker, no real mouth involved. +# Error handling, invalid input and recovery are covered there — say/say_test.go 418 lines and +# say/stream_test.go 352 lines are error-cases-first tables from 01_out and 03_stream. + +$ 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 + 200 cmd/cans/main_test.go + 201 internal/tts/worker.go + 352 internal/say/stream_test.go + 418 internal/say/say_test.go + 3147 total +# every changed file < 500. internal/tts/worker.go is 201 against the 196 the rules pin +# ("add files, do not grow it") — carried over from 03_stream, not touched here. + +$ just build quick +go build -trimpath -ldflags "-s -w -X …/internal/ship.Version=v0.1.0-27-g5e8123d" -o bin/cans ./cmd/cans +# no warnings, clean version string + +$ ./bin/cans say "Put the cans on." ; echo "exit=$?" +ttfa_ms=5669 +exit=0 +# wall 14s; played through the speakers; one-shot output shape unchanged (bare ttfa_ms=N, +# no path, no JSON). Against the 5 839 ms / 13.1 s baseline and the 5 652 ms recorded in +# 03_stream/05_testing this is the same run — v1 behaviour is intact. + +$ pgrep -fl 'cans/native/bin/qwen3-tts-worker' # after the run +(no output — exit 1) # worker gone, lock released by the kernel + +$ ls -ld /var/folders/…/T/cans-say.* +-rw-------@ 1 user staff 355 Aug 19 22:09 …/T/cans-say.F26P2oxvoX +# The run's own temp wav was removed: the only cans-say.* file in TMPDIR is a 355-byte +# leftover dated Aug 19 22:09, present before this run and unchanged by it — debris from +# before this branch existed, not a regression. No new temp file appeared. +``` + +### Sequence checks from the task files + +Both re-verified after the last edit to the task documents: + +``` +$ rg -i '' README.md | rg -v '' +(no output) +$ rg -i '' README.md docs/ tapes/ festivals/CV0001/ +(no output) +$ rg -c 'fest.build' README.md +1 +$ fest validate festivals/CV0001 +VALIDATION PASSED WITH WARNINGS — 90/100 +# two warnings, both the cold reviewer's hidden .review-*.md scratch files carried by the rsync +$ git check-ignore -v festivals/CV0001/fest.yaml +(no output — not ignored) +$ find festivals/CV0001 -type f | wc -l + 88 + +# NOTE, added in 05_iterate: the review gate made those two warnings a Critical (D009 was +# amended to exclude `.review-*`). After the fix the same two commands read 100/100 and 86 +# files. Everything else in this gate is unchanged — no Go code moved. +$ git status --short +?? festivals/CV0001/ +``` + +`02_recheck.md` carries the fresh-home doctor / `say -o` / `ffprobe` / `ls $CANS_HOME` output, +`just dist check`, `just vhs pipe`, and the **`festivals/CA0001/` phrase-lock finding** raised to +`004_REVIEW` (pre-existing committed content in another festival's snapshot, untouched here). + +**No regressions. Gate green.** \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/05_snapshot/04_review.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/04_review.md new file mode 100644 index 0000000..5f554c8 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/04_review.md @@ -0,0 +1,121 @@ +--- +fest_type: gate +fest_id: 04_review.md +fest_name: Code Review +fest_parent: 05_snapshot +fest_order: 4 +fest_status: completed +fest_autonomy: low +fest_gate_id: review +fest_gate_type: review +fest_managed: true +fest_created: 2026-08-21T05:04:57.561891-06:00 +fest_updated: 2026-08-21T18:09:57.506987-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 — no Go code here; the tree itself reads cleanly +- [x] Functions are focused (single responsibility) — n/a +- [x] Naming is clear and consistent +- [x] No unnecessary complexity or duplication + +### Standards Compliance + +- [x] Linting passes without warnings — `gofmt -l .` re-run silent +- [x] Formatting is consistent +- [x] Project conventions are followed — one exception, see Critical 2 + +### Error Handling & Security + +- [x] Errors are handled appropriately — n/a +- [x] No secrets in code — leak greps clean; only the public `veronica-agent` commit identity appears +- [x] Input validation present where needed — n/a +- [x] No obvious security issues + +### Alignment + +- [x] Changes align with sequence goal +- [x] No scope creep beyond what was requested — the source scrub was in scope and recorded + +## Findings + +Reviewed cold. `05_snapshot` adds **no Go code** — the surface is the untracked +`festivals/CV0001/` tree plus the recorded recheck. + +**Green, verified independently:** all five exclusions absent; 88 files; the three greps print +nothing / nothing / `1`; grep 2 over the snapshot **source** minus the excluded paths is also +empty, so the copy is not cleaner than its source; no operator account name anywhere; the 14 +links in `002_PLAN/decisions/INDEX.md` all resolve; `fest validate` 90/100 with exactly the two +expected warnings; `git status --short` is `?? festivals/CV0001/` alone; `go.mod`/`go.sum` undiffed; +HEAD still `5e8123d`. Re-ran the safe recheck blocks — `gofmt -l .` silent, `just --list vhs` +still carries `pipe`, largest `.go` 236 / 352 / 418 — all match the pasted output. `02_recheck.md` / `03_testing.md` are complete for every required block and +honest, unflattering entries included. Overview, plan, measurements and task files read as a plan +a stranger could follow with `fest` uninstalled. + +**Critical Issues:** (must fix) + +- `002_PLAN/decisions/D009_public_snapshot.md:3` — the shipped copy is **stale**. `diff -r` + source-vs-snapshot with the exclusions applied is empty except this one file: the source D009 + was amended at 18:02 (adding `.workflow/` and `.review-*`), the last rsync ran at 18:00, so + the copy still states the old four-item list — the tree misstates the policy that produced it, + on the document a stranger reads to understand the scrub. Fix: re-run the rsync (idempotent). +- `003_IMPLEMENT/01_out/.review-01_out.md`, `003_IMPLEMENT/02_lock/.review-02_lock.md` — two + hidden reviewer scratch files ship. Amended D009 excludes `.review-*`, so this is now a D009 + violation, and they are the only reason `fest validate` scores 90 and not 100. Read both in + full: ordinary code notes, no phrase-lock hit, no path leak — a policy break, not a leak. + Fix: add `--exclude '.review-*'` to the rsync. +- `003_IMPLEMENT/05_snapshot/01_snapshot.md:64` — the rsync recorded here is explicitly the + command `004_REVIEW` re-runs before the PR, and it has no `--exclude '.review-*'`; left as is, + both findings above reappear after the pre-PR re-sync. Fix: update it, then re-run it. + +**Suggestions:** (should consider) + +- `FESTIVAL_OVERVIEW.md:57`, `TODO.md:51` (28 files in all) — the tree cites `CONTEXT.md` ~50 + times and the `001_INGEST/input_specs/` design pack ~90 times; D009 excludes both, so those + pointers dangle for a stranger. No substance is lost — D001–D014 are all present under + `002_PLAN/decisions/`. Fix: one line in `TODO.md` saying both are campaign-private and the + decisions live in `002_PLAN/decisions/`, rather than 140 edits. +- `002_PLAN/plan/IMPLEMENTATION_PLAN.md:59` — the `05_snapshot` row restates the pre-amendment + four-exclusion rsync as "per D009 exclusions", contradicting D009 two directories away. It is + a plan of record, so rewriting is a judgement call. Fix: cite D009 instead of restating it. +- `002_PLAN/inputs/measurements.md:9,28` — the baseline probe (`scratchpad/measure/main.go`) and + its build worktree (`.../cans/drop-sidecar`) are not in the public repo, so those two rows are + the only numbers a stranger cannot reproduce (§Stream is). Fix: note the probe was throwaway. +- `fest.yaml:13,17,20,61` — carries the campaign-side `status_history` paths and names the + excluded `.festival-checksums.json`. Machine metadata, validate passes; no action needed. +- **For `004_REVIEW` / the operator — `festivals/CA0001/`.** Confirmed the implementer's finding, + count tightened: grep 2 over `festivals/CA0001/` returns **25 matching lines across 11 files** + (recorded as 24/11), of which **7 files / 21 lines** are the tracked `001_INGEST/input_specs/` + pack — the exact directory D009 excludes here; the rest are `FESTIVAL_OVERVIEW.md`, + `FESTIVAL_RULES.md`, `output_specs/constraints.md`, `003_IMPLEMENT/06_snapshot/01_readme.md`. + Committed, public, predating this branch and outside the `cans-v2` diff — rightly untouched and + not a blocker here. Fix: an operator call before the PR — scrub, or accept on record. +- `internal/tts/worker.go` is 201 lines against the 196 `FESTIVAL_RULES.md` pins — inherited from + `03_stream`, committed, untouched here. `004_REVIEW` should accept it or amend the rule. + +## 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: + +Points 1–7 and 9 are **n/a**: no Go, flag, or lock changes here; `go.mod`/`go.sum` undiffed. +Point 8 is the whole sequence and is green; point 6's length clause is inherited (last Suggestion). + +- `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/05_snapshot/05_iterate.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/05_iterate.md new file mode 100644 index 0000000..34b8727 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/05_iterate.md @@ -0,0 +1,104 @@ +--- +fest_type: gate +fest_id: 05_iterate.md +fest_name: Review Results and Iterate +fest_parent: 05_snapshot +fest_order: 5 +fest_status: completed +fest_autonomy: medium +fest_gate_id: iterate +fest_gate_type: iterate +fest_managed: true +fest_created: 2026-08-21T05:04:57.562955-06:00 +fest_updated: 2026-08-21T18:13:58.775332-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] **No defects.** `03_testing` was green in every block — ten packages on the fake worker, `gofmt`/`go vet` silent, `go.mod`/`go.sum` undiffed, the real-mouth one-shot at `ttfa_ms=5669` / exit 0. Nothing to iterate on. +- [x] **Stale temp file** `/var/folders/…/T/cans-say.F26P2oxvoX`, 355 B, dated Aug 19 — noted in the gate as pre-dating this branch. Confirmed not ours (the run removed its own temp wav); left alone, no fix warranted. + +### From Code Review + +All three Criticals were **one root cause**: the recorded rsync did not implement D009 as amended at 18:02, and the copy on disk was made before the amendment. + +- [x] **Critical 1 — `D009_public_snapshot.md` stale in the copy.** The source was amended at 18:02, the last rsync ran at 18:00, so the shipped tree stated the old four-item exclusion list — the document a stranger reads to understand the scrub, misstating the policy that produced the tree. Fixed by re-running the rsync; `diff -r` source→copy with the exclusions applied is now empty, so nothing else was stale either. +- [x] **Critical 2 — two hidden reviewer scratch files shipped.** `003_IMPLEMENT/01_out/.review-01_out.md` and `02_lock/.review-02_lock.md` are excluded by amended D009 and were the only reason `fest validate` scored 90. Fixed by `--exclude '.review-*'` **plus `--delete-excluded`** — the exclusion alone was not enough, because rsync protects an already-present excluded file at the destination from `--delete`; the first re-sync left both files in place and the score at 90. Now 100/100, and the copy is 86 files rather than 88. +- [x] **Critical 3 — `01_snapshot.md` recorded the wrong command.** That file is explicitly the command `004_REVIEW` re-runs before the PR, so leaving it would have reintroduced Criticals 1 and 2 at the pre-PR re-sync. Both copies of the command in the file (the requirement line and the recorded block) now carry the six exclusions and `--delete-excluded`, with a note on why the flag is load-bearing. The command is now idempotent from any destination state and self-corrects a copy made under an older exclusion list. + +Suggestions taken: + +- [x] **Dangling `CONTEXT.md` / `input_specs/` pointers** (~50 and ~90 citations across 28 files). Took the reviewer's own recommendation — one line near the top of `TODO.md` naming both as campaign-private and pointing at `002_PLAN/decisions/`, rather than 140 edits. No substance is lost: D001–D014 are all in the tree. +- [x] **`IMPLEMENTATION_PLAN.md` §05_snapshot row 01** restated the pre-amendment four-exclusion rsync as "per D009 exclusions", contradicting D009 two directories away. Now cites `002_PLAN/decisions/D009_public_snapshot.md` instead of restating it, so it cannot drift again. +- [x] **`measurements.md` §Baseline** — the probe (`scratchpad/measure/main.go`) and the `drop-sidecar` build worktree are campaign-side and not reproducible from this repo. Both rows now say so, and point at §Stream as the numbers a stranger can reproduce with the shipped binary. + +Suggestions deliberately **not** taken (recorded, not silently dropped): + +- [x] **`festivals/CA0001/` phrase-lock hits** — reviewer tightened my count to 25 lines / 11 files, 21 lines / 7 files of them the tracked `001_INGEST/input_specs/` pack. Committed, public, predating this branch, outside the `cans-v2` diff, and a call on another festival's shipped output. Still untouched; it is `004_REVIEW`'s / the operator's decision to scrub or accept on record. +- [x] **`internal/tts/worker.go` at 201 lines vs the 196 `FESTIVAL_RULES.md` pins** — inherited from `03_stream`, committed, no Go file was touched in this sequence. For `004_REVIEW` to accept or amend the rule. +- [x] **`fest.yaml` campaign-side `status_history` paths** — reviewer marked it machine metadata needing no action; `fest validate` passes. Agreed, no change. + +## Iteration + +For each finding: + +1. Fix the issue +2. Re-run affected tests +3. Verify linting passes + +### Verification after the fix + +``` +$ rg -i '' +(no output) # writing Results re-introduces hits; re-grepped after every edit + +$ rsync -a --delete --delete-excluded \ + --exclude CONTEXT.md --exclude '001_INGEST/input_specs' \ + --exclude .fest --exclude .workflow --exclude .festival-checksums.json \ + --exclude '.review-*' \ + festivals/active/cans-v2-CV0001/ projects/worktrees/cans/cans-v2/festivals/CV0001/ +exit=0 + +$ diff -r -x CONTEXT.md -x input_specs -x .fest -x .workflow \ + -x .festival-checksums.json -x '.review-*' +exit=0 # identical — Critical 1 cleared, nothing else was stale + +$ find festivals/CV0001 \( -name '.review-*' -o -name '.workflow' \ + -o -name '.festival-checksums.json' -o -name CONTEXT.md -o -name input_specs \) +(no output) # Critical 2 cleared + +$ fest validate festivals/CV0001 +Score 100/100 +VALIDATION PASSED # was 90/100 + +$ find festivals/CV0001 -type f | wc -l + 86 # was 88 + +$ rg -i '' README.md | rg -v '' (no output) +$ rg -i '' README.md docs/ tapes/ festivals/CV0001/ (no output) +$ rg -c 'fest.build' README.md 1 + +$ git check-ignore -v festivals/CV0001/fest.yaml (no output — not ignored) +$ git status --short +?? festivals/CV0001/ # the new tree and nothing else +``` + +No Go code was touched in this sequence, so tests and linting are unaffected by the iteration — +`03_testing`'s green suite still stands. Re-confirmed cheaply: `gofmt -l .` silent. + +## Definition of Done + +- [x] All critical findings fixed — all three, one root cause, verified above +- [x] All tests pass after changes — no Go touched; `03_testing`'s ten-package suite stands, `gofmt -l .` re-run silent +- [x] Linting passes +- [x] Code review findings addressed — 3 Criticals fixed, 3 Suggestions taken, 3 declined on the record with reasons +- [x] Ready to commit \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/05_snapshot/06_fest_commit.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/06_fest_commit.md new file mode 100644 index 0000000..bf4e40c --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/06_fest_commit.md @@ -0,0 +1,68 @@ +--- +fest_autonomy: high +fest_created: 2026-08-21T05:04:57.567658-06:00 +fest_gate_id: fest-commit +fest_gate_type: commit +fest_id: 06_fest_commit.md +fest_managed: true +fest_name: Fest Commit Changes +fest_order: 6 +fest_parent: 05_snapshot +fest_status: pending +fest_tracking: true +fest_type: gate +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 — `03_testing`'s ten-package suite; no Go touched in this sequence or in `05_iterate` +- [x] Linting is clean — `gofmt -l .` silent, re-run after the iteration +- [x] No debug code or temporary files — the tree is 86 files, all `.md` plus `fest.yaml`; `.fest/`, `.workflow/`, `.festival-checksums.json` and the `.review-*` scratch notes are all excluded by D009 +- [x] No secrets or credentials in staged changes — leak greps clean (nothing / nothing / `1`); home paths scrubbed to `~`; only the public `veronica-agent` commit identity appears + +## 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/05_snapshot/SEQUENCE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/SEQUENCE_GOAL.md new file mode 100644 index 0000000..3290126 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/SEQUENCE_GOAL.md @@ -0,0 +1,19 @@ +--- +fest_type: sequence +fest_id: 05_snapshot +fest_name: snapshot +fest_parent: 003_IMPLEMENT +fest_order: 5 +fest_status: pending +fest_created: 2026-08-21T05:04:56.253675-06:00 +fest_tracking: true +fest_working_dir: projects/worktrees/cans/cans-v2 +--- + +# Sequence Goal: 05_snapshot + +**Primary Goal:** This festival becomes the second readable plan in the public repo (`festivals/CV0001/`, per D009's exclusions), and the whole surface is rechecked: grep, footer, tests, fresh-home doctor, `cans` without `fest`. + +Covers P1-3, P1-5. Decision D009. + +Dependencies: 04_tape. Last, so it captures the finished tree. `004_REVIEW` re-syncs the snapshot once more before the PR. diff --git a/festivals/CV0001/003_IMPLEMENT/GATES.md b/festivals/CV0001/003_IMPLEMENT/GATES.md new file mode 100644 index 0000000..5c8e9ef --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/GATES.md @@ -0,0 +1,73 @@ +--- +fest_type: phase_gate +fest_id: 003_IMPLEMENT-GATE +fest_parent: 003_IMPLEMENT +--- + +# Implementation Phase Gate + +This gate verifies the implementation phase achieved its goal and produced working deliverables. + +--- + +## Step 1: PHASE GOAL — Verify Goal Achievement + +**Question:** Does the implementation satisfy the PHASE_GOAL.md objectives? Were all required deliverables produced? + +**Actions:** +1. Re-read PHASE_GOAL.md and compare stated objectives against actual results +2. Verify each required deliverable exists and is functional +3. Confirm the implementation solves the problem the phase was created for + +**Checkpoint:** APPROVAL REQUIRED — Confirm phase goal is met + +--- + +## Step 2: SEQUENCE OUTCOMES — Verify Sequence Goals Met + +**Question:** Did each sequence achieve its stated goal? Do actual results match each SEQUENCE_GOAL? + +**Actions:** +1. Compare each sequence's output against its SEQUENCE_GOAL.md +2. Verify all sequence-level quality gates passed +3. Confirm no sequences were skipped or left incomplete + +**Checkpoint:** APPROVAL REQUIRED — Confirm all sequence goals achieved + +--- + +## Step 3: QUALITY — Verify Build and Test Health + +**Question:** Does the project build cleanly and do all tests pass with no regressions? + +**Actions:** +1. Run the project build command and confirm no errors +2. Run the full test suite and confirm all tests pass +3. Check for regressions introduced during implementation +4. Verify no new warnings or linting issues + +**Checkpoint:** APPROVAL REQUIRED — Confirm build and tests are green + +--- + +## Step 4: COMPLETENESS — Verify Nothing Left Behind + +**Question:** Are all tasks done, all gates passed, and all review feedback addressed? + +**Actions:** +1. Confirm every task is marked complete +2. Verify code review findings were incorporated or explicitly deferred with justification +3. Check that iterate gates resolved all flagged issues + +**Checkpoint:** APPROVAL REQUIRED — Confirm completeness + +--- + +## Gate State Tracking + +| Step | Status | Notes | +|------|--------|-------| +| 1. PHASE GOAL | [ ] pending | Goal achievement verified | +| 2. SEQUENCE OUTCOMES | [ ] pending | All sequence goals met | +| 3. QUALITY | [ ] pending | Build and tests pass | +| 4. COMPLETENESS | [ ] pending | All tasks and gates done | diff --git a/festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md new file mode 100644 index 0000000..2c86823 --- /dev/null +++ b/festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md @@ -0,0 +1,85 @@ +--- +fest_type: phase +fest_id: 003_IMPLEMENT +fest_name: IMPLEMENT +fest_parent: cans-v2-CV0001 +fest_order: 3 +fest_status: pending +fest_created: 2026-08-21T05:03:56.848822-06:00 +fest_phase_type: implementation +fest_tracking: true +--- + +# Phase Goal: 003_IMPLEMENT + +**Phase:** 003_IMPLEMENT | **Status:** Pending | **Type:** Implementation + +## Phase Objective + +**Primary Goal:** Build the slice on branch cans-v2: 01_out, 02_lock, 03_stream, 04_tape, 05_snapshot — in that order, through fest commit. + +**Context:** Requirements are `001_INGEST/output_specs/requirements.md`; decisions are `002_PLAN/decisions/`; the per-sequence contract is `002_PLAN/plan/IMPLEMENTATION_PLAN.md`. Everything lands on branch `cans-v2` in `projects/worktrees/cans/cans-v2` through `fest commit`, and `004_REVIEW` opens the PR. + +## Required Outcomes + +Deliverables this phase must produce: + +- [ ] `cans say` takes `-o`, stdin, `--json`, `--play`, `--nowait`, `--wait`, `--stream` — and nothing else; `cans say "x"` is unchanged +- [ ] exactly one `qwen3-tts-worker` resident under any loop, `xargs -P`, or second terminal; the booth holds the lock +- [ ] `--stream` renders N lines over one `Session`; Ctrl-C keeps finished wavs and exits 130 +- [ ] `docs/pipe.gif` from `just vhs pipe`; README scripting section; professional-surface grep clean +- [ ] `festivals/CV0001/` snapshot in the repo; measurements recorded with their commands + + + +## Quality Standards + +Quality criteria for all work in this phase: + +- [ ] `gofmt -l .` empty, `go vet ./...` clean, `CANS_NOPLAY=1 go test ./...` green on the fake worker +- [ ] no new `go.mod` requires; files < 500 lines; functions < 50 lines +- [ ] `context.Context` first on I/O; stdout is data, stderr is prose +- [ ] reviewer is a different agent than the implementer + + + +## Sequence Alignment + +| Sequence | Goal | Key Deliverable | +|----------|------|-----------------| +| 01_out | `-o`, stdin, `--json`, exit codes; `internal/say` | `cans say -o take.wav "x"` | +| 02_lock | one mouth at a time | `internal/mouth`, `tts.OpenWith`, exit 75 | +| 03_stream | one `Session`, N lines | `cans say --stream -o 'out/%03d.wav'`, measurements | +| 04_tape | the honest demo | `docs/pipe.gif`, README §Scripting | +| 05_snapshot | the public plan | `festivals/CV0001/`, recheck | + + + +## Pre-Phase Checklist + +Before starting implementation: + +- [ ] Planning phase complete +- [ ] Architecture/design decisions documented +- [ ] Dependencies resolved +- [ ] Development environment ready + +## Phase Progress + +### Sequence Completion + +- [ ] 01_out +- [ ] 02_lock +- [ ] 03_stream +- [ ] 04_tape +- [ ] 05_snapshot + + + +## Notes + +Order is load-bearing: `01 → 02 → 03 → 04 → 05`. Do not start `03_stream` before `02_lock` is committed. Measurements need the real mouth and an idle machine (`uptime` 1-min load below the core count) — if the box is loaded, record that and come back. + +--- + +*Implementation phases use numbered sequences. Create sequences with `fest create sequence`.* \ No newline at end of file diff --git a/festivals/CV0001/004_REVIEW/BAR.md b/festivals/CV0001/004_REVIEW/BAR.md new file mode 100644 index 0000000..d84793b --- /dev/null +++ b/festivals/CV0001/004_REVIEW/BAR.md @@ -0,0 +1,37 @@ +# BAR — commands and recorded output + +Fill each block with the exact command and its output when `004_REVIEW` runs. Real mouth, idle machine (`uptime` first). Do not paraphrase output. + +## 0. Preconditions + +```bash +uptime # 1-min load must be below 16 +pgrep -fl qwen3-tts-worker # must print nothing +just build quick +``` + +## 1. Stream — one worker, one load + +## 2. xargs -P 8 — one worker, no pageouts + +## 3. Ctrl-C mid-stream → 130, wavs kept, no orphan + +## 4. kill -9 → next run unblocked + +## 5. Booth holds the lock + +## 6. One-shot unchanged; tests green on the fake worker + +## 7. Margin (from measurements.md) + +## 8. Professional-surface grep + one footer + +## 9. Fresh CANS_HOME doctor + +## 10. Identity + +## 11. fest validate (festival + snapshot) + +## 12. Snapshot re-sync + +## 13. PR diff --git a/festivals/CV0001/004_REVIEW/GATES.md b/festivals/CV0001/004_REVIEW/GATES.md new file mode 100644 index 0000000..2ef0128 --- /dev/null +++ b/festivals/CV0001/004_REVIEW/GATES.md @@ -0,0 +1,58 @@ +--- +fest_type: phase_gate +fest_id: 004_REVIEW-GATE +fest_parent: 004_REVIEW +--- + +# Review Phase Gate + +This gate verifies the review phase achieved its goal and produced incorporated feedback. + +--- + +## Step 1: PHASE GOAL — Verify Goal Achievement + +**Question:** Did the review achieve its stated objective? Were the right things reviewed against the right criteria? + +**Actions:** +1. Re-read PHASE_GOAL.md and compare stated review objectives against actual review work +2. Verify the review criteria were appropriate for the deliverables examined +3. Confirm the review answered the questions the phase was created to answer + +**Checkpoint:** APPROVAL REQUIRED — Confirm review goal is met + +--- + +## Step 2: COVERAGE — Verify All Items Reviewed + +**Question:** Were all items in scope examined? + +**Actions:** +1. Confirm every item in scope was reviewed +2. Verify no items were skipped or deferred without justification +3. Check that review criteria were applied consistently + +**Checkpoint:** APPROVAL REQUIRED — Confirm all items reviewed + +--- + +## Step 3: INCORPORATION — Verify Feedback Applied + +**Question:** Was feedback applied to relevant deliverables? + +**Actions:** +1. Confirm fixes were applied for accepted findings +2. Verify deferred items have clear justification and tracking +3. Check that the reviewed deliverables reflect the feedback + +**Checkpoint:** APPROVAL REQUIRED — Confirm feedback incorporated + +--- + +## Gate State Tracking + +| Step | Status | Notes | +|------|--------|-------| +| 1. PHASE GOAL | [ ] pending | Review goal achieved | +| 2. COVERAGE | [ ] pending | All items reviewed | +| 3. INCORPORATION | [ ] pending | Feedback applied | diff --git a/festivals/CV0001/004_REVIEW/PHASE_GOAL.md b/festivals/CV0001/004_REVIEW/PHASE_GOAL.md new file mode 100644 index 0000000..69ec599 --- /dev/null +++ b/festivals/CV0001/004_REVIEW/PHASE_GOAL.md @@ -0,0 +1,100 @@ +--- +fest_type: phase +fest_id: 004_REVIEW +fest_name: REVIEW +fest_parent: cans-v2-CV0001 +fest_order: 4 +fest_status: pending +fest_created: 2026-08-21T05:03:56.979736-06:00 +fest_phase_type: review +fest_tracking: true +--- + +# Phase Goal: 004_REVIEW + +**Phase:** 004_REVIEW | **Status:** Pending | **Type:** Review + +## Review Objective + +**Primary Goal:** The ship bar from design-recommend.md, identity, fest validate, and the PR from cans-v2 to main. + +**Context:** Five sequences on `cans-v2` turned `cans say` into a unix primitive with a mouth lock. This review runs the ship bar from `design-recommend.md` on the real mouth, checks identity and the public surface, re-syncs the snapshot, and opens the one PR. + +## What's Being Reviewed + +Items that must pass this review: + +- see BAR.md + + + +## Review Criteria + +Criteria each item must meet: + +- [ ] see BAR.md + + + +## Stakeholder Sign-off + +| Stakeholder | Role | Status | Date | +|-------------|------|--------|------| +| see BAR.md | see BAR.md | [ ] Approved | | + + + +## Approval Gates + +Gates that must pass before review completion: + +- [ ] see BAR.md + + + +## Go/No-Go Decision + +**Decision:** [ ] GO / [ ] NO-GO + +**Conditions for GO:** +- [ ] All review criteria passed +- [ ] All stakeholder sign-offs received +- [ ] All approval gates satisfied + +**If NO-GO, actions required:** +- Document blockers +- Return to relevant implementation tasks +- Schedule re-review + +## Notes + +see BAR.md + +--- + +*Review phases validate completed work. All sign-offs required before marking complete.* + +## The bar (commands and recorded output live in `BAR.md`) + +| # | Check | Pass | +|---|-------|------| +| 1 | `cat lines.txt \| cans say --stream -o 'out/%03d.wav'` writes one wav per line; `pgrep -f qwen3-tts-worker` shows **one** worker throughout; one GGUF load | [ ] | +| 2 | `xargs -P 8` over 24 lines completes with one worker resident at every sample, no pageouts | [ ] | +| 3 | Ctrl-C mid-stream: completed wavs remain, no orphaned worker, next `cans say` runs immediately, exit 130 | [ ] | +| 4 | `kill -9` on a running cans leaves the next run unblocked | [ ] | +| 5 | Booth session + background `cans say --nowait` → 75; background without `--nowait` waits with the stderr line | [ ] | +| 6 | `cans say "x"` matches `1e8cea2`; `CANS_NOPLAY=1 go test ./...` green; stream path runs on the fake worker | [ ] | +| 7 | 50-line stream beats the 50-call loop by the margin recorded in `002_PLAN/inputs/measurements.md` | [ ] | +| 8 | Professional-surface grep (`CONTEXT.md §Professional grep`) empty over README, docs/, tapes/, festivals/; exactly one Festival footer | [ ] | +| 9 | `just test unit` green; fresh `CANS_HOME` doctor green with the binary outside the checkout | [ ] | +| 10 | `git log origin/main..cans-v2 --format='%an <%ae>'` is only Veronica; no `Co-authored-by` | [ ] | +| 11 | `fest validate` green on this festival and on `festivals/CV0001/` in the repo | [ ] | +| 12 | Snapshot re-synced after this review's statuses are set; committed | [ ] | +| 13 | PR opened from `cans-v2` to `main` on `veronica-agent/cans` under `veronica-agent`, body from `BAR.md`, CI green | [ ] | + +## Sign-off + +| Role | Who | Date | Verdict | +|------|-----|------|---------| +| Orchestrator | | | | +| Operator | | | (after the fact — may veto) | diff --git a/festivals/CV0001/FESTIVAL_GOAL.md b/festivals/CV0001/FESTIVAL_GOAL.md new file mode 100644 index 0000000..cea8446 --- /dev/null +++ b/festivals/CV0001/FESTIVAL_GOAL.md @@ -0,0 +1,60 @@ +--- +fest_type: festival +fest_id: CV0001 +fest_name: cans-v2 +fest_status: active +fest_created: 2026-08-21T04:32:49.124639-06:00 +fest_updated: 2026-08-21T05:14:26.058302-06:00 +fest_tracking: true +--- + + + +# cans-v2 + +**Status:** Planning | **Created:** 2026-08-21 + +## Festival Objective + +**Primary Goal:** 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. + +**Vision:** A shell loop over a chapter renders one wav per line with a single model load, and `xargs -P 8` cannot put two workers in memory. `cans say "x"` is byte-for-byte what it was. The festival tree lands in the public repo as the second readable plan, and nothing on that surface stops being professional. + +## Success Criteria + +### Functional Success + +- [ ] `cans say "x" -o take.wav` writes the wav, does not play, does not delete; stdout is the path +- [ ] `echo x | cans say` and `cans say -` read one utterance from stdin; empty argv on a TTY is still exit 2 +- [ ] `cat lines | cans say --stream -o 'out/%03d.wav'` writes one wav per line over **one** `Session` +- [ ] `--json` emits one record per utterance on stdout, flushed as each finishes; prose is on stderr +- [ ] A mouth lock on `CANS_HOME/mouth.lock` keeps exactly one `qwen3-tts-worker` resident across any loop, `xargs -P`, or second terminal; `--nowait` exits 75, `--wait` bounds the block +- [ ] The booth holds the lock for its session; a script started alongside it waits (or gets 75), never talks over it +- [ ] Ctrl-C mid-stream keeps completed wavs, leaves no worker and no held lock; `kill -9` leaves the next run unblocked +- [ ] A second VHS tape shows a script piping lines in and wavs landing on disk + +### Quality Success + +- [ ] `cans say "x"` behaves exactly as at `1e8cea2`; every existing test passes +- [ ] Stream and lock paths are tested with the fake worker (`internal/tts/testdata/fakeworker`) — CI needs no real mouth +- [ ] 200-line stream beats the 200-call loop by a recorded margin; both numbers are in this festival +- [ ] `xargs -P 8` over 50 lines: one worker at every `pgrep` sample, no swap +- [ ] `just test unit`, `go vet`, `gofmt -l` clean; files under 500 lines, functions under 50 +- [ ] README, docs, tapes and the festival snapshot pass the professional-surface grep; exactly one Festival footer +- [ ] `fest validate` green on this festival + +## Progress Tracking + +### Phase Completion + +- [ ] 001_INGEST: design pack + user direction structured into output_specs +- [ ] 002_PLAN: decisions (lock lifetime, one PR, flag grammar), measurements, STRUCTURE, IMPLEMENTATION_PLAN, scaffolded sequences +- [ ] 003_IMPLEMENT: 01_out, 02_lock, 03_stream, 04_tape, 05_snapshot — on branch `cans-v2`, committed through `fest commit` +- [ ] 004_REVIEW: the ship bar, identity, professional surface, `fest validate` + +## Complete When + +- [ ] All phases completed +- [ ] One PR from `cans-v2` to `main` on `veronica-agent/cans` is open with CI green and the bar recorded in its body +- [ ] `projects/cans/festivals/CV0001/` holds the readable snapshot of this festival +- [ ] Git log authors on the branch are Veronica / `318153306+veronica-agent@users.noreply.github.com` \ No newline at end of file diff --git a/festivals/CV0001/FESTIVAL_OVERVIEW.md b/festivals/CV0001/FESTIVAL_OVERVIEW.md new file mode 100644 index 0000000..0e31c83 --- /dev/null +++ b/festivals/CV0001/FESTIVAL_OVERVIEW.md @@ -0,0 +1,58 @@ +# Festival Overview: cans-v2 + +## Problem Statement + +**Current State:** `cans say` takes text from argv only, plays the wav and deletes it. There is no way to write a file, no stdin, and nothing coordinating concurrent calls. `tts.SayWith` opens a worker and defers `Close` per call (`internal/tts/synth.go`), and every `Open` loads the GGUF model — so a 200-line loop pays 200 model loads, and `xargs -P 8` puts eight workers' weights in unified memory with nothing refusing and nothing waiting. The booth already runs warm on one `Session`; the CLI does not. + +**Desired State:** A script walks a document and hands cans one line at a time — from argv, from stdin, or as a stream — and gets a wav where it asked, a machine-readable record on stdout, and exactly one worker resident no matter how the loop is shaped. + +**Why This Matters:** This is what makes cans useful beyond the booth, it is the honest demo for the second tape, and the festival tree that builds it becomes the second readable plan in the public repo. + +## Scope + +### In Scope + +- `-o path` (one-shot) and `-o 'out/%03d.wav'` (stream) — wav written in Go by `audio.WritePCM16` to the caller's path; no `RemoveTemp` +- stdin as one utterance (`echo x | cans say`, `cans say -`) +- `--stream`: one utterance per stdin line over one `Session`, records flushed per line, per-line failure policy +- `--json` records on stdout; stdout is data, stderr is prose +- `--play` with `-o` (write and play) +- Exit codes 0 / 1 / 2 / 75 +- Mouth lock: `flock` on `CANS_HOME/mouth.lock` held for the lifetime of one `Session`; `--nowait`, `--wait `; the booth takes it too +- Tests on the fake worker; measurements (load time, RSS, stream vs loop) recorded here +- Second VHS tape (`just vhs pipe`) and the README scripting section +- Snapshot of this festival into `projects/cans/festivals/CV0001/` + +### Out of Scope + +- Document ingestion: `cans read`, `-f`, markdown stripping, sentence chunking — the script owns the document +- A daemon (`cansd`), a job queue file, FIFO fairness +- Voice picker, SSML, config file, mic/VAD/replies, browser booth, radio +- Restyling mid-session; keep stays the only throat change +- A mid-utterance abort in the worker protocol (Ctrl-C terminates the worker instead — D014; documented) +- The `ttfa_ms` semantics bug and the shipped default ref text — flagged for the operator, not this festival's call +- Re-cutting `docs/booth.gif` / `booth.mp4` + +## Planned Phases + +### 001_INGEST + +Structure the accepted design pack (`workflow/design/cans-v2`, `WI-a2e393`) and the operator's direction into purpose / requirements / constraints / context. + +### 002_PLAN + +Decide the contentious calls (lock lifetime and the booth, one PR, flag grammar), take the measurements, write STRUCTURE and IMPLEMENTATION_PLAN, scaffold the implementation sequences and task files, apply gates. + +### 003_IMPLEMENT + +`01_out` → `02_lock` → `03_stream` → `04_tape` → `05_snapshot`, in that order, on branch `cans-v2`. Safe before fast: the lock lands before stream mode so a stalled festival still leaves a safe tool. + +### 004_REVIEW + +The ship bar from `design-recommend.md`, identity, professional-surface greps, `fest validate`, and the PR. + +## Notes + +- Dependencies are satisfied: `WI-7eb171` (native mouth) gave the JSONL protocol, `Session`, and ctx; `WI-8b1c5d` (PR #13, `1e8cea2`) removed the Python payload. +- Execution is delegated to Opus/Sonnet subagents; the orchestrating agent plans, reviews, and approves on the operator's delegation. Every approval is logged in CONTEXT.md. +- The design pack said worktree-per-sequence; the operator asked for one PR. One worktree, one branch, one PR. diff --git a/festivals/CV0001/FESTIVAL_RULES.md b/festivals/CV0001/FESTIVAL_RULES.md new file mode 100644 index 0000000..bd711b6 --- /dev/null +++ b/festivals/CV0001/FESTIVAL_RULES.md @@ -0,0 +1,33 @@ +# Festival Rules: cans-v2 + +## Cans-specific (do not violate) + +- Git author: `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`. Display name stays the one the campaign identity lock already sets — do not change it. +- Public surface is **professional** — an engineer's repo, not a product page: no pitch, no suggestive example text. 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). Example text is boring and technical — `"Put the cans on."` is fine. +- `cans say "x"` behaves exactly as at `1e8cea2`. One-shot stays one-shot. +- The script owns the document. No `read`, no `-f`, no stripping, no chunking. If a task adds a flag that is not in `design-pipes.md`, the task is wrong. +- No daemon, no queue file, no persisted lock state. The lock is `flock`; the kernel cleans up. +- Exactly one `qwen3-tts-worker` resident, ever. Lock lifetime equals `Session` lifetime. +- stdout is data, stderr is prose. They never mix. +- No new module dependencies. The lock uses stdlib `syscall.Flock`. +- No Python in the shipped payload. `tapes/render-demo-tape.py` is dev tooling and stays. +- Do not import `projects/veronica-voice` or `qwen3-tts-native` into `go.mod`. + +## Code + +- Files under 500 lines, functions under 50. `internal/tts/worker.go` is at 196 — add files, do not grow it. +- `context.Context` first on anything that does I/O; check `ctx.Err()` before long work; honor cancellation through to the worker. +- Wrap errors with the failing operation (`fmt.Errorf("say: %w", err)` is the project's established style). +- Tests: error cases first, table-driven where there are several shapes, run on the fake worker (`CANS_WORKER_BIN=internal/tts/testdata/fakeworker`) so `go test ./...` needs no real mouth. `CANS_NOPLAY=1` in tests. +- `gofmt -l .` empty, `go vet ./...` clean, `just test unit` green before every gate. + +## Process + +- `fest next` → do the task → `fest task completed --yes` → at the commit gate `fest commit -m ": "`. +- All implementation on branch `cans-v2` in `projects/worktrees/cans/cans-v2` (linked to `WI-a2e393`). Never edit `projects/cans` directly. +- Never raw `git commit`. Never "Co-authored-by" or AI attribution in a message. +- 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. +- Implementation and the review gate are done by **different** agents. The reviewer reads the diff cold. +- Record every number you measure (load time, RSS, stream vs loop) in the task file or `002_PLAN/inputs/measurements.md`, with the command that produced it. +- Update `CONTEXT.md` when a decision is made or a blocker is hit. diff --git a/festivals/CV0001/TODO.md b/festivals/CV0001/TODO.md new file mode 100644 index 0000000..9ffff57 --- /dev/null +++ b/festivals/CV0001/TODO.md @@ -0,0 +1,57 @@ +# Festival TODO - cans-v2 + +**Goal**: Cans becomes a unix primitive a script can drive over a document — text in from argv/stdin, wav out where you point it, one mouth at a time. +**Status**: Planning + +Campaign-private and not in this public copy: `CONTEXT.md` (session memory) and `001_INGEST/input_specs/` (the design pack). Every decision they reference is under `002_PLAN/decisions/`. + +--- + +## Festival Progress Overview + +### Phase Completion Status + +- [ ] 001_INGEST — design pack + direction → output_specs +- [ ] 002_PLAN — decisions, measurements, structure, scaffold, gates +- [ ] 003_IMPLEMENT — 01_out, 02_lock, 03_stream, 04_tape, 05_snapshot +- [ ] 004_REVIEW — ship bar, identity, greps, PR + +### Current Work Status + +``` +Active Phase: 001_INGEST +Active Sequences: N/A (workflow phase) +Blockers: None +``` + +--- + +## Phase Progress + +### 003_IMPLEMENT + +**Status**: Not Started + +#### Sequences + +- [ ] 01_out — `-o`, stdin, `--json`, exit codes +- [ ] 02_lock — mouth lock, `--nowait` / `--wait`, booth +- [ ] 03_stream — `--stream`, `%03d` template, cancel, measurements +- [ ] 04_tape — pipe tape + README scripting section +- [ ] 05_snapshot — festival snapshot, professional recheck + +--- + +## Blockers + +None currently. + +--- + +## Decision Log + +See `CONTEXT.md` and `002_PLAN/decisions/`. + +--- + +*Detailed progress available via `fest progress`* diff --git a/festivals/CV0001/fest.yaml b/festivals/CV0001/fest.yaml new file mode 100644 index 0000000..37f5b02 --- /dev/null +++ b/festivals/CV0001/fest.yaml @@ -0,0 +1,61 @@ +version: "1.0" +metadata: + id: CV0001 + uuid: 21314ea0-9d04-4966-8f0d-cb0ee29597bc + name: cans-v2 + goal: '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.' + festival_type: standard + created_at: 2026-08-21T10:32:49.126582Z + initial_size_bytes: 36436 + status_history: + - status: planning + timestamp: 2026-08-21T10:32:49.126582Z + path: festivals/planning/cans-v2-CV0001 + notes: Festival created + - status: ready + timestamp: 2026-08-21T05:14:25.529502-06:00 + path: festivals/ready/cans-v2-CV0001 + - status: active + timestamp: 2026-08-21T05:14:26.058564-06:00 + path: festivals/active/cans-v2-CV0001 +project_path: projects/cans +type_config: + auto_phases: + - INGEST + - PLAN + pending_phases: + - name: IMPLEMENT + type: implementation + - name: POLISH + type: planning +quality_gates: + enabled: true + auto_append: true + implementation: + - id: testing + template: gates/implementation/QUALITY_GATE_TESTING + name: Testing and Verification + enabled: true + - id: review + template: gates/implementation/QUALITY_GATE_REVIEW + name: Code Review + enabled: true + - id: iterate + template: gates/implementation/QUALITY_GATE_ITERATE + name: Review Results and Iterate + enabled: true + - id: fest-commit + template: gates/implementation/QUALITY_GATE_FEST_COMMIT + name: Fest Commit Changes + enabled: true +excluded_patterns: + - '*_planning' + - '*_research' + - '*_requirements' + - '*_docs' +templates: + task_default: tasks/SIMPLE + prefer_simple: true +tracking: + enabled: true + checksum_file: .festival-checksums.json diff --git a/festivals/CV0001/gates/implementation/QUALITY_GATE_FEST_COMMIT.md b/festivals/CV0001/gates/implementation/QUALITY_GATE_FEST_COMMIT.md new file mode 100644 index 0000000..0dc0719 --- /dev/null +++ b/festivals/CV0001/gates/implementation/QUALITY_GATE_FEST_COMMIT.md @@ -0,0 +1,73 @@ +--- +# Template metadata (for fest CLI discovery) +id: QUALITY_GATE_FEST_COMMIT +aliases: + - fest-commit + - qg-fest-commit +description: Standard quality gate task for committing sequence changes with fest commit + +# Fest document metadata (becomes document frontmatter) +fest_type: gate +fest_id: +fest_name: Fest Commit Sequence Changes +fest_parent: +fest_order: +fest_gate_type: commit +fest_autonomy: high +fest_status: pending +fest_tracking: true +fest_created: 2026-08-21T04:32:49-06:00 +--- + +# Gate: Commit Sequence Changes + +Commit all changes from this sequence using the `fest commit` command. + +## Pre-Commit Checklist + +- [ ] All tests pass +- [ ] Linting is clean +- [ ] No debug code or temporary files +- [ ] 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 diff --git a/festivals/CV0001/gates/implementation/QUALITY_GATE_ITERATE.md b/festivals/CV0001/gates/implementation/QUALITY_GATE_ITERATE.md new file mode 100644 index 0000000..0dd3df9 --- /dev/null +++ b/festivals/CV0001/gates/implementation/QUALITY_GATE_ITERATE.md @@ -0,0 +1,50 @@ +--- +# Template metadata (for fest CLI discovery) +id: QUALITY_GATE_ITERATE +aliases: + - review-iterate + - qg-iterate +description: Standard quality gate task for addressing review findings and iterating + +# Fest document metadata (becomes document frontmatter) +fest_type: gate +fest_id: +fest_name: Review Results and Iterate +fest_parent: +fest_order: +fest_gate_type: iterate +fest_autonomy: medium +fest_status: pending +fest_tracking: true +fest_created: 2026-08-21T04:32:49-06:00 +--- + +# 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 + +- [ ] (list findings from testing gate) + +### From Code Review + +- [ ] (list findings from review gate) + +## Iteration + +For each finding: + +1. Fix the issue +2. Re-run affected tests +3. Verify linting passes + +## Definition of Done + +- [ ] All critical findings fixed +- [ ] All tests pass after changes +- [ ] Linting passes +- [ ] Code review findings addressed +- [ ] Ready to commit diff --git a/festivals/CV0001/gates/implementation/QUALITY_GATE_REVIEW.md b/festivals/CV0001/gates/implementation/QUALITY_GATE_REVIEW.md new file mode 100644 index 0000000..84c49fa --- /dev/null +++ b/festivals/CV0001/gates/implementation/QUALITY_GATE_REVIEW.md @@ -0,0 +1,73 @@ +--- +# Template metadata (for fest CLI discovery) +id: QUALITY_GATE_REVIEW +aliases: + - code-review + - qg-review +description: Standard quality gate task for code review + +# Fest document metadata (becomes document frontmatter) +fest_type: gate +fest_id: +fest_name: Code Review +fest_parent: +fest_order: +fest_gate_type: review +fest_autonomy: low +fest_status: pending +fest_tracking: true +fest_created: 2026-08-21T04:32:49-06:00 +--- + +# Gate: Code Review + +Review all code changes in this sequence for quality, correctness, and standards compliance. + +## Review Checklist + +### Code Quality + +- [ ] Code is readable and well-organized +- [ ] Functions are focused (single responsibility) +- [ ] Naming is clear and consistent +- [ ] No unnecessary complexity or duplication + +### Standards Compliance + +- [ ] Linting passes without warnings +- [ ] Formatting is consistent +- [ ] Project conventions are followed + +### Error Handling & Security + +- [ ] Errors are handled appropriately +- [ ] No secrets in code +- [ ] Input validation present where needed +- [ ] No obvious security issues + +### Alignment + +- [ ] Changes align with sequence goal +- [ ] No scope creep beyond what was requested + +## Findings + +Document any issues that must be addressed before commit. + +**Critical Issues:** (must fix) + +**Suggestions:** (should consider) + +## 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` diff --git a/festivals/CV0001/gates/implementation/QUALITY_GATE_TESTING.md b/festivals/CV0001/gates/implementation/QUALITY_GATE_TESTING.md new file mode 100644 index 0000000..06d051f --- /dev/null +++ b/festivals/CV0001/gates/implementation/QUALITY_GATE_TESTING.md @@ -0,0 +1,66 @@ +--- +# Template metadata (for fest CLI discovery) +id: QUALITY_GATE_TESTING +aliases: + - testing-verify + - qg-test +description: Standard quality gate task for testing and verification + +# Fest document metadata (becomes document frontmatter) +fest_type: gate +fest_id: +fest_name: Testing and Verification +fest_parent: +fest_order: +fest_gate_type: testing +fest_autonomy: medium +fest_status: pending +fest_tracking: true +fest_created: 2026-08-21T04:32:49-06:00 +--- + +# Gate: Testing and Verification + +Verify all functionality implemented in this sequence works correctly. + +## Test Categories + +### Unit Tests + +- [ ] All unit tests pass +- [ ] New/modified code has test coverage +- [ ] Tests are meaningful (not just coverage padding) + +### Integration Tests + +- [ ] Integration tests pass +- [ ] Components work together correctly + +### Error Handling + +- [ ] Invalid inputs are rejected gracefully +- [ ] Error messages are clear and actionable +- [ ] Recovery paths work correctly + +## Verification + +- [ ] Build completes without warnings +- [ ] No regressions introduced +- [ ] 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 + +_(paste command output here)_ From 64e89a717bf61d9f9c517e75af9ee6249e8b88c7 Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:44:00 -0600 Subject: [PATCH 7/9] [veronica:ea389d71-FE-CV0001] review: ship bar, pipe gif re-cut, snapshot re-sync What - 004_REVIEW/BAR.md: the 13-item ship bar run end to end on the real mouth, each item with its exact command and verbatim output. 13/13 pass. One worker PID across a whole stream (98 samples) with 6.5 s of non-synthesis for three lines; xargs -P 8 with eight sequential worker PIDs, never two at once, pageouts delta 0; Ctrl-C exit 130 with the worker gone 0.11 s after exit; the booth holding the lock and refusing --nowait in 0.01 s with 75; fresh-home doctor green with the binary outside the checkout; six commits, one author, no assistant attribution. - 004_REVIEW/PHASE_GOAL.md: bar table ticked, criteria and gates filled, GO recorded, orchestrator row signed. - docs/pipe.gif re-cut at 1-minute load 4.54-5.35 (100% under 16), the carry-over 04_tape assigned here: 680x300 so the gif matches tapes/pipe.tape:13 again, 91.52 s (was 123.44), 148 556 B (was 168 370), on-screen ttfa_ms 26607 / 29461 / 14174. - festivals/CV0001 re-synced with the recorded rsync (six exclusions plus --delete-excluded): 86 files, fest validate 100/100, diff -r against the source empty under those exclusions, all three surface greps clean. Why The bar is the acceptance record. Every claim this branch makes is a number a stranger can reproduce by re-running the command printed next to it, on their own machine, without asking anyone what was meant. That is also why the two engine faults seen during the run are written down where they happened rather than smoothed away: end-of-speech variance and near-silent wavs returned as success are the mouth's, not --stream's, and a reader comparing their own numbers to these needs to know that before they file a bug against the wrong layer. --- docs/pipe.gif | Bin 168370 -> 148556 bytes .../05_snapshot/06_fest_commit.md | 46 +- .../05_snapshot/SEQUENCE_GOAL.md | 6 +- festivals/CV0001/004_REVIEW/BAR.md | 419 +++++++++++++++++- festivals/CV0001/004_REVIEW/PHASE_GOAL.md | 53 ++- 5 files changed, 485 insertions(+), 39 deletions(-) diff --git a/docs/pipe.gif b/docs/pipe.gif index 543c8220acecc502d727986b8f32ea5439e1f4d0..fb862f70a4afa24d866dc28eb4ef225d4ca5630d 100644 GIT binary patch literal 148556 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⋙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 literal 168370 zcmeFY=Uda=wl17NfCK`B9(w2jDWOUygcgtzibz$uR1HNyMIjJ+qzKZ{&_P9!j-rO% zH6S7=B2@uVs)!BV_&jUv{jB|-eI5URb6vj=ly7s)agTAAX=ZM!spVNq7X$oyqznK6 zfB+yp0006282|tV0FVg)1Ow#3fq|U?%+3ITF)+Xwm^r`@4loNRBbXD+$O(aRF|%+nvvNb2xmnn`p{zU* zR$eHKmzAB51325WnEQuJ@w=I>Z8i?55TpPO62nKQv&Ugvy#&-r?v_w&Q++2HM5LjwZB z{m+K`2Sx-0T?h`2I(P2k`SX{~<1g#jT*ilA4vn}R78(}38lAN5IIoEIH=HI$;gPfIHmY-KuR9JH^ zt|lU}rnKZX`Br^Jc|&zoQ(bLyXj1FlJ017xADy}M=)wKYrUy@3nz~#rcDJ|oxLoS- zjq82X(eHez|M8=tr;kURE{}K-M!KGk^>k7Dd&fN!UJUh5dJm8G2zE4ynSzI^-ad-nU4J{$ zcK_W|{gbuiiqWE$fRq}3M^iIA3nfe9#LZb814O&0QP{e({XyD5u2H(ke|*(2!z@zb+~O5Pavhi(d-T<@E8kw`N$HQK&85Md&E714QQC04t)R%W*X=tW zrcP{K*wlhaay`zPMqEq2mIdJ<;aw{`kicfH5VD z`?8RcjsBu!Qz|PL#UtzL`}(At=ja^$%Px!Q;uu7;|qCpycz5>~9DPKO@4*Dj_F z$-)54uWV$Kr=Jd0xyeeLQ=Yr z_1kyDzVJhEup^^URP)qrsB##I9C)X5U+zW#&I2>j(93oVA}rZVt-C*}QJl-7>`yji z5^|#e8I>atH=AZ7FXyr-QUIUZ=c^I-SmvtloWm(1Ki+%vvHzHo&E^xG&Bq7t?`%H# zx&LwVDS%0Sivr>c-s%F&+}-Mi9^csNftkr~_i~>K-tL2+y}R8n6t%HEfJm0#8I;Tq z-WfvH+}#YZ=f15JoW1w$eM!{k zZy(6Xir-f&^Ur->tF5{Bef@6R=kFgI1{8m6G`~9c<5T;Gdp|yRe*OGolftC*bE}u{ z{Lk${nfpI?Mvrg)+@+c+{o0#6b^h0v>9hBL?axMS{`xwftaNy=oPYlC+iK1I!|xky zn}sGT6;ScM=Iy8!S>0yY^OPmmh-j^_9W9KgI7SBU#GzZL|g%$0VtXkG+zZ!ZL`Ea*%i&Ud~gAeu_2^l~% zkd&{Xy9}hQ2><|?_ABjA|L-6F`;iC(pa5wAoOV{U`SJk%dukAlI9vTWyrG&wFT^cK z1rmuY1SVBZ%Y*Y}87k~D)^}Gi73RD`dfG(f_42+P0ecm0w&+9Q0E+OzwD-)!2Q-&? z|BQY9vl9I4t4W=qtDc3POdyn2Mr9p-(zm{Vgd z95PdG{*UmwA#?+lr)-Zw6EKOjAvKNYGUDu`<1EKZ(qAzUSI%$H8RCXi zj|a>>A+#Y-o$V|5*+sW4@b&t^-t>jk1-mOI!rI6BKM>!v`q77PN?cI7`D)Cn!25d` zk5*{uul>TYD>AnNzV!B3ssGB|?K>qn9crz4-k(zHS;4`56FK7HO+u&_0H%2FkT88r zo5FeUh|jQwcy1=_77q)Gn1!a3pita2G~X92u|C}PW79?6#XFd4YgH~~>t6SZ=a@)Q zr3=uKoy0&cAXP_iIGwH=Wj&UaN5>j2wBu!Imxp3d@UO0`W|hSg$C=UizI8wEb8n2Eb09LxXN?--;rjtmiWInf(q?A2>t6i z{9YpP1s85LxOgIk1I(ua`U*?xjh8*QM|jyQ-cJ;$abBwJS?c95 zCADz*sCW^>=Ho<2XCQdvq9z*(Ri+ z{6|TIy41$ows(J4exZ2y@@q$b_HTq=+LbSkJ?~#II9|ox97VJqt$L_@){&Lh-f7hn z+TiifFZ_va*v4A~7Y1^ydA)HSJe;R&q!{zEi2-qq;le!X(yntniWv7`2`VCk^1%2j z$~luEB5Ca*32pb?S^`ZX!>0ne&B}cT(jcly&>7B^`%KkjL|ok0O`&q%GlDi(bx-hX zGnu+6Js=+JEO)7=m5KlAB}gT$y}N&=uu)UlIO3hL6xC~<0G{GNI37VYsSs{|gcu`j zrV@w}l&qIYtl9^eEQlnec(S1OAl+r83f3!`zIsq3`HzZWKr4piKZ?;X|1{yqq1Inj zzPKd-R^!}MZE2m#tH2y6nSxsAPGXm^z8kH)WO_|nBQ9>imUS?Nv)(o*2a`CIL$BT- zn-_o?$rqO$R`%qQF$5=ViAhi~WhatRGSBV?T<-?5LZL9SM?3?R6+rjNh>=2}FPtpv6!f5ryt>$ zZ$cO!IwHqOf_X#sZMBw-SL-A3I8IXseSgQPlx$_q)ybZb4Tg_Ej|m+gM|~0k-Sn$Ps=k1qpspoD3z4AFp?2Zn^bnFtWm^8<-~ zCT`e{wYrBtg^tNpC?`|zon#YzQljmbUT^{YMK4&iGL%K;1Q9`H@|^dAs&uFU`(kd+ zsL`=N!3~m7Q8Fg8Nbp;IfmZs-8(&e#{EF0q@bm zvLF-rVQ9sNo5L_8`;zd9)U48G?!eVD?-;;piSWxIAcKHMTo;3oKLyAn9pU3wA>1*) zRztp;mStNKmNs5j6J`-uCtQ{mSn)@f#g=wig#QWT9_hT=LWB{G`USP1soHTb>f)`w zcm{hCB2;_`%Xlv=N%K324t-~Jmct!7oUA+nF$c{p3GpKADr`+4_!|5(WFIv$UMfY0D2r?byr3~GB7sNA;gd~@cz??tgMh;&VEO+)BF?|xF zfxi~vHoE$=V{mI}EXVBrP2V4Hj?}2)8h4)_c@6{YN~0bx9H&zNOV`c|Dm&bOMIMyK zbrA(T@IWSb1l16Nnq?Hbden|6XOSf##Zy3$L~(0)%j#m9S^erF>4FsZpR9*rw3-P1 z18;GCNhikcIv4lji&}zvB1UoJ@njQTIbMXrG_|b76K9O$p7t_I%8@yqMJ>R4_9Qox zv*1IE-3nnJsHLjOP|LV$iv0SZ4uW5j*UsFH;m19Tcp)mmp??=<*RlrU8AE8`! z>swOSD?)DLQgCTrj}g!_vLc)~?%faO8ONJM>L+|IKhI@{a1g)!05vaBn=dE6HF~+k zd^=)?F}v>$Zqvncdr)u5@A(;JMBDu8`f7ZOZDREm)aR7CUm>3!#^4bb(c=c+vn36MX&5&j&$5oTWdZR{cQaj} zdMzaKX_qHJ70^y(wBlWgA(H_9cb#Z-6M|~rdY-Oyl{OXC9%f;#X?DXs2}+Tx6`9)I z2VyLGhv3SLKSI09{}b)V%shcf&P_D5Yfc;%(87%`-lD|APiDoHqg>UJS?Y#l$7x6s zD~%G6%*N9Mkr%b@RDW z0^$s-roiHip5L;mPn8VVJ0lD5Bb9l1EHYL2o+qoN_rrWf3+>0~4!AP;2^?lu`%X+Y zopUPF_Tm1;m&pMz+|K)@C%z$EabL7&xYBvNoRcno-kecLeolN8J&+f{vHmU@QkDY& zjDCC@keK9o?q(W+Avk(elITI(ZYsK!#Ms_gqv+@eqN5!a+O4Ooly3t&X=2`!Vyf|UTL3>!=SXL z0m0~yh&Tp_ulGVCZ(x)AS$GvqLscLSPN3jkNKRX!bu}D|VBf?j3e{grQn?V(a5L@L zZ9t+#^B%2KVL}lVX>uj=7>Iym>c0k`Jb-GxuWtnRBvLX{Y1) zt8@RTr2i{xOQh9W=0BkFZ;C?jN<03WqBK{tnltl3*aOvSv&xK8I3?_i^*~cuUOTxVv)%-tixF__7z*xCJ?$>#=TfEFkd)(Ot9M*1~ zmzKNBbc`iTPS^S-J4S?Vi*}kxc-)mWV!MS;cR;P$-V!2<&qXiW9a-*>&wO(m+ z9tVr2g1`3IeeG#XB+Hns4$3Ko81T+i?C_xDY&ND<*u;Tx>AK{hk6k3Kxe56~9@MHE zik^j4nXacR-{gVdtq zX4($T{AX_q0Y2?iPYGczS;yrDL!We(=DVRA2dG6FBZ(el_G<*y2Oq1^A|h>< z(Ojkzb(cL+OQsnj7doTArmgsG=`JMfKY3sXlfVG10 z#dj2#BlTLbj8aK^SSZP~hna2Bu8q!q*&|`#?IZQzxvhpQm9rC7`p%VE)QVCab^*1L zd2*=IM4lcCU5n*j`N@HRSFJoD#E9|Yj309DR`@*~CO(Z?YWlu}&$!9xOK|S}| zF__9<-1UA{cUD~a#z*$R@Xpa0S|B;%i`B-OOyNd03zHjpW?;FGm+ zD!-*&SdCWYR0<)Dm)?qq^=v?N$I4;RWj7RDjZ>$P+U-b~v504YW`xjzvZPV6jEsg$ zYBmGN?SRg<;38@vHCrm&B`6XCJaOyEK16`l(t|WQvmWt!QFQXkB>)_O=<20#3qWhF z&=^Vc9GCplD){YNB*rJ76T=d(y^)nhIFX_km|nLA_2tF27b>{8rMZ1tOw9l6t{3w~ z8iF;XAJ@LRwYZkhN-yh{!d5u(Y1pe#$J!%)TlGDCw@~;xXK3h)k8`(+oo#D^dtb_f z&g@@T2cog=>Z%~-Z#o0R_I2K0hYKBVe_z{n2 z^g9N>!b2~NlMTfO$X#7b?zE=m5sPV{ml#0zu&Q?S5*+ZHRxhBr3NaKqC?|9a2t^U` z6rdP|5)XkQaYSAfP(z$RPp(gG*`(SRZd-;#P&b6xaD22ZS$deKg7FGy0;1ikGFn59 zcQJ#ed%1996q?EO3U^!A1BC@5R99})6v zHyTdzxCY&(Vm``OJkuAfZ!Y?y3)VrqVE@umW3jQhd(~t4(u*fDYN*MUoJI%N2n?3=P7HG^lYn(X}-;0 z5N>y@YwCCaadD?|!YWMO3JeC>-VAK}{+%VSVmkKGM)X+0sZS)J05oBwzeDwdKhL?q z{n?osx^X|(UO;7V!N*tk0KGhN(%IJMdkX?ix1~xd#e--C-Cw!j% zr~{wv_3+Euvs-O2;y7Ag9jN$9yuQTXB5NvvBNA^#_2Ir9`Hh1?gz-*I6x-}!Q z<9*DMbMU|ju&sDsyrLa?yc)Npmcb!&RS3=rG0%|^t#%vQ@xT-y^*D(f@dUyxD2^oG zVOwoYKykXQ(YmX}#UiOwt39hYXH_iz@^PxmYJ9vqu!{?UZ9b{#B>dyTCuNW5BY%$6M$fIKo>LlC#PnD-rs@O3 zJ5P^}P88tZ862$S(=mZhQwZ^JJedFinPNR?!~r&uDC?w2CNkWce4C7DrFas*CBn;z ze>Ah6(Ms@NRPL9z;cw$j9>nYb#IUIcO;wwh_$j~y~nX2q?=n)dg&4=A86 zb_mqp3KfrlvN9pDDD*rql}k-_&bTgo-N4NfJKB)2K?A|Tf=Z9jE?AR!ZEI-wOXDtF z4(E&rZ5PdEK=ggD`oa5ru1#E2pfj!Qnfv3*``Y|h(=-EJZytS)Y41AwJRf=b-u7i; zPu$(;E$o%TzVkC|*Q2Td@b+!jVj}3lis738kT!fK=5|LSWJZO zWT}Jv1sF{MQ5%1*#RCRVRKCJ1j3a&^y5kt}9um)lzVI-Vp-De`fL!~P;R8Gg?G-l4 zqTP));!qFRyBJyN$Xl3P9IRLQprFZ8_rq4gC@1~uBD;iMKgTnzaB3MeYQG+dqI2;0=}N!m1EKNkXbjjG|7q!*Y(GfY)9}L;yXD5Ujz7!C7n#!sNeCPyoCG%8Fj?WL-?v2r*BlkzZhY5h(Q%#z;3R8Y)U z4twMy$E47?|2jw#N1V^AFIoJa$)XrY1~T#C;|Pq(y;<=Odc}?F(TYCBAA5RC!omF zLUr*((-2|7tI!!QNmEV;pLUGDg7}GiM%P|T^B2;6u@W9oOM(~yNYBK(>M$6-@;nyd zRInNfrb+cwwHE4NX!VOUGN=KrT=P~p?A>y<(col|RTlS`UtD3H=E|2l?h!k}&mL3A zJ$J?N=YSt3+xcff*F_FQ+|X02ca1;4K4)WW{P;cl#1!+WH$_Cs;Bk0IGQyU+vEa(s z6yo1?)ctnNBOVB#bK>lH^mWx!u^eDK&J(mt_E4&BS zJoDx*Yh>sm_*81y}Yg?nxOZ_P(o7%H7*BGG|1f(ZMQfs8^Pw9*NX z0rb9_=dgt=y8EjYESij|T)t3VXP+!=c{E41sR+N%9|=F7Xf^x)vl(g2E~DR~u7u03 zhO}uqFCBZJWG#LnGnichQ{uGk)qhPu=b=Y|RV581l*?ypC;Hfv6>G9d7sLFzGLD?y zPeNWuGTZ`LVNsMl!!+*WMrpGFjT0=D0>tR<{(1p9?qku-qvkwI2K@SFAb$1dbDGiH zSNYqun&m}w6=U?_%NauDim9`8P2t{mM;ebf5qjjITMf*tajUnZf*7Cmm?&QjSlEq| ziERN_W_cpdsPHA#_*vJM@5;nJ1Q*Pg$-YzJ%Rial>#t;S*7}fi5=SN$>Yk~sdNTX# z`DvcVNppp+d&c8Bk)h15+^=mApTkT~uazczg}OgE%_v|hQS8ja`%Kl-k{lm6qj9#j zfP7FEb8(bjuMA`a2F1noLFtKrlNwNsX@sWtDxSlR@mDwQPnJ-G2PT90)1Ws-@$Rw@ zvz11NPn?id9o59iFJS7rnsZ@Ro=fB(=|yzFe@L!>13#~zze$r{2u;JA>C~D~u1KV9 zMyjn@7XdX|A148A3*%)MZ7={XE@g-qXXZbaPQ!Yuu(sGcNX=aeh>Cmy89hY|Y9=uSA z+Zad@H{b81_Zn!uxI*`MW-5~-Im1`%<_%dGALG+q>uU(gZ4pI*{LTlRcXB@T`SSgo z2&Yfzsf_AQnH~$+TYO4=vkLUwCUC3^7asp`_fpI0hJgnmm9^JgzKlIe&pc!iyEK5u zwuQ^|>oitAiKVmK0=GTaVzT&3f}}csqPpy)OWV!DrAUnF9Fg(LVT6N`uC$EVgaX&6~Pe_EcurPo9IG7OVK$Y1k%OrYfipF=Y&xRPJl9uKgOHmvr)9MCZpgrJ9Hh9qWNv`6VA4oA-CfAvGv)4a#L_0$EREV^<9gT(v{nJ zzG-}s`-!Sl@#Vp0Zm!KvUmxNrZj*<7Tn>fOj*o+n7+g8y8Avd=8Zpiml}OV6*6xcc zI#vJv+EHGdhcaeZP{Tr(PiVZSq*GI1+w1fl&4Vp)Ub|=UB*i05Fl9^Yg?;FL^XB6z zXV~=0^IP|TOs6`Xj&G))+%0TdWSJ|9yTAVkjHgD`OiPTiB$rheh#PgP1H}h~!UsW2 zQa-&pd;Vg=UamX{LvIuo5eML=YRt2A-Aza_n)6v&OrP-&H-|#Ejd3gr4SO~$<`Lxs zCsEzV3XsFhDh~#7t%1Uz?NL68!kKE3RPuu46pC2|6zg`Z*4Fpc{9{n6MIqg{aiNi&q! zYKvXip8xXc^`rb4O4mxC)^INC zZ=IK!Puk}6PqW`UvqbZJ(FxEsS}K6Hx^xj(4)boEIBK8AFmpO0%M#c*)y55lB9VwB z*x^!HGS?r4E#zs({~wfv%px#F^O!ZqirTc54_lC$C==!cFu!7irYRFC6=WY#5L#`P z#K*)H7z}1BH;xx50=d3Y6mAxmRk;_j$|;!+GjUwFETiyNF8~}7xV-ux5yT~rTsF)< z$C;(OGd-4F78Wp)qHrh+>_TK4oE1mH45e+R({C_ejQ{oZIfYf5QGEQi1JXHsds_c@<$qz1dyN%`@%l<4t7xnDH5-4f> zQIx-*Wx@ToYHtlRIHU26`R9vuVmjE6H-5GxhRTYmaWCXxr`aU#A=D@hHft?~1^lt%hALF?C6Zbs zZ6Nml+7)L}py_m(XfN78l5R)8%GQ2zV8g748meT$38YH8>%2a@azo47St{?8IRBrZ z=>IC$YSPO4?*-?#gqJmM@E5!q#%DAJ|4oOARkUez28t3zI{>EQNNKbXR~H`$?L9FM%MjJe-@{Vc2-wYe&F zB+3}t8s37cd>iCdPGA1fMm|_6GKHhUB>jE!#X+H6=UUOqC>xGXkE5VGfP+PLDFA@B zLIOeaxQk-}`_K8M!O=vB036Ez%o*)rl?aXC7?cRLoW5n=9MVq*ojqtOV@#(uk+`|x z9=hhJ6-=JEQUKEY1)@7cRQSDQz&0WE zrhM={?sd*;ONS%$UNc0cvc)gotM}*E^L(k?YRBp7i*m~QVz&<6!Ra_D>!8=C&#gS% z0K6d{FSMAwI5lU$7&Wl|*m%#YTu1ct!7*0VZQsD&}knmfo;PAuxl@jUo_Cl|i$&zZ8{quk_a-D?|<=&u1jD3t^_GeV=vJkRcQ`!6J4{YMw8(XjKQ3a*o5qGHB}en7;s5ir{W&0CJQgn#NSy-MVsP`IvpWfuI-!Jp-KwO^ZO0&~Xe- z8(ET=DW{yVJU71tPN>>TNgT^Wc9w0Pd)PPM0x#yt2^78`Qlm0F`oO-Jsl|BYX3V~` z8Iy_&(iM#7XrsQh?8G>ZhRQKB@>*;nb<{l?_msHz|9FC7jmKwb#_mx z*LpUOT{=k40dn&EV&CWM>JW|@ymed5!V{fV;_maI;|foumf4)wp4FZbi9Ckt6}vog z`qpQ-TCgqGxo9g+FFK9$wM})6^SF7#?Pqh1=C>5zMm;B~SJ>yXDb@^@OF1{&Q1PkL z4ZEw!vOSgIKGEBP0^>zCx1LDU@xEi%1$xmlveE)zU9rNsG_G)7&A~6kt(*L=2`DfsfH;Me z#rwMAAx8?63t%DRRPJK;n6gk}rupsWxPug&n32otMiZle5P^CyKU3Ir5fVbmCnlYZ2m~ZX#7q>yEot_kTY1_((Da{t!CX7Bzhn6=zru zeiHrIICc2T=Q;S&bp^+xn-&hDPp?wrVm!9Jtn@E_n(GQO%e*PH6@4b>Ok^g%N=&;) zRyT{seE2hQl4Ltr95??#APf`IMbb&hBCzSE!y39dev~Bq_FxcRMpK%J`O$_CaE{u< zU{tF9%3Yu#$Mk&oFv6X(Jt)R{$$K%u4)W4OeOpom&Czx6VvG|t&yiv9N%IBkrM`2FB0@J}8L|C|C z4GFw@&<3>8jO1)Vk!s3D)6mrGfMyK^ zpZb=`K$B1PuOc6v`lE}M`adTv&4E$wkN!=KjDlW=RpPg8Xo?~emtw>LN-*Lrmd9Y% zP6l!`hP=^~T>;ycr&r_DJJ+VH-SsXURK6VdyMF9>^_N2h$OD?45c}+B=aBu`kT=Pe z$GV$jl8oMl{L@ zv0@&%?|Eg^hf3TE?MQp)Q&MMIPJWE}aq*pjY{Z8G!Q*BL_)uIPtb|kFN`@;MbkEf+_J=&;2vZ{X3q7tcLqEUQ5;rQiS6$U9)?fHJ*yx+iE@flglxH_!K}+IJ_d;^YEHe>Dzqk^`P5O$pY<3)G7H{bX zqfK?CZY#eZt&Coz;w?CCo7d@`I;Ze(D_xW#k1`I>{+v8cT&QClO(Shah3trfWN6?;QESuA_wjc*a>Fp_7 zFYdn;=E|$^eKV#b*sjeHl%d%;BBBb4t#JN0cDed>ES(kTVskp{If7_Xz`G~zg{AQ4 z-e+|Ex4P8ocUND$l3ged93jXcJ*Z?L%}d)=C&iUD8|>xL5@2K&Stybm`|EMrL_E|n zk!;7TF-VU6qY+1@o%DZfL5TEN@KGJE%+E=K7Br!utW}{9mz>K_)xl5?OroTKelATQ zDB+faMQ$8~dKv}IVC^a+RhIg1FuEoQG1~j}nX&~hjuuj-o)>V+YUJ2sR&fMrNqE6` zbl*@BB_9lyRwD@;WV2rkS3i8r^e^)x6d>l5#`37+1;Y2(+as_t5 zd@|#0Y5q*PulwtWPdHHCPyPfd0IPVZovhiL?s)8723YOeh!mm^iur!sFo5@3xOov$ zcnSJxGataEC%0ff_Cnp{v7zsMwXC6c41I_8@g3_!=L0tR$j&q&sK z#H;mDF%~u)G~NK`Z<&vOJxGW*l{1NkVc2hzXHa8M@AA}Pr@iM1inH9F7zJH$qImP9 zzxTC*L1nsR#l<}ij9f_;1}fnHI}l6j8K`mrbHmOd|4hZEm6i7y{ALc8w5`P`=!Jg(E|kODG+vOl>i#xNugkEC zJFIb(x6q)j-&m_jVkAZLWcxvWJef!@zzIh+7%~jpU=5kL1XvQk6RTuLMXubnQ3;;r z{DNI&T^}e`me1p+h?}WjURfV|@!{DCh9ZNO1^JH8qA9}Dt2elo-@80DvkB7Zt=m6y zk2P+s%tZ{%^Vkh1Zb^tQ##Wi9*|=@GuIUO~`u>jZ1oMTo%@K zd*p1AVKvp!WQ3g22wh;nV7H+%Z6>A3H%@~g*9r&YS%iLjB{Z1`n1LWt3#?Th zB7B%J57CubQc?kSrhLN7%Q*&e9rigm{lY)j72XrTMRrEF;z| zo%aYDC{%#*OlNIwG@&VW*?sAP$0Hq_#9f-QIc0B-7T7HfXErx?l{oJBq!-EQwMKx$ zz53FRXu^Sid_19s(-P+P?S5ZkiO%KIy$3eyj6y~CLV6r;Gl4UXC+iCZMEE_bl)iNL zcFpEny~_<4d0)eqa#Q7Vmgl}OrS=o7_HKXam@sqEG2x;zUue&(w5$YYf{^bwU(4Fm z-rLr3oSz@NqYY#+m9vDT`23jbNI3rH<^y_r87|+;g&&_v@n)^CM5bhinuI^w(fgJE zHSnGl0KS_Krs)7v?1QMc4-#D{W>-|7@>7Q9T1@Z{!9{!T>7z1hn;7>~@u`ebZ9AWq z_BVt(%{j6CoWAp8%O@l;1s+{o<$n?W+zAu#rY!fZKz)wCxjul&N*72+%YooLPbxU) zjHSu@(LW0ppvC{daz7=>7-7vx@pPr{AP2)M?k1xsV_upx8`m2*nb~o{EM9_^{Hn^0 z93{eX9K@C}5aEcSf3>TWAH;zMzU22sLDgjGBK5L5s$+zgpPxmT<4Kv)V}Ha!C;!o^ ze?`Cgl1%?QeM?TEMLORZ#KWCyo$#`>*AC2ozjmNp6Xwx~@V}NPz{#ypq&qaeG@Q&~ z&?~RSRb`bmW&D|2Q@o2tlqFG!hI#aGCY0wP(foQMM5+J!$ve^((`_epmZQU?xx~3k zPZ}*7M%modi(K1yY$TZ{k1GrKQTwRcOu)eE-u{j26;@KzIwPZ1&#xyyjhFFkZ*@$j z@eWP?zVS}-BOsNFk!<5eC;8=0bQWn{&O81Ar#F#kb>fOvgzBSbw_Kkpx@(*bd3nod zGvkVCZSU)=8=q7bEZ+$m|2n;`4^6y1%HeZK*uqn`An$k|=vP9z-`B>?+w%I{WQtb+ zm<<4MOUtW&^6PugWE_qoyc(Um_x9`=Rqau9HnZZv0EPuRSvHD-nSwp#wFgnTu?jH= zCIz9$=`x9z*^)W2D9V-y6CZhyz$olbOX~9W)goAqI#)5w8n40hB;^dv91{hQtWVZ& zThkoPCR>@!`$q+vqZRBw7L!C~WY1mS-=cwf(BBzc(XMoPhb;8(TxQVZGWo!a)4PiU zS7g#OwI$JV0^(AO_Y5POX2`qhLjy1-Lq+_@rDImJS4KeGM|vnRDu{s|Cpue63z3Zk z{C?e18)%-wjz)SeIkM)Ps`oUm{G7ha5np)(=bwKdJEs@Axh%{=2`#hE3bPoD*k;sF>R<~hfC^zc0P$UfTH`}Tb97$E&8k3^PNlE@V*MyDi_ z`}5l50H|`h416Z|_Em$x7S`XIF&|aG~-J0&kq*Xa4rLu4!BNYX zGIYGe&6r<0gB(oxJZO6+oI8~^E-TS+bAy+A?4F7a&Q&>%m^vxk(JXCTx+KX;no3^z zR#osv-5|q61iv;3IsS$_9j@L80Gj5L z1w{;`BgA&Zh61>pYkdM&h?se ztUTprP!>V~=Eon^owUcHD7@XN+}Jk7FPB~>rbE(7Lc{$wO7BUE=~D|FLf)u5>Us4( zE(|}Le(?x8tzew-PV?fYtrd%M&jIAc?{hPc2JZBFRY^bY{!vrT&=ylLahKy2dp;*! z`h4haI76mtbaph!XSQX{z*7?2d--f7eVp&lFj*jOM6}KRrnTWx+eSZr(o%WxC?~1h zqB9I*iO%x1$HL$&K59?$Cx>#z4R$PgjjTD=-%iYNaP^)giIGs{Sn)`7 znU$SX;#V8J0TQY^Ov48NP#L$XNjE440OEd2zHyx24b^N-`WJwXng{-ieL_on62ibN z>I<6g>%To9c^*10eu`JXh=NYKo5QZQ*1Kebvhc_C%I)v%j;Vg6CZrNwL;FNcA*_{g zG6Nn7&|}?*#RLeie3!Y(OOGbYWA1SN_ilU~TZV>^ICLK^fQSG((ZpW>BTf9J1Evvj z#wv5InV%I1)B!L-e+>i~pG0Ei4Ljy%+)`>)6Sqy3D@Rcc{g%vkRLm>qF@<+TKz0~u zsNu+c3Zv`CdghhD8<@zBzep)no(Ybp7&1T7z7fyTGah~Zp3@n!q+_0)Qc z1v!Uv9f&_dnVlygx37WG4tqnS&YAsVM8B-%Z!M24BU1gJ&n(P zFgj;r`hlH>UGqHpgrHF}OzI|g%67ll<*oVbC9xd8)?!vMjR|EV6P5&2w8xqXyYM)# zFj`znc03@RNt(I(0AV!OamP8+MX->zFgPAzQpP-a>!(^YPkiqEI%3RBig-S3`RJsq zeGQ$AGr%os%e__VF|$u(+yjrhnY$L1{@INyA{j3+Q_k7r2D+kvS2|1rO{<+Jip$jy z^jkVnNV#B2mR5jI8sTuvd`!IWTFAcKjCoG^@l$QQdbUWg_xPhLD-ZdBS`)_u2)8MN z2@0A^xeeZf@%YViPOTJn6tqYajYb0?{`=#*B7OF_Kq}bD9{pedf&5Wey`Fd)!R8wU1zI@za_ZqNfq+Oy4%CZ?bbx*SBp|* zj%!noA5F3prSk;E^y8-Klt3Jh9D+gD=PVYOsGQ9hUqyw3{@Qn3eXRI9_?K%9kujSN`m`V zGRp4G^aGF4D>n5OadD|*N2auV0wB%^KeO}kd8F;`djfMy%e$+;ewpNBj+$w3-vfPN znE+!mvJ@7MW)j6Pw@~BRGN_9S$)a|v?g1#V+AJ28T-uHe|1J9n&{7c)#r8O|v$VL= zQikk?lLS;_4(pJi6-cX<@pUKMb+E|A%G5ssenDE%{--7W@8oU3xcq-7Z(}s;otq(C z$6SIrDzZ&8V8!NO+3WLN@VHLj^nPWRW(IlJ#&-X(r5=B|aJHXWlsE!C0xP$-MwPZs zqgU$GY7KeQq=|^wGloP+O>%qP{-m1xc}`1{EidfxP$NT%_qUjtZ}FrvDCE&y-z9-u zuXNTAJ5_6F0J~P$g>`wibkzhFJ|*MY92o{8L}X$aXxG{1ha1R}dUu&loOmN-TR_(D zH8p?*2v=*E&pP)`fSr!BOy_K}>bn~$hpC?1p8WdW_-4e%uPnn;MZrhE5~O>2i_agn zdXD6OwsI*{i``H7C1c5$&X{=VR9L`HYHekT6S>p8ff-r%S9im0MxmsbhS_ zFi}+hF&9J6d-GJ;sK&i-stZhZyVhfv7+E2GA)gC3L?vFw7YULu!L2n;34Z0?%>V39GU$@9!YJ>=(X{BxFg|sejMRIr;8rf=JU08q6 zV`i~s)skc&SLR_t6*^`Kd#uC92+9uI=PWGj$vE?&8T(2^@vNCq3cHuw#o<0{_i`5F ztt4vws66U;^{LN*t#70BI^j{nM6B!KkRUgJ zLoe(mZ>qq+y@@2$SG;P^#kKlZH&4n}?b?U{YZuv%0RUSdoei89kmFm4eHSC0M@1%K zg0O!Y(*+lcLmnb}`uOhe{eO(TgE?+M zmmSr*NuD?&DP37gzzqh4AeH-&n#7AI;yyj%aBrk^b6~*k9PB{mnjNW9N$G^y3*%{Q zV(R!Kx+%KDO8PAur9_0NL?sBO*g_>E6lr)|6}wY&_tG3;{0 z)c;tuOw3DTRq7b@m+5pL)?o3gP5o^JFvq_`M*{*NzvPHM&ErADzf7m|;m-9(9#?O?D>dg zt#Q2dW<{iJ4|PLrTR$&Ca18M@uS8Nd$)>OGYQ-h5C#>!@B16+6Qwc|Y_9xbm_tOp( zEi;;=m#&~X``&vpU+(lX(>qNsA2A(pPH5IUDMk(W{uQuclh0JMYo6_7c&qAEA@%%M zufb0>quih)!LfXx=r}&r)tlV@5kZujvaEaVSzcg^8<(4(p^I#~uAf;D(rN#mvu!u9 zM+%n5j-2}QBnbWZ<3+n12A>KTNL}*IuyurLCEv&SS(rnU(j;VC@M@eS%!QuWfVkDSe|$~)Q2CimU#=Zc(tZ~DGKS{W#X z7PKFQo`I)3z~8e2Bnhq;Xx$N|Qka~gy^L;SC$qIhYqc1qKh zB>!8Ntr_q&{@ua;KODf;|2Tld#wGvzw|AKHF9EeDUJ}R_mFQELF;C zj!JwHX=!){`HT;HIAyQunxyc3h3A~fw`8`cG*Ff;?%?9?R`!IeGQjPIRz_tgBmL* zi(}RvSky7x4kn`z_>In~Am-j)kJ4lUL@-Rq)jVyi99V>BY>0Hu_4O#EeCXev1;AoP zrGb!pF~aU=WbObSCxvRi@&uJyj0Zx!A1a%you)sQtl3ZU{4FWv=zpHj|HJ~o5M`_2 zp81w|puORPt~o$nf%t&fA`sdT=aD_s!g;eCDJY^RY1P$5jLX|^_!OA(2 zTO5L?UOg-}(QBDU6v!TVNh{0pCGa>gwlC^s`!!u9$txes2PPEBrLC@x$49}0$Qjci zRh38bRIPkuS`(XbUA$RE5-(#@3$M#!OBJGT!`NatQHKVr{6lJ7g@jYaO}K7J{&7Br z=TbtzF^3|f^RKrCGg>ts7cMR?sz@DDV#R9?*y1l zHe%$CdcuWt=UbF+YMHux_!LkR0MvifRG<5W&d3J-ti5&#V3e9?lml-xoB;<~U_QQl6l%nXmp8Om}Jv0_1cikdsV8;T%|t_pipFDz4z*#Jv5t& zHO&wcJ)kV-G|7kN*XL@x(4R7QO-xyGP_A&)*;MePR@0#T!{R@#5&ToDzKp3ri)6tb z8Ul4CHCUS${B1WlZdnR|jEzw8?}8V*j&m1g6Vy#>&eN)FRS(a-|8wH{)e_X*J1W^d zw;%;5cEXgAjlPQ9hQr;ru1TN}+V(`C+kSlyB8lhn0zhnpR~~o1eN3Y5Y>0t!Or?Ll z&bikE3=shzWPY>Wq{tO?aUp~Yw7ZR9qta%2_V(x@7&oxB7X_hAT9l%(@i1-8AevN| z#^2ozDKRH6qngG*(A2-t8Gnzle^{i%ucYMiFX;gYvUM(kPB_#kjTx)$03_KV&P3UG zl%${BQ4@a0@eD47-*U&D1-{t7QquqOCAH+T=-jq{1pE0a9%Uz}oc^*&0!o3WiS&yk zOW?;j_0C2C1xd0|mz?6Pm?a1<%b(6H4!gFX8QT;0nA@iYd98Dok}>b5=tXi`x;t^2 z<%i2fO{y7Xm4kIxZb0#>b6aJ-i6)MBl=O)-r3Meg6@5x)Cp$`Y_=MTrri~*}oX_Iq znajPRKC=y~h@LS;a@pl(AtW+lamH|Ux9{S>{-T_ki?^Dz4IHU1=;Q69H_&k-H5dC! z$C`u;PLDHa*1Tx;>T8BB<))Cr&qq@$ulbt!ibq|-{#K({j9iIF`5(t~=W8NXEjkZKI{ zky(=&8N0_MRlm1(iFXM{TPgCVY!RjVjCw}3`gUxD(m$A))7V6XE7{pyGy#(hjznSO zb$ZR#$yxfN(nojRh2O+Rrg4mjkVys7@V4W`X+W&`Qf(gGUz26e1cK0I2|N`e^4Lxh zKLTh|`#P|ic>q>r-6n#K*uf;mQNZQ_;*#(i15iGN#?B>vX)Fq^#=x!ur7lSUuj+BL z@Rt>(pT0gS>IA(Cye`AN&%dGjm^~Z3Xptgw{j`cdzw}_XvTU4^@bWJrTr{R#*mkvl|DJ8b>{e`!_L(P9 z^)*|XYyDSk9V*$}>m=`<*?fKc+}PBMeh-2+%IWjzu^hdtbz3oC(9igh;bV=Hy)kSw zZi72Mm%g5bB?$EG_T4@48kNy{R95-xZIiw@&EyOD3)jz}PG-A7*iaM@C)cRf1Yl1_ zG(NtX`EzY|E6%1w!93>61MdrGdoSxc?g*6x zEASaI!qoF&DbTEG4Y)-fRodNf8x{4s$)})pyTtl#+nZPgkCXRD@7{hlg(UAu9=#X2 zBylGK5!7|BBV=W);>crV>5lhTA2#1+KD>W7qWtuE_@1ea3Z2}9Am=f) zv=}ZBr&LZ`YNd=O&Ui(8=q^r`^+e0|D^)}^{m5EQRIVN@PgZ}pv69|1tv?DB5csF+ zNq&N^W?G4!^vbYMfO%xupE$peWorJVEXO6hLp0Z1rlTs)E4N03Iq6qZnIF`4UbrCO z=;nG+=v&BjX5dWCixTgrIQeTd&X+mYJmEkO6Pd3-u{=c=o?U**hLx8?TKgY+p&W%wG5{gX{m3cSiu|UIF;~pL#x^0t_mUsXu$B zGf{C*h~+mfv(yEMVh%0sTi8SatWwewX5(sqv`I;9GQHdsj#hPTZ*b6Du@tovcPDCk z!;h8d*^ZbF%Jjj}vQ|@8EMlyv$5erhoq7FB`vbBHZeG$W37N7)FQ(~iHcz9EUm>@N zp;dknuM?z+jpOu!u9qWmR_;c5swO}(I6lzT2dvz(PAoig*}DDQ&PLf%!{<|Wf}z>o zvR3WtItGep0c%1OzWqWL_s@&l#6~!OXdGXOx${;_4^E+$d7!~8da?#4>GkH$yXT#) z>{5TY*r*{mJ8{jsW2TP{BDZEC&FtSIPj}7a>g}CAiZTe;hBi?(hasMn@Mra@=9(lYl$Jx@SLK+=C{)V&(I$L=eh3Ssb3^Z4Ie9#mM%y{u1VYBfmR|k9hN-+4LSmr50;<~g#&Wr&lz7Iv zqmUS)I34)T*u+u6?*4DB{u00-{`&X#IcO?9CP5dp{b@<+iZV;S%7y+ONXRJfOx}a? zxNMh|4J2XrdEE0eEokRcGist{fXote)!eJ8OCV3UXb6vneLiRieeKY7e7?+1uUQUI z)>6qb$sDr={(3=z>j^rr5Z)t z*S0z|r7XJ_p8z@&nQ|g>gQjy<5N+`+&QmW<{AmTuqWk{E_fav`k?jxn?n^p-GY$rI zVbTnPGRUwiQ;vFtt^+^p9vQ#ud%*+$=pBW<@bcryLOdT2Ng5`!t|)#rtWlA1Fc;qd zHu%CA1HsC61dw58Gnh}~fFyf2Pc*hW7k)|(#1$lxJBx+zUIH#fz=k-^g$NpZd{8=Jd-3W2w(S3EiNU(88h0pCoFVAYpU{o3jB^aUkR9q|2d*y-?>&! zMS;PNN?sZbOPxa8;5NVQTX@rW4orYcLgP|zIu>W}gp}!NftI%|#nzR zf9XoP>1bxo4APe=XuVBs_U1Tg!Ijb|hFg`sZHb$YlmyC?; zca?4KQa0}zul9ddWbOPkQBJ}n*GiIrCt|h{`YVjGSunT zhDXNcR4Q2OXUhuD!Ed3S{c#*?pO>#Bd=D`R^2SFU!9|NNbMYxQ2z$qWg$&U+aSdz$ z2;A)?f{WuoW; zKwaiAJ<0)O8Bl=7xv2k|BQl{zA{0*~XjmSeG*<4FP<~aX%A)a^$o-g-HE$b+zq+No zYdU%Jme9iQ?YYLlD2W1_?%%f^W$PF=T}_5fXQ3^s>8PPiKFn9XR>^LhGk+ z6-0-`=zQOe2vrAuW&0T6fsq7Q5}2A;G4uN7v%B+tn+y$IQ-kxi<_SC*tN912auL1) zU&}w}T(*|n=$Isq9q?)?2s{4tb}a0~M@jjbnYReVE7@wB3Pq2pvyHgL7DJ)K#eb?d zf0YP8tA>3DiWg|^b)X*=*E{o>eEed>3)_fwrcQ`LA${TQh4!2IHx%C_y?^?=aUBEg zx;L3FQCK;dt^jF&@zizkpoI`DYIgDwMk#b9U51?kDLu*8-4_KXh@XSyVAbWnIDgd8W-q#Wh4Zlz=z(l zD0mJ|K)5`QbuuaS`$;_)1!BNVI&gCu>)&!B@8JdU* z@rpXfaUW8~r|+9GO35+u{j>*wrgHqo+`S)^m)hhN2V`M=kZ2g;K1qP z7K5}eN0UeFDS5nkx4!I~0ViZgQ>0GGOYlGYQ%}_PGa?q>mniJ`>3!9kZXlZ~u6Fi_ z-Uj{gYMbA8cO{T#ww?AjFgHtkT!6M&^5RYO_eyY8n&P1&>SDfWL{v+rU zs5oY9t6%(%;Rkl#Q>Q`+nf=_j6iN&>N{K+c(8M>G4Hm5=k3soK>XH#0Y5i^|RWD6T z2V>3qCXXt;TooV_0b;Ut9`B7$UV z&qp9Pp{n3}WPk4*p5NN%eSohg@qd3mEOhsJd8|o1hb-KPl+o7%*yVZZNPK3AXeFa4 z^k%o2X&RRuHoqye>H*HgiYb>cK|!EKvuzfJLKLwQdro6@{uvzgKBIBc#|xTmQsPVk z+l2X4f#T4~n{JTL0y5)Ks`~cRwK`(@*U5^aDx0X+akb;sLpg<`3yc2_YU(~seKb~iZO$%Q7Sf1~X#YR^?b#w6q5#nr*5A)md0F0LFdjHCk? zoFD%3{nUnrrDThEHiz@wI*xaVR~6OjHb1A$HF=M{yuZNr=4?Rsu~;E1_LZ?ikuT?4 zonJf@Pn*n9^&DJx-s&m7^7v*p&ZK$-BiQkxHtmo-hxpZ}P?R4Y%SOLV+w2!}K`=Thctu)FUyfze7g4B@ny%tfcK56`0 zzuI8nt1AM3e{U}_+qBL<(JK53=)nf*9wAkGZAv7;3=t0!46sk&lQm5M8N2niH?9&> z)DBlF(E-*2t?Yb<>NnwKA#BH3ebSL-k(4hIcn0|OyPGQYvT&}o_Kw8>Iv7C*OqeDy z6ibnTGeLnTZyq&dQAo!<;UXqiTvBU&n5{u`)%PzSspkVF6CN~;b()K+bMDzQXcIwa z+HO;8hUf;iXNic*?=4MG!41y9mhIo~lE>~=o~F8yVddDD(8km{+g2^yH^{ITkt@G_ z)6FRKqFbW9pt{rNenHU>Z2KIb`roY#cP51Zpnzf|cK>r9t1it$fvyt^d7;05rU;tn$lu~RPR9tY}$I!r>s?@b=v7xRQp z2>lkke#*qK-ERU)WJEnW^e&;f(D|h`#znRyb*C@-LOum7onn-kfQm6|mbxm!^TL5B zZPW=6vzJN*T@Vvh%f6@1p_vp`pN^|;QVz<-EltGR(QZm<()U`bcBfCFx%c#-e1Zay za*&IxIcb3_q5_rL_P{xx>qcdVNgSN#GZiwEuQkJtgPm+xQ8Rodo@RoxFnT=5&V}X(R-=!Bl7-a_MdRT8aV|9R zjd9N;X*))5ktPIgt;AVBnRmQVU%5{74} zpt!%ivF*=2(13i9@b+xeoTyfs?whm8VS9Bfk5)NkRB(;BPK?IxbIkC3X5p^u)P)7QGfeI9a5DEiI$ZksNFdld3;^;B5u z%!i5Bfj&Xx-iHpmeXH(&wk8yBWnjek8x24G@p0#U&yR{OuTSVkBGA9iRiyJCmos2G zC(_O?1N&)SLgj|Z6(FDQ+CAn>Uu;ehrERoQqW4Je)A?|nTk$gbENYZs!V`8g*=M`g zg=iv&s1gvQN-q(LP{E%_LmbjQbwskY6cpH5<1Lv*wmd$SV=9q@zHDVsVxFb)r00+shi;ry%! zP3Hh;s&<_r9{C=dgqu3IZf~A?y$L6O*r(310esXOSZAH@WQ%5^iHQpLHdPB-Z~UUw z*5A}1;%%Kv@gTLr>H3o$GKB50jV{c7r8Z#8ah~yy+;*CbGmS?^@h#bUl~ufbde7+# zQ8mAI5GsUCSv-e%_q0(#0x3K5Afj*N(w31&ZD+MvL8<)c`h%3RaguZ*W`X%6kD=x=`N*w9C`?}ojazyD+( zr`f{Ny>852pzjEb-Tm89Gn}1*ResLYctpw2PZ*8zlL}&E=SHDu90ia8jI)~DgcN%` ztKz77{Ooq3dKxlA%C|>Qk%1O{f*&&c+2Hx`;5)hUrSsx$iVO*(%1#36d{E|*{UzV& zrhzH}(>d`-z+1(HCK8f)>zHJU+;jk$~HPC9(As5GiPUQ&6g zR9FzaS^8U7NC7Yfxc>qUiFA-77T8wIk_EVh&QaPMD1;ac4j#IJa<@nYt0sxpXh7fs zN=K_z+$UlYak8FHmCUGtY#fRDY$UOF?FbMfRI~2_9##n;Wy#2Mu9+q$5j=TRYwH}R z-A9{rlOa{TlQA~YGIAGc903V%;;5ma6*rKj?u^SZAooR?k3V!WxvS^wj8X=?^cp8? zdocU$jTjZyZ(0U|%GlD5sCz9%#S6qMz1j_)|C7!ar8MIHurX|V+5Le)sZI04$xl4d zP0~AfQHAbm20QnucCUrSFi@xPCtoF*AhB-|aH0b;W%hK)hbU5}gp|JX#n*GUL_1GG z)b?aix~7=mk5PSuG8!22xc|pQ+d*~qC6imo+dqUUA&fIQQu7I!Z5{SX7L&T61@iDWwwxT?(Al&oUuXkiy1LaZ!If+yOG zl%xBqUK!uj{H=rJ)_)x&Fbh~Tpy=La(d=?en)_%0AyxFa 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/003_IMPLEMENT/05_snapshot +$ gh auth switch -u veronica-agent +✓ Switched active account for github.com to veronica-agent +$ fest commit -m "feat: festivals/CV0001 — the v2 plan as a readable tree …" +Hash 9136cbe +Message [veronica:ea389d71-FE-CV0001] feat: festivals/CV0001 — the v2 plan as a readable tree +Task FE-CV0001 +Campaign [veronica:ea389d71-FE-CV0001] +Root Commit 21e5252 +``` + +- **Worktree (`veronica-agent/cans`, branch `cans-v2`): `9136cbe`** — `86 files changed, 5541 insertions(+)`, all additions under `festivals/CV0001/`. Author `Veronica <318153306+veronica-agent@users.noreply.github.com>`, the campaign identity the rules require. +- **Campaign root: `21e5252`** — the festival's own task documents and statuses. +- Pushed from the worktree: `5e8123d..9136cbe cans-v2 -> cans-v2`. `git status --short` clean afterwards. + +The copy in the commit is current as of `05_iterate` being completed; only this gate's own +`fest_status` moved after the snapshot was taken. `004_REVIEW` re-runs the recorded rsync +(`01_snapshot.md` — six exclusions plus `--delete-excluded`) before the PR, which picks it up. \ No newline at end of file diff --git a/festivals/CV0001/003_IMPLEMENT/05_snapshot/SEQUENCE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/05_snapshot/SEQUENCE_GOAL.md index 3290126..43c2ae0 100644 --- a/festivals/CV0001/003_IMPLEMENT/05_snapshot/SEQUENCE_GOAL.md +++ b/festivals/CV0001/003_IMPLEMENT/05_snapshot/SEQUENCE_GOAL.md @@ -4,16 +4,18 @@ fest_id: 05_snapshot fest_name: snapshot fest_parent: 003_IMPLEMENT fest_order: 5 -fest_status: pending +fest_status: completed fest_created: 2026-08-21T05:04:56.253675-06:00 +fest_updated: 2026-08-21T18:15:27.69008-06:00 fest_tracking: true fest_working_dir: projects/worktrees/cans/cans-v2 --- + # Sequence Goal: 05_snapshot **Primary Goal:** This festival becomes the second readable plan in the public repo (`festivals/CV0001/`, per D009's exclusions), and the whole surface is rechecked: grep, footer, tests, fresh-home doctor, `cans` without `fest`. Covers P1-3, P1-5. Decision D009. -Dependencies: 04_tape. Last, so it captures the finished tree. `004_REVIEW` re-syncs the snapshot once more before the PR. +Dependencies: 04_tape. Last, so it captures the finished tree. `004_REVIEW` re-syncs the snapshot once more before the PR. \ No newline at end of file diff --git a/festivals/CV0001/004_REVIEW/BAR.md b/festivals/CV0001/004_REVIEW/BAR.md index d84793b..f958b2d 100644 --- a/festivals/CV0001/004_REVIEW/BAR.md +++ b/festivals/CV0001/004_REVIEW/BAR.md @@ -2,36 +2,449 @@ Fill each block with the exact command and its output when `004_REVIEW` runs. Real mouth, idle machine (`uptime` first). Do not paraphrase output. +**Run 2026-08-21 18:17–18:40 by the review agent.** Binary `bin/cans` at `v0.1.0-28-g9136cbe`, worktree clean at `9136cbe`. Every real-mouth item was taken one at a time on a quiet box — the 1-minute load never left the 4.6–7.6 band for the whole session, so no item is contaminated and none had to be deferred. + +Paths below are written `` (a `mktemp -d` outside the repo) and `~`; the recorded commands had them spelled out. Grep patterns are referenced, never quoted — they are campaign-private (`CONTEXT.md §Professional grep`). + +**Verdict: 13 / 13 pass.** Two known engine faults were seen again and are recorded where they appeared; neither is a `--stream` defect and both are already deferred in `CONTEXT.md`. + ## 0. Preconditions ```bash -uptime # 1-min load must be below 16 -pgrep -fl qwen3-tts-worker # must print nothing -just build quick +$ uptime +18:17 up 18 days, 12:29, 41 users, load averages: 5.97 6.41 6.85 + +$ ps -Ao command= | grep -c '^~/\.cans/native/bin/qwen3-tts-worker ' +0 + +$ just build quick +mkdir -p /bin +cd && go build -trimpath -ldflags "-s -w -X github.com/veronica-agent/cans/internal/ship.Version=v0.1.0-28-g9136cbe" -o bin/cans ./cmd/cans +exit=0 ``` +**The worker counter.** `pgrep -f 'qwen3-tts-worker'` is not used anywhere in this bar. It self-matches and it cross-matches other concurrent samplers, which is how attempt 3 of `04_measure` read a false 2 and 3 with exactly one worker resident. Every count below comes from an anchored `ps` counter in its own script file, which also excludes the foreign `~/.cache/festival-voice/…/qwen3-tts-worker` (PID 62136, resident for this whole session, not cans and not touched): + +```sh +ps -Ao pid,command= | grep '^ *[0-9][0-9]* /Users/…/\.cans/native/bin/qwen3-tts-worker ' +``` + +It samples once a second and records **both the count and the PIDs**. The PID column is the stronger evidence for items 1–2: a second GGUF load means a second process, so a single unchanging PID across a whole run is proof of a single load in a way a count of 1 alone is not. + ## 1. Stream — one worker, one load +```bash +$ ./bin/cans say --stream -o '/out1/%03d.wav' --json < lines3.txt > stream1.jsonl 2> stream1.err +( ... ) 111.70s user 10.30s system 117% cpu 1:43.89 total +exit=0 + +$ cat stream1.jsonl +{"line":1,"wav":"/out1/001.wav","ttfa_ms":32591,"sample_rate":24000} +{"line":2,"wav":"/out1/002.wav","ttfa_ms":32418,"sample_rate":24000} +{"line":3,"wav":"/out1/003.wav","ttfa_ms":32346,"sample_rate":24000} + +$ cat stream1.err +(nothing) + +$ ls -l /out1/ +-rw-r--r-- 53912 001.wav +-rw-r--r-- 1484 002.wav +-rw-r--r-- 126530 003.wav +``` + +Sampler over the whole run — 98 one-second samples: + +``` +samples=98 max=1 counts seen: 0 1 distinct worker PIDs: 89253 +``` + +Every sample from the second onward reads `1 89253`. **One process, start to finish** — the worker was started once, loaded the GGUF once and served all three lines. Worker gone after the run (`workers_after=0`). + +The overhead confirms the single load arithmetically: + +``` +Σ ttfa_ms = 32591 + 32418 + 32346 = 97 355 ms = 97.4 s +wall = 103.9 s +everything else (process start, doctor.Prepare, keep.Load, flock, GGUF load, 3 writes, 3 records) + = 6.5 s for all three lines +``` + +6.5 s is one GGUF load (~6.6 s in the baseline), not three. **PASS.** + +*Mouth fault, not a v2 defect:* `002.wav` is a well-formed but near-silent **0.03 s** / 1 484-byte file after 32.4 s of synthesis (`001` is 1.12 s, `003` is 2.64 s). Same shape as the 4/50, 5/50, 1/24 seen in `04_measure`; the worker reported success, so `--stream` had no error to report and correctly wrote what it was given. Deferred in `CONTEXT.md`, engine-side. + ## 2. xargs -P 8 — one worker, no pageouts +The 24-line measurement is **not re-run here** — it is `002_PLAN/inputs/measurements.md §Stream → Attempt 4 (c)`, taken on a quiet box with 100 % of its load samples below 16: + +| | Attempt 4 (c), 24 lines | +|---|---| +| Wall | 943.2 s (`real 15m43.217s`) | +| Exit / records / wavs | 0 / **24 of 24** / 24 | +| **Worker max** | **1** (878 samples, values `{0,1}`) | +| Pageouts | 875 724 → 875 724 — **delta 0** | +| stderr | 20 × `waiting for the mouth…`, nothing else | + +Re-confirmed here with a short 8-line run under the same `-P 8` fan-out, on the same null-delimited pipeline (BSD `xargs` has no `-d`): + +```bash +$ cat lines8.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 +xargs_exit=0 +xargs_wall_s=268.8 + +$ vm_stat | awk '/Pageouts/{print $NF}' # before / after +875753. / 875753. + +$ wc -l < x.jsonl ; ls /out2/ | wc -l +8 +8 + +$ cat x.err +waiting for the mouth… +waiting for the mouth… +waiting for the mouth… +waiting for the mouth… +waiting for the mouth… +waiting for the mouth… +waiting for the mouth… +``` + +Sampler across the run — 250 one-second samples: + +``` +samples=250 max=1 counts seen: 0 1 +distinct worker PIDs: 9201 10239 10589 11762 12843 13339 14370 14837 +``` + +Eight callers, eight worker processes, **never two at the same instant** — the PIDs are strictly sequential, each starting only after the previous exited. Seven `waiting for the mouth…` for eight callers is exactly right: one holds the lock, seven queue. **Pageouts delta 0. PASS.** + +*Mouth fault again:* `5.wav` is 1 484 bytes (1 of 8). + ## 3. Ctrl-C mid-stream → 130, wavs kept, no orphan +20 lines in, `SIGINT` sent to the `cans` process one second after `002.wav` appeared. + +``` +002.wav exists at 18:24:42 — sending SIGINT to 92731 +exit_code=130 +sigint_to_exit_s=0.05 +worker_gone_after_exit_s=0.11 +next_oneshot_exit=0 +next_oneshot_wall_s=14.65 +gap_sigint_to_next_start_s=0.18 +``` + +```bash +$ cat stream3.err +interrupted after line 2 + +$ cat stream3.jsonl +{"line":1,"wav":"/out3/001.wav","ttfa_ms":41986,"sample_rate":24000} +{"line":2,"wav":"/out3/002.wav","ttfa_ms":41509,"sample_rate":24000} + +$ ls -l /out3/ +-rw-r--r-- 1484 001.wav +-rw-r--r-- 63972 002.wav + # no 003.wav — line 3 was in flight and was dropped + +$ cat next3.out ; cat next3.err +ttfa_ms=5798 +(nothing — no `waiting for the mouth…`) +``` + +Exit **130**; the two finished wavs stayed; the in-flight line 3 was terminated rather than waited out (D014); the worker was gone **0.11 s** after the process exited; the next one-shot began **0.18 s** after the Ctrl-C and completed normally. **PASS.** + ## 4. kill -9 → next run unblocked +`SIGKILL` to the `cans` process ~8 s into a 20-line stream, with the lock file watched on either side. + +``` +lock_before: -rw-r--r-- 0 Aug 21 06:32 ~/.cans/mouth.lock +kill -9 96323 at 18:25:24 +killed_exit=137 +workers_1s_after_kill9=1 +lock_after_kill: -rw-r--r-- 0 Aug 21 06:32 ~/.cans/mouth.lock +next_oneshot_exit=0 +next_oneshot_wall_s=35.36 +gap_kill_to_next_start_s=1.10 +lock_after_next: -rw-r--r-- 0 Aug 21 06:32 ~/.cans/mouth.lock +``` + +```bash +$ cat next4.out ; cat next4.err +ttfa_ms=27241 +(nothing — no `waiting for the mouth…`) +``` + +The next `cans say` started **1.10 s** after the kill, never printed the wait line, and exited 0: the kernel dropped the `flock` with the process, exactly as D003 intends. The **lock file itself is never deleted** — same 0-byte inode and mtime before the kill, after the kill and after the next run. **PASS.** + +Two things worth recording, neither a failure: + +- **The orphaned worker outlives its killed parent for about a second.** `SIGKILL` cannot be handled, so cans gets no chance to shut the worker down; the worker exits on its own when the pipe closes. In the 42-sample trace exactly one sample reads 2 — `96327` (the killed stream's worker, on its way out) alongside `96546` (the new one-shot's) — and the next sample is back to 1. The overlap is a teardown transient during the second the new run spends on `doctor.Prepare`, not two workers synthesising. This is the price of `kill -9` and is what the design accepts in exchange for a lock the kernel always releases. +- `ttfa_ms=27241` for a one-shot whose quiet-box baseline is ~5 700 ms is the mouth's end-of-speech variance (see item 6), not the kill. + ## 5. Booth holds the lock +The booth needs a real terminal. `script -q /dev/null ./bin/cans` **cannot** be driven from a fifo on macOS — `script` runs `tcgetattr` on its own stdin and dies with `script: tcgetattr/ioctl: Operation not supported on socket`, the booth never starts, and every `say` fired at it then wrongly succeeds. The booth here is given a real pty from `forkpty` instead, with keys written to the master fd. + +``` +booth pid=7843 started 18:31:34 +booth_worker_up_after_s=5.2 workers=1 + +--- A_nowait --- +A_nowait_exit=75 +A_nowait_wall_s=0.01 +A_nowait_stdout='' +A_nowait_stderr='say: mouth busy\n' + +--- B_wait2s --- +B_wait2s_exit=75 +B_wait2s_wall_s=2.01 +B_wait2s_stdout='' +B_wait2s_stderr='waiting for the mouth…\nsay: mouth busy\n' + +--- C: end the booth --- +still alive after q — sending Esc +booth_exit_status=0 +workers_after_booth=0 + +--- D_after --- +D_after_exit=0 +D_after_wall_s=13.60 +D_after_stdout='ttfa_ms=5627\n' +D_after_stderr='' +``` + +The booth took the lock 5.2 s after launch and held it for its whole run (D001). `--nowait` refused in **0.01 s** with **75** and `say: mouth busy` and never touched the mouth. `--wait 2s` printed `waiting for the mouth…`, polled for **2.01 s** — the requested budget, to the hundredth — then gave up with **75**. The booth quit on Esc (`q` is not its quit key), its worker went with it, and the next `say` ran immediately. Sampler max **1** over 27 samples; the two PIDs (booth's, then the post-booth say's) are sequential. **PASS.** + ## 6. One-shot unchanged; tests green on the fake worker +```bash +$ gofmt -l . +(nothing) +$ go vet ./... +(nothing) +$ CANS_NOPLAY=1 go test -count=1 ./... +ok github.com/veronica-agent/cans/cmd/cans 2.111s +ok github.com/veronica-agent/cans/internal/audio 0.739s +ok github.com/veronica-agent/cans/internal/booth 1.002s +ok github.com/veronica-agent/cans/internal/doctor 1.205s +ok github.com/veronica-agent/cans/internal/keep 1.340s +ok github.com/veronica-agent/cans/internal/mouth 2.389s +ok github.com/veronica-agent/cans/internal/play 0.220s +ok github.com/veronica-agent/cans/internal/say 7.285s +ok github.com/veronica-agent/cans/internal/ship 1.519s +ok github.com/veronica-agent/cans/internal/tts 3.786s +exit=0 +``` + +Ten packages, no cached results, the stream path exercised on the fake worker. The real-mouth one-shot, unchanged from `1e8cea2`: + +```bash +$ ./bin/cans say "Put the cans on." +ttfa_ms=7157 +( ... ) 13.72s user 0.81s system 90% cpu 16.004 total +exit=0 + +sampler: max=1 counts seen: 0 1 (16 samples) workers_after=0 +temp wavs matching $TMPDIR/cans-say.*: 1 before, 1 after +``` + +One line of output in the v1 shape, exit 0, one worker, worker gone afterwards. The temp-wav count is unchanged across the run — the single match is a 355-byte leftover dated **2026-08-19**, older than this branch and untouched; this run created no temp file and left none behind. `ttfa_ms` 7 157 against a 5 652 / 5 669 / 5 798 / 5 627 baseline across this session's other one-shots. **PASS.** + ## 7. Margin (from measurements.md) +Quoted from `002_PLAN/inputs/measurements.md §Stream → Attempt 4`, the one uncontaminated attempt — three back-to-back runs with **100 % of every run's load samples below 16** (means 8.36 / 7.06 / 7.45, max 14.97) and worker max 1 in each. Not re-measured here: nothing in this phase changed the code, and a 60-minute re-run would only re-derive it. + +| | (a) stream, 50 lines | (b) loop, 50 calls | +|---|---|---| +| Wall | **1 512.6 s** | **2 109.2 s** | +| Records / errors | 50 of 50 / 0 | 50 of 50 / 0 | +| Σ reported synthesis | 1 495.8 s | 1 770.6 s | +| **Everything else** | **16.8 s — 0.34 s/line** | **338.6 s — 6.77 s/line** | + +``` +margin = 2 109.2 − 1 512.6 = 596.7 s over 50 lines +per-line = 596.7 / 50 = 11.93 s per line +structural = 338.6 − 16.8 = 321.8 s → 6.44 s per line +``` + +The **6.4 s/line** figure is the defensible one and the one the README quotes: it is the per-call GGUF load `--stream` removes, reproduced independently by run (c) at 6.69 s/line and matching the ~6.6 s load in the baseline. The remaining 274.9 s of the margin is the loop's higher reported synthesis time — real, but the mouth's variance rather than something `--stream` engineered away. Item 1 above reproduces the same structure in miniature: 6.5 s of non-synthesis for a whole 3-line stream. **PASS.** + ## 8. Professional-surface grep + one footer +Three patterns from `CONTEXT.md §Professional grep`, run from the worktree over `README.md docs/ tapes/ festivals/CV0001/`. The patterns are campaign-private and are referenced, not quoted. + +```bash +$ rg -i '' README.md | rg -v '' +(nothing — exit 1) + +$ rg -i '' README.md docs/ tapes/ festivals/CV0001/ +(nothing — exit 1) + +$ rg -c 'fest.build' README.md +1 +``` + +Nothing / nothing / exactly one Festival footer. **PASS.** + ## 9. Fresh CANS_HOME doctor +`bin/cans` copied to a scratch directory outside the checkout, run from a directory outside the checkout, against an empty `CANS_HOME`. + +```bash +$ just test unit +cd && CANS_NOPLAY=1 go test ./... +ok …/cmd/cans …/internal/audio …/internal/booth …/internal/doctor …/internal/keep +ok …/internal/mouth …/internal/play …/internal/say …/internal/ship …/internal/tts +exit=0 + +$ export CANS_HOME=/freshhome \ + CANS_WORKER_BIN=~/.cans/native/bin/qwen3-tts-worker \ + CANS_WORKER_MODELS=~/.cans/native/models +$ /binout/cans doctor + machine ok darwin/arm64 + worker ok ~/.cans/native/bin/qwen3-tts-worker + payload ok /freshhome/shipped + throat ok /freshhome/shipped/voices/veronica/ref.wav + play ok /usr/bin/afplay +put the cans on. +exit=0 + +$ /binout/cans say "Put the cans on." -o /fresh.wav +/fresh.wav +( ... ) 12.29s user 0.67s system 103% cpu 12.473 total +exit=0 + +$ ffprobe -v error -show_entries stream=codec_name,sample_rate,channels -show_entries format=duration,size … +codec_name=pcm_s16le +sample_rate=24000 +channels=1 +duration=1.041583 +size=50040 + +$ ls $CANS_HOME +mouth.lock +shipped + +sampler: max=1 counts seen: 0 1 (13 samples) +``` + +Five doctor rows ok, a real 24 kHz mono wav in 12.5 s, one worker, and a fresh home containing exactly the lock file and the unpacked payload. **PASS.** + ## 10. Identity +```bash +$ git log origin/main..cans-v2 --format='%h %an <%ae>' +9136cbe Veronica <318153306+veronica-agent@users.noreply.github.com> +5e8123d Veronica <318153306+veronica-agent@users.noreply.github.com> +c443de9 Veronica <318153306+veronica-agent@users.noreply.github.com> +c736f46 Veronica <318153306+veronica-agent@users.noreply.github.com> +47d39d9 Veronica <318153306+veronica-agent@users.noreply.github.com> +0f4e436 Veronica <318153306+veronica-agent@users.noreply.github.com> + +$ git log origin/main..cans-v2 --format='%cn <%ce>' | sort -u +Veronica <318153306+veronica-agent@users.noreply.github.com> + +$ git log origin/main..cans-v2 --format=%B | rg -i 'co-authored|claude|gpt|grok' +(nothing — exit 1) +``` + +Six commits, one author and one committer on every one, no assistant attribution anywhere in the bodies. **PASS.** + ## 11. fest validate (festival + snapshot) +```bash +$ fest validate # the campaign festival +● STRUCTURE +⚠ Task filename should match NN_name.md: .review-01_out.md +⚠ Task filename should match NN_name.md: .review-02_lock.md +✓ COMPLETENESS ✓ Task Files ✓ QUALITY GATES ✓ Markers ✓ ORDERING ✓ AUTO-LINK ✓ HOOKS ✓ WORKFLOW +Score 90/100 +VALIDATION PASSED WITH WARNINGS +exit=0 + +$ fest validate festivals/CV0001 # the public snapshot, from the worktree +✓ STRUCTURE ✓ COMPLETENESS ✓ Task Files ✓ QUALITY GATES ✓ Markers ✓ ORDERING ✓ AUTO-LINK ✓ HOOKS ✓ WORKFLOW +Score 100/100 +VALIDATION PASSED +exit=0 +``` + +Both pass. The campaign festival's two warnings are the cold reviewers' hidden `.review-01_out.md` / `.review-02_lock.md` scratch notes — the exact files D009 was amended to exclude, which is why the snapshot scores 100. Expected, not a defect. **PASS.** + ## 12. Snapshot re-sync +The recorded command from `003_IMPLEMENT/05_snapshot/01_snapshot.md`, re-run after this phase's own statuses and results were written. `--delete-excluded` is load-bearing: plain `--exclude` will not remove a file already present at the destination. + +```bash +$ rsync -a --delete --delete-excluded \ + --exclude CONTEXT.md --exclude '001_INGEST/input_specs' \ + --exclude .fest --exclude .workflow --exclude .festival-checksums.json \ + --exclude '.review-*' \ + festivals/active/cans-v2-CV0001/ projects/worktrees/cans/cans-v2/festivals/CV0001/ +exit=0 + +$ find festivals/CV0001 -type f | wc -l + 86 + +$ fest validate festivals/CV0001 +Score 100/100 — VALIDATION PASSED + +$ rg -i '' README.md | rg -v '' +(nothing) +$ rg -i '' README.md docs/ tapes/ festivals/CV0001/ +(nothing) +$ rg -c 'fest.build' README.md +1 + +$ diff -r -x CONTEXT.md -x input_specs -x .fest -x .workflow -x .festival-checksums.json -x '.review-*' \ + festivals/active/cans-v2-CV0001 projects/worktrees/cans/cans-v2/festivals/CV0001 +(nothing — the trees are identical under the six exclusions) +``` + +**PASS.** Committed with this phase. + ## 13. PR + +PR **#14** on `veronica-agent/cans`, `cans-v2` → `main`, opened under the `veronica-agent` account. The body was rewritten from this file at the end of the phase: why the change exists, the five features with their flags and exit codes, the Attempt 4 numbers with a citation to `festivals/CV0001/002_PLAN/inputs/measurements.md`, one line per bar item, the two known engine behaviours, and what is deliberately not in the PR. The three professional-surface patterns were run over the body before it was applied and all three printed nothing. + +CI and the final head SHA are recorded in the phase report. + +## Carry-over from `04_tape` — `docs/pipe.gif` re-cut + +`04_tape` cut the gif at 1-minute load 16.3–27.9 and assigned the re-cut here. Re-cut on a quiet box with no change to the tape: + +```bash +$ just vhs pipe +Creating docs/pipe.gif... +( ... ) 18.94s user 6.36s system 23% cpu 1:46.40 total + +load during the record: n=22 mean=4.89 min=4.54 max=5.35 below16=22 (100%) + +$ ffprobe -v error -show_entries format=duration,size -show_entries stream=width,height … +width=680 +height=300 +duration=91.520000 +size=148556 +``` + +| | committed by `04_tape` | re-cut here | +|---|---|---| +| Geometry | 680 × **380** | 680 × **300** — now matches `tapes/pipe.tape:13` | +| Duration | 123.44 s | **91.52 s** | +| Size | 168 370 B | **148 556 B** (0.14 MB, bar is 2 MB) | +| `ttfa_ms` on screen | 31377 / 34570 / 35202 | **26607 / 29461 / 14174** | +| Load while recording | 16.26–27.93 | 4.54–5.35 | + +The last frame was extracted (`ffmpeg -y -sseof -0.5 -i docs/pipe.gif -frames:v 1`) and read: the three JSON records, then `ls out` returning `001.wav 002.wav 003.wav`, then the prompt — the whole run fits the 300 px frame with no dead space, which is what the height change was for. + +The on-screen `ttfa_ms` are still 14–29 s for three short lines on an idle box. That is the mouth's end-of-speech variance, not load and not `--stream`: the same binary in item 6 did a one-shot in 7 157 ms and in item 5 in 5 627 ms. The gif is honest about what the field means — total synthesis time — and the README and the PR body both say so. + +## Known engine behaviour seen during this bar + +Neither is a defect in this branch; both are already deferred in `CONTEXT.md`, and both are named in the PR body so a stranger reading the numbers is not surprised. + +1. **End-of-speech variance.** The same one-shot text cost 5 627 / 5 652 / 5 669 / 5 798 / 7 157 ms in some runs and 27 241 ms in another, and stream lines ran 32–42 s. `ttfa_ms` is stamped at `final`, so it is total synthesis time; the variance is the worker's, not the caller's. +2. **Near-silent wavs.** 1 484-byte, ~0.03 s files returned as success after full synthesis time — one in item 1 (1 of 3), one in item 2 (1 of 8), one in item 3 (1 of 2). Matches the 4/50, 5/50, 1/24 rates measured in all three modes in `04_measure`, which is what identifies it as the mouth rather than `--stream`. A length floor that warns on a suspiciously short return is the cheap future guard. diff --git a/festivals/CV0001/004_REVIEW/PHASE_GOAL.md b/festivals/CV0001/004_REVIEW/PHASE_GOAL.md index 69ec599..6a4faed 100644 --- a/festivals/CV0001/004_REVIEW/PHASE_GOAL.md +++ b/festivals/CV0001/004_REVIEW/PHASE_GOAL.md @@ -24,7 +24,7 @@ fest_tracking: true Items that must pass this review: -- see BAR.md +- The 13-item bar below. Commands and verbatim output: `BAR.md`. @@ -32,7 +32,10 @@ Items that must pass this review: Criteria each item must meet: -- [ ] see BAR.md +- [x] Each item is a command a stranger can re-run, with its output recorded verbatim rather than paraphrased. +- [x] Real-mouth items taken one at a time, worker count 0 and 1-minute load < 16 before each. +- [x] Worker counts taken with an anchored `ps` counter, never `pgrep -f` (which self-matches and cross-matches other samplers). +- [x] Anything that did not match expectation is recorded as it happened, not smoothed over. @@ -40,7 +43,8 @@ Criteria each item must meet: | Stakeholder | Role | Status | Date | |-------------|------|--------|------| -| see BAR.md | see BAR.md | [ ] Approved | | +| Opus review agent | Orchestrator | [x] Approved | 2026-08-21 | +| Operator | Veto after the fact | [ ] Pending | | @@ -48,18 +52,21 @@ Criteria each item must meet: Gates that must pass before review completion: -- [ ] see BAR.md +- [x] Bar items 1–13 pass (`BAR.md`). +- [x] `docs/pipe.gif` re-cut under load < 16 — the carry-over from `04_tape`. +- [x] `festivals/CV0001/` re-synced with the recorded rsync; `fest validate` 100/100; greps clean; `diff -r` empty. +- [x] PR #14 body rewritten from `BAR.md` and CI green. ## Go/No-Go Decision -**Decision:** [ ] GO / [ ] NO-GO +**Decision:** [x] GO / [ ] NO-GO **Conditions for GO:** -- [ ] All review criteria passed -- [ ] All stakeholder sign-offs received -- [ ] All approval gates satisfied +- [x] All review criteria passed +- [x] All stakeholder sign-offs received (orchestrator; the operator may veto after the fact) +- [x] All approval gates satisfied **If NO-GO, actions required:** - Document blockers @@ -68,7 +75,7 @@ Gates that must pass before review completion: ## Notes -see BAR.md +`BAR.md` carries the commands and the verbatim output for all 13 items, plus the gif re-cut table and a closing section on the two known engine behaviours. Nothing in this phase changed code — the only tracked file it touches in the worktree is `docs/pipe.gif` (re-cut) and the `festivals/CV0001/` snapshot. --- @@ -78,23 +85,23 @@ see BAR.md | # | Check | Pass | |---|-------|------| -| 1 | `cat lines.txt \| cans say --stream -o 'out/%03d.wav'` writes one wav per line; `pgrep -f qwen3-tts-worker` shows **one** worker throughout; one GGUF load | [ ] | -| 2 | `xargs -P 8` over 24 lines completes with one worker resident at every sample, no pageouts | [ ] | -| 3 | Ctrl-C mid-stream: completed wavs remain, no orphaned worker, next `cans say` runs immediately, exit 130 | [ ] | -| 4 | `kill -9` on a running cans leaves the next run unblocked | [ ] | -| 5 | Booth session + background `cans say --nowait` → 75; background without `--nowait` waits with the stderr line | [ ] | -| 6 | `cans say "x"` matches `1e8cea2`; `CANS_NOPLAY=1 go test ./...` green; stream path runs on the fake worker | [ ] | -| 7 | 50-line stream beats the 50-call loop by the margin recorded in `002_PLAN/inputs/measurements.md` | [ ] | -| 8 | Professional-surface grep (`CONTEXT.md §Professional grep`) empty over README, docs/, tapes/, festivals/; exactly one Festival footer | [ ] | -| 9 | `just test unit` green; fresh `CANS_HOME` doctor green with the binary outside the checkout | [ ] | -| 10 | `git log origin/main..cans-v2 --format='%an <%ae>'` is only Veronica; no `Co-authored-by` | [ ] | -| 11 | `fest validate` green on this festival and on `festivals/CV0001/` in the repo | [ ] | -| 12 | Snapshot re-synced after this review's statuses are set; committed | [ ] | -| 13 | PR opened from `cans-v2` to `main` on `veronica-agent/cans` under `veronica-agent`, body from `BAR.md`, CI green | [ ] | +| 1 | `cat lines.txt \| cans say --stream -o 'out/%03d.wav'` writes one wav per line; `pgrep -f qwen3-tts-worker` shows **one** worker throughout; one GGUF load | [x] | +| 2 | `xargs -P 8` over 24 lines completes with one worker resident at every sample, no pageouts | [x] | +| 3 | Ctrl-C mid-stream: completed wavs remain, no orphaned worker, next `cans say` runs immediately, exit 130 | [x] | +| 4 | `kill -9` on a running cans leaves the next run unblocked | [x] | +| 5 | Booth session + background `cans say --nowait` → 75; background without `--nowait` waits with the stderr line | [x] | +| 6 | `cans say "x"` matches `1e8cea2`; `CANS_NOPLAY=1 go test ./...` green; stream path runs on the fake worker | [x] | +| 7 | 50-line stream beats the 50-call loop by the margin recorded in `002_PLAN/inputs/measurements.md` | [x] | +| 8 | Professional-surface grep (`CONTEXT.md §Professional grep`) empty over README, docs/, tapes/, festivals/; exactly one Festival footer | [x] | +| 9 | `just test unit` green; fresh `CANS_HOME` doctor green with the binary outside the checkout | [x] | +| 10 | `git log origin/main..cans-v2 --format='%an <%ae>'` is only Veronica; no `Co-authored-by` | [x] | +| 11 | `fest validate` green on this festival and on `festivals/CV0001/` in the repo | [x] | +| 12 | Snapshot re-synced after this review's statuses are set; committed | [x] | +| 13 | PR opened from `cans-v2` to `main` on `veronica-agent/cans` under `veronica-agent`, body from `BAR.md`, CI green | [x] | ## Sign-off | Role | Who | Date | Verdict | |------|-----|------|---------| -| Orchestrator | | | | +| Orchestrator | Opus review agent | 2026-08-21 | **GO — 13 / 13 pass.** Every item run on the real mouth one at a time on a quiet box (1-minute load 4.6–7.6 throughout); commands and verbatim output in `BAR.md`. Two known engine faults reappeared (end-of-speech variance; near-silent wavs returned as success) — recorded, deferred, and named in the PR body; neither is a defect in this branch. | | Operator | | | (after the fact — may veto) | From 3c58f9a8d449e1da44378db94dfd1581a816ab41 Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:48:06 -0600 Subject: [PATCH 8/9] =?UTF-8?q?[veronica:ea389d71-WI-a2e393]=20fest:=20CV0?= =?UTF-8?q?001=20completed=20=E2=80=94=20snapshot=20statuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The festival was promoted to completed after the 004_REVIEW bar passed; the public copy's phase and festival status fields now say so, matching how CA0001 reads. No code change. --- festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md | 4 +++- festivals/CV0001/004_REVIEW/PHASE_GOAL.md | 6 ++++-- festivals/CV0001/FESTIVAL_GOAL.md | 5 +++-- festivals/CV0001/fest.yaml | 3 +++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md b/festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md index 2c86823..7289ea7 100644 --- a/festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md +++ b/festivals/CV0001/003_IMPLEMENT/PHASE_GOAL.md @@ -4,12 +4,14 @@ fest_id: 003_IMPLEMENT fest_name: IMPLEMENT fest_parent: cans-v2-CV0001 fest_order: 3 -fest_status: pending +fest_status: completed fest_created: 2026-08-21T05:03:56.848822-06:00 +fest_updated: 2026-08-21T18:47:47.966647-06:00 fest_phase_type: implementation fest_tracking: true --- + # Phase Goal: 003_IMPLEMENT **Phase:** 003_IMPLEMENT | **Status:** Pending | **Type:** Implementation diff --git a/festivals/CV0001/004_REVIEW/PHASE_GOAL.md b/festivals/CV0001/004_REVIEW/PHASE_GOAL.md index 6a4faed..8e2f06c 100644 --- a/festivals/CV0001/004_REVIEW/PHASE_GOAL.md +++ b/festivals/CV0001/004_REVIEW/PHASE_GOAL.md @@ -4,12 +4,14 @@ fest_id: 004_REVIEW fest_name: REVIEW fest_parent: cans-v2-CV0001 fest_order: 4 -fest_status: pending +fest_status: completed fest_created: 2026-08-21T05:03:56.979736-06:00 +fest_updated: 2026-08-21T18:47:06.60834-06:00 fest_phase_type: review fest_tracking: true --- + # Phase Goal: 004_REVIEW **Phase:** 004_REVIEW | **Status:** Pending | **Type:** Review @@ -104,4 +106,4 @@ Gates that must pass before review completion: | Role | Who | Date | Verdict | |------|-----|------|---------| | Orchestrator | Opus review agent | 2026-08-21 | **GO — 13 / 13 pass.** Every item run on the real mouth one at a time on a quiet box (1-minute load 4.6–7.6 throughout); commands and verbatim output in `BAR.md`. Two known engine faults reappeared (end-of-speech variance; near-silent wavs returned as success) — recorded, deferred, and named in the PR body; neither is a defect in this branch. | -| Operator | | | (after the fact — may veto) | +| Operator | | | (after the fact — may veto) | \ No newline at end of file diff --git a/festivals/CV0001/FESTIVAL_GOAL.md b/festivals/CV0001/FESTIVAL_GOAL.md index cea8446..6087914 100644 --- a/festivals/CV0001/FESTIVAL_GOAL.md +++ b/festivals/CV0001/FESTIVAL_GOAL.md @@ -2,14 +2,15 @@ fest_type: festival fest_id: CV0001 fest_name: cans-v2 -fest_status: active +fest_status: completed fest_created: 2026-08-21T04:32:49.124639-06:00 -fest_updated: 2026-08-21T05:14:26.058302-06:00 +fest_updated: 2026-08-21T18:47:48.044206-06:00 fest_tracking: true --- + # cans-v2 **Status:** Planning | **Created:** 2026-08-21 diff --git a/festivals/CV0001/fest.yaml b/festivals/CV0001/fest.yaml index 37f5b02..16d23f2 100644 --- a/festivals/CV0001/fest.yaml +++ b/festivals/CV0001/fest.yaml @@ -18,6 +18,9 @@ metadata: - status: active timestamp: 2026-08-21T05:14:26.058564-06:00 path: festivals/active/cans-v2-CV0001 + - status: completed + timestamp: 2026-08-21T18:47:48.044444-06:00 + path: festivals/.dungeon/completed/2026-08-21/cans-v2-CV0001 project_path: projects/cans type_config: auto_phases: From f4c46bff540d59ca30758616d875bab634ed75d5 Mon Sep 17 00:00:00 2001 From: Veronica <318153306+veronica-agent@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:28:39 -0600 Subject: [PATCH 9/9] [veronica:ea389d71-WI-a2e393] fix: omit wav from --json when there is no -o Without -o the file is a temp that playTail deletes. Printing its path in the JSON record handed scripts a dead file. wav is now omitted unless -o named a surviving path. --- README.md | 4 ++-- internal/say/say.go | 14 +++++++++++++- internal/say/say_test.go | 10 ++++++++-- internal/say/stream.go | 8 ++++++-- internal/say/stream_test.go | 25 +++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 459ca47..7dac915 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ cans say --stream --json -o 'out/%03d.wav' < lines.txt | jq -r 'select(.error==n |------|--------| | `-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"`. | +| `--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. | @@ -115,7 +115,7 @@ stdout carries data — a JSON record, a wav path, or `ttfa_ms=N`, the worker's 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. +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. --- diff --git a/internal/say/say.go b/internal/say/say.go index 419128b..c5feb07 100644 --- a/internal/say/say.go +++ b/internal/say/say.go @@ -74,12 +74,24 @@ func playTail(o Options, wav string) error { return nil } +// jsonShot is a one-shot --json record. wav is omitted when there is no -o: +// that file is a temp that playTail deletes, and a script must not trust it. +type jsonShot struct { + Wav string `json:"wav,omitempty"` + TTFAMs int `json:"ttfa_ms"` + SampleRate int `json:"sample_rate"` +} + // emit writes the one record for an utterance: a JSON line, the wav path, or // the v1 ttfa_ms line. stdout carries nothing else. func emit(stdout io.Writer, o Options, r tts.Result) error { switch { case o.JSON: - return json.NewEncoder(stdout).Encode(r) + rec := jsonShot{TTFAMs: r.TTFAMs, SampleRate: r.SampleRate} + if o.Out != "" { + rec.Wav = r.Wav + } + return json.NewEncoder(stdout).Encode(rec) case o.Out != "": _, err := fmt.Fprintln(stdout, r.Wav) return err diff --git a/internal/say/say_test.go b/internal/say/say_test.go index 794c568..402ff4b 100644 --- a/internal/say/say_test.go +++ b/internal/say/say_test.go @@ -217,7 +217,7 @@ func TestRunStdinIsOneUtterance(t *testing.T) { } func TestRunJSONRecord(t *testing.T) { - sayBinEnv(t) + fake := sayBinEnv(t) o := DefaultOptions() o.Text = "Put the cans on." o.JSON = true @@ -229,13 +229,19 @@ func TestRunJSONRecord(t *testing.T) { if len(lines) != 1 { t.Fatalf("stdout %q, want one record", out.String()) } + if strings.Contains(lines[0], `"wav"`) { + t.Fatalf("wav in record without -o: %s", lines[0]) + } var r tts.Result if err := json.Unmarshal([]byte(lines[0]), &r); err != nil { t.Fatalf("stdout %q: %v", lines[0], err) } - if r.TTFAMs != 12 || r.SampleRate != 24000 { + if r.Wav != "" || r.TTFAMs != 12 || r.SampleRate != 24000 { t.Fatalf("record %+v", r) } + if _, err := os.Stat(fake.spoken); err == nil { + t.Fatal("temp wav should be gone after --json without -o") + } } func TestRunNowaitBusy(t *testing.T) { diff --git a/internal/say/stream.go b/internal/say/stream.go index 4ed2c6b..181a704 100644 --- a/internal/say/stream.go +++ b/internal/say/stream.go @@ -18,7 +18,7 @@ var errLineFailed = errors.New("line failed") type okRecord struct { Line int `json:"line"` - Wav string `json:"wav"` + Wav string `json:"wav,omitempty"` TTFAMs int `json:"ttfa_ms"` SampleRate int `json:"sample_rate"` } @@ -119,7 +119,11 @@ func (s *streamer) speak(ctx context.Context, line string, lineNo, idx int) erro _ = s.emit(errRecord{Line: lineNo, Error: err.Error()}) return errLineFailed } - if err := s.emit(okRecord{Line: lineNo, Wav: r.Wav, TTFAMs: r.TTFAMs, SampleRate: r.SampleRate}); err != nil { + rec := okRecord{Line: lineNo, TTFAMs: r.TTFAMs, SampleRate: r.SampleRate} + if s.o.Out != "" { + rec.Wav = r.Wav + } + if err := s.emit(rec); err != nil { fmt.Fprintln(s.stderr, err) return errLineFailed } diff --git a/internal/say/stream_test.go b/internal/say/stream_test.go index 51cc338..a07aadd 100644 --- a/internal/say/stream_test.go +++ b/internal/say/stream_test.go @@ -73,6 +73,31 @@ func TestStreamJSONRecords(t *testing.T) { if recs[1]["error"] == nil { t.Fatalf("middle record %+v", recs[1]) } + if recs[0]["wav"] != filepath.Join(dir, "001.wav") { + t.Fatalf("wav %+v", recs[0]["wav"]) + } +} + +func TestStreamJSONWithoutOutOmitsWav(t *testing.T) { + fakeWorkerEnv(t) + o := DefaultOptions() + o.Stream = true + o.JSON = true + var out, errBuf bytes.Buffer + code := Run(context.Background(), o, strings.NewReader("Put the cans on.\n"), &out, &errBuf) + if code != ExitOK { + t.Fatalf("code %d stderr %q", code, errBuf.String()) + } + if strings.Contains(out.String(), `"wav"`) { + t.Fatalf("wav in record without -o: %s", out.String()) + } + var rec map[string]any + if err := json.Unmarshal(out.Bytes(), &rec); err != nil { + t.Fatalf("stdout %q: %v", out.String(), err) + } + if rec["line"] != float64(1) || rec["ttfa_ms"] == nil { + t.Fatalf("record %+v", rec) + } } func TestStreamOneWorker(t *testing.T) {