diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 409bcf7..37f7710 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -76,7 +76,11 @@ Plans:
1. User can run `netsynth -i eth0 --filter "port 53"` and only DNS traffic is captured and sonified
2. User can run `netsynth --read capture.pcap -o out.mp3` against an existing pcap file and receive a valid MP3
3. An invalid BPF filter expression produces a clear error message before any capture begins
-**Plans**: TBD
+**Plans:** 2 plans
+
+Plans:
+- [ ] 04-01-PLAN.md — BPF validation, pcap file reading, timestamp-based aggregation (core library functions)
+- [ ] 04-02-PLAN.md — Wire --filter and --read flags into CLI with branching run logic
## Progress
@@ -88,4 +92,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4
| 1. Capture and Classification | 4/4 | Complete | 2026-03-25 |
| 2. Audio Synthesis Engine | 3/3 | Complete | 2026-03-26 |
| 3. Pipeline Integration and MVP | 2/2 | Complete | 2026-03-26 |
-| 4. Power User Features | 0/? | Not started | - |
+| 4. Power User Features | 0/2 | Not started | - |
diff --git a/.planning/phases/04-power-user-features/04-01-PLAN.md b/.planning/phases/04-power-user-features/04-01-PLAN.md
new file mode 100644
index 0000000..7463e3f
--- /dev/null
+++ b/.planning/phases/04-power-user-features/04-01-PLAN.md
@@ -0,0 +1,383 @@
+---
+phase: 04-power-user-features
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - classify/types.go
+ - capture/capture.go
+ - capture/bpf.go
+ - capture/pcap_reader.go
+ - aggregate/window.go
+ - capture/capture_test.go
+ - capture/bpf_test.go
+ - capture/pcap_reader_test.go
+ - aggregate/window_test.go
+autonomous: true
+requirements: [CAPT-05, CAPT-06]
+
+must_haves:
+ truths:
+ - "BPF filter expressions can be validated without a live socket"
+ - "A valid pcap file can be read and packets emitted on a channel"
+ - "Invalid/missing pcap files produce clear error messages"
+ - "Pcap packets are aggregated into time windows using packet timestamps, not wall clock"
+ - "Gap windows (no packets) produce empty snapshots preserving silence"
+ - "BPF filter can be applied to live capture handles"
+ - "Software BPF filtering works for pcap file packets"
+ artifacts:
+ - path: "capture/bpf.go"
+ provides: "BPF validation and software filter compilation"
+ exports: ["ValidateBPFFilter", "CompileSoftwareBPF"]
+ - path: "capture/pcap_reader.go"
+ provides: "Pcap file reading into packet channel"
+ exports: ["ReadPcapFile"]
+ - path: "aggregate/window.go"
+ provides: "Timestamp-based aggregation for pcap mode"
+ exports: ["AggregatePcap"]
+ - path: "classify/types.go"
+ provides: "Timestamp field on ClassifiedPacket"
+ contains: "Timestamp time.Time"
+ key_links:
+ - from: "capture/pcap_reader.go"
+ to: "gopacket/pcapgo"
+ via: "pcapgo.NewReader"
+ pattern: "pcapgo\\.NewReader"
+ - from: "capture/bpf.go"
+ to: "packetcap/go-pcap/filter"
+ via: "filter.NewExpression"
+ pattern: "filter\\.NewExpression"
+ - from: "aggregate/window.go"
+ to: "classify/types.go"
+ via: "ClassifiedPacket.Timestamp for window assignment"
+ pattern: "ev\\.Timestamp"
+---
+
+
+Add core library functions for BPF filtering and pcap file reading.
+
+Purpose: Build the foundation that Plan 02 wires into the CLI. BPF validation, pcap reading, software BPF matching, and timestamp-based aggregation are all independently testable library functions.
+Output: New files capture/bpf.go, capture/pcap_reader.go; updated classify/types.go (Timestamp field), updated capture/capture.go (filter param on OpenCapture), updated aggregate/window.go (AggregatePcap); all with tests.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/04-power-user-features/04-CONTEXT.md
+@.planning/phases/04-power-user-features/04-RESEARCH.md
+
+@capture/capture.go
+@capture/capture_test.go
+@classify/types.go
+@classify/classifier.go
+@aggregate/window.go
+@aggregate/window_test.go
+
+
+
+
+From classify/types.go:
+```go
+type TrafficClass string
+type ClassifiedPacket struct {
+ Class TrafficClass
+ SrcPort uint16
+ DstPort uint16
+ Protocol string
+ Length int
+}
+type WindowSnapshot struct {
+ Counts map[TrafficClass]int64
+ TotalPackets int64
+ WindowIndex int
+}
+```
+
+From capture/capture.go:
+```go
+func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error)
+func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error)
+```
+
+From aggregate/window.go:
+```go
+const DefaultWindowMs = 500
+func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) <-chan classify.WindowSnapshot
+```
+
+From go-pcap (verified in module cache):
+```go
+// pcap.Handle method:
+func (h *Handle) SetBPFFilter(expr string) error
+
+// filter package:
+func NewExpression(expr string) *Expression
+func (e *Expression) Compile() *Filter
+func (f *Filter) Compile() ([]bpf.Instruction, error)
+```
+
+From gopacket/pcapgo (verified in module cache):
+```go
+func NewReader(r io.Reader) (*Reader, error)
+func (r *Reader) LinkType() layers.LinkType
+// Reader implements gopacket.PacketDataSource
+```
+
+From golang.org/x/net/bpf:
+```go
+func NewVM(filter []bpf.Instruction) (*VM, error)
+func (vm *VM) Run(in []byte) (int, error) // >0 means packet passes
+```
+
+
+
+
+
+
+ Task 1: Add Timestamp to ClassifiedPacket, BPF validation, pcap reading, and software BPF filter
+ classify/types.go, capture/bpf.go, capture/pcap_reader.go, capture/capture.go, capture/bpf_test.go, capture/pcap_reader_test.go, capture/capture_test.go
+ classify/types.go, capture/capture.go, capture/capture_test.go, classify/classifier.go
+
+ - TestValidateBPFFilter: ValidateBPFFilter("port 53") returns nil; ValidateBPFFilter("invalid garbage xyz") returns non-nil error containing "invalid BPF filter"
+ - TestValidateBPFFilterEmpty: ValidateBPFFilter("") returns nil (empty = no filter)
+ - TestCompileSoftwareBPF: CompileSoftwareBPF("tcp") returns a non-nil *bpf.VM and nil error
+ - TestCompileSoftwareBPFInvalid: CompileSoftwareBPF("invalid garbage") returns nil VM and non-nil error
+ - TestReadPcapFile: ReadPcapFile with a programmatically-generated pcap (using pcapgo.NewWriter to write 3 test packets) returns a channel that yields exactly 3 packets, then closes
+ - TestReadPcapFileNotFound: ReadPcapFile("/nonexistent/file.pcap", "") returns error containing "cannot open"
+ - TestReadPcapFileInvalid: ReadPcapFile on a non-pcap file (e.g., a temp file with garbage bytes) returns error containing "invalid pcap file" or "not a valid pcap"
+ - TestReadPcapFileWithFilter: ReadPcapFile with filter="tcp" on a pcap containing both TCP and UDP packets returns only TCP packets
+ - TestOpenCaptureWithFilter: OpenCapture now accepts a filter string parameter (signature change verified by compilation)
+ - TestClassifiedPacketTimestamp: ClassifiedPacket struct has a Timestamp field of type time.Time
+
+
+ **1. Update classify/types.go** — Add `Timestamp time.Time` field to `ClassifiedPacket`:
+ ```go
+ import "time"
+
+ type ClassifiedPacket struct {
+ Class TrafficClass
+ SrcPort uint16
+ DstPort uint16
+ Protocol string
+ Length int
+ Timestamp time.Time // Set from pkt.Metadata().CaptureInfo.Timestamp; zero in live mode
+ }
+ ```
+ This is a backward-compatible addition. Live mode leaves it zero-valued (no behavior change). Per D-01, pcap mode uses it for window assignment.
+
+ **2. Create capture/bpf.go** — Two exported functions:
+
+ ```go
+ package capture
+
+ import (
+ "fmt"
+ "strings"
+
+ "golang.org/x/net/bpf"
+ gpcapfilter "github.com/packetcap/go-pcap/filter"
+ )
+
+ // ValidateBPFFilter checks if a BPF expression string is valid without needing a live socket.
+ // Returns nil for empty strings (empty = no filter). Per CAPT-05.
+ func ValidateBPFFilter(expr string) error {
+ if strings.TrimSpace(expr) == "" {
+ return nil
+ }
+ e := gpcapfilter.NewExpression(expr)
+ if e == nil {
+ return fmt.Errorf("invalid BPF filter expression: %q", expr)
+ }
+ compiled := e.Compile()
+ if _, err := compiled.Compile(); err != nil {
+ return fmt.Errorf("invalid BPF filter %q: %v", expr, err)
+ }
+ return nil
+ }
+
+ // CompileSoftwareBPF compiles a BPF expression into a VM for user-space packet matching.
+ // Used for --read mode where kernel BPF is unavailable. Per D-04 (filter works with --read).
+ func CompileSoftwareBPF(expr string) (*bpf.VM, error) {
+ e := gpcapfilter.NewExpression(expr)
+ if e == nil {
+ return nil, fmt.Errorf("invalid BPF filter expression: %q", expr)
+ }
+ instructions, err := e.Compile().Compile()
+ if err != nil {
+ return nil, fmt.Errorf("BPF compile error for %q: %v", expr, err)
+ }
+ return bpf.NewVM(instructions)
+ }
+ ```
+
+ **3. Create capture/pcap_reader.go** — ReadPcapFile function:
+
+ ```go
+ package capture
+
+ import (
+ "fmt"
+ "os"
+
+ "github.com/gopacket/gopacket"
+ "github.com/gopacket/gopacket/layers"
+ "github.com/gopacket/gopacket/pcapgo"
+ "golang.org/x/net/bpf"
+ )
+
+ // ReadPcapFile opens a pcap file and returns a channel of packets.
+ // If filter is non-empty, applies software BPF filtering (kernel BPF unavailable for files).
+ // Per D-03: replaces -i as packet source. Per D-04: --filter works with --read.
+ func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("cannot open pcap file %q: %w", path, err)
+ }
+ r, err := pcapgo.NewReader(f)
+ if err != nil {
+ f.Close()
+ return nil, fmt.Errorf("not a valid pcap file %q: %w", path, err)
+ }
+
+ // Compile software BPF filter if provided
+ var vm *bpf.VM
+ if filter != "" {
+ vm, err = CompileSoftwareBPF(filter)
+ if err != nil {
+ f.Close()
+ return nil, err
+ }
+ }
+
+ lt := layers.LinkType(r.LinkType())
+ packetSource := gopacket.NewPacketSource(r, lt)
+ packetSource.NoCopy = false // CRITICAL: do NOT use NoCopy=true with pcapgo (Pitfall 3)
+
+ packets := make(chan gopacket.Packet, 512)
+ go func() {
+ defer close(packets)
+ defer f.Close()
+ for pkt := range packetSource.Packets() {
+ // Apply software BPF filter if active
+ if vm != nil {
+ result, err := vm.Run(pkt.Data())
+ if err != nil || result == 0 {
+ continue // packet does not match filter
+ }
+ }
+ packets <- pkt
+ }
+ }()
+ return packets, nil
+ }
+ ```
+
+ **4. Update capture/capture.go** — Add `filter string` parameter to `OpenCapture` and `StartCapture`. In `OpenCapture`, after successfully opening the handle, call `handle.SetBPFFilter(filter)` if filter is non-empty. If SetBPFFilter fails, close the handle and return error wrapped with `fmt.Errorf("invalid BPF filter %q: %w", filter, err)`. Update `StartCapture` signature to `StartCapture(ctx context.Context, iface string, filter string)` and pass filter through to `OpenCapture`.
+
+ **5. Create test files:**
+ - `capture/bpf_test.go` — Tests for ValidateBPFFilter and CompileSoftwareBPF per behavior list above.
+ - `capture/pcap_reader_test.go` — Uses `pcapgo.NewWriter` to create temporary pcap files programmatically (no binary fixtures). Write a helper `createTestPcap(t *testing.T, packets [][]byte) string` that creates a temp file with pcapgo.NewWriter, writes Ethernet+IP+TCP/UDP raw packets, and returns the path. Test ReadPcapFile with valid pcap, nonexistent file, invalid file (garbage bytes), and filter.
+ - `capture/capture_test.go` — Add TestOpenCaptureSignature that verifies the new signature compiles (the existing tests don't call OpenCapture directly due to privilege requirements; just ensure the file compiles with the new signature).
+
+ **6. Update cmd/netsynth/main.go call site** — The call `capture.StartCapture(ctx, ifaceName)` must become `capture.StartCapture(ctx, ifaceName, "")` to match the updated signature. This is a minimal change to keep existing code compiling. (The actual --filter flag wiring happens in Plan 02.)
+
+
+ cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./capture/... -run "TestValidateBPF|TestCompileSoftware|TestReadPcap|TestClassifiedPacket" -v -count=1 && go build ./...
+
+
+ - classify/types.go contains `Timestamp time.Time` inside ClassifiedPacket struct
+ - capture/bpf.go contains `func ValidateBPFFilter(expr string) error`
+ - capture/bpf.go contains `func CompileSoftwareBPF(expr string) (*bpf.VM, error)`
+ - capture/pcap_reader.go contains `func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error)`
+ - capture/pcap_reader.go contains `packetSource.NoCopy = false`
+ - capture/capture.go contains `func OpenCapture(ctx context.Context, iface string, filter string)`
+ - capture/capture.go contains `handle.SetBPFFilter(filter)`
+ - capture/capture.go contains `func StartCapture(ctx context.Context, iface string, filter string)`
+ - cmd/netsynth/main.go contains `capture.StartCapture(ctx, ifaceName, "")`
+ - `go test ./capture/... -v` passes all new tests
+ - `go build ./...` succeeds (all packages compile)
+
+ BPF validation rejects invalid expressions and accepts valid ones. Pcap reader opens valid files and returns packet channels, rejects invalid/missing files. Software BPF filters pcap packets. OpenCapture/StartCapture accept filter parameter. ClassifiedPacket has Timestamp field. All tests pass, all packages compile.
+
+
+
+ Task 2: Timestamp-based pcap aggregation with gap-filling
+ aggregate/window.go, aggregate/window_test.go
+ aggregate/window.go, aggregate/window_test.go, classify/types.go
+
+ - TestAggregatePcapBasic: AggregatePcap with 3 packets at times T+0ms, T+100ms, T+200ms (all in window 0 for 500ms windows) returns 1 snapshot with TotalPackets=3
+ - TestAggregatePcapMultipleWindows: AggregatePcap with packets at T+0ms, T+600ms, T+1200ms returns 3 snapshots (window 0, 1, 2) each with TotalPackets=1
+ - TestAggregatePcapGaps: AggregatePcap with packets at T+0ms and T+1500ms (skip window 1 and 2) returns 4 snapshots: window 0 with 1 packet, windows 1-2 with TotalPackets=0, window 3 with 1 packet (per D-02: gaps are silent)
+ - TestAggregatePcapEmpty: AggregatePcap with empty channel returns empty slice (no snapshots)
+ - TestAggregatePcapWindowIndex: Each returned snapshot has correct sequential WindowIndex 0,1,2,...
+ - TestAggregatePcapClassCounts: Packets of different classes in same window have correct per-class counts in snapshot.Counts
+ - TestAggregatePcapOnSnapshot: If onSnapshot callback is provided, it is called for each emitted snapshot (for --verbose support per D-07)
+
+
+ Add `AggregatePcap` function to `aggregate/window.go`. This function reads from a `<-chan classify.ClassifiedPacket` (same channel type as live mode), collects all events into a slice, then assigns each to a time window using `pkt.Timestamp.Sub(firstTimestamp).Milliseconds() / windowMs`.
+
+ **Function signature:**
+ ```go
+ // AggregatePcap reads all ClassifiedPackets (with Timestamp set), assigns to time windows
+ // using packet timestamps per D-01, fills gap windows with empty snapshots per D-02,
+ // and returns all WindowSnapshots. Calls onSnapshot for each if non-nil (D-07: --verbose).
+ // Returns synchronously after channel closes (pcap processing is finite).
+ func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot
+ ```
+
+ **Implementation details:**
+ 1. Drain the `events` channel into a `[]classify.ClassifiedPacket` slice.
+ 2. If slice is empty, return `nil` (empty slice).
+ 3. Find `minTimestamp` by scanning all events (handles non-monotonic pcaps per Pitfall 5).
+ 4. Compute `maxWindowIdx` = `int(maxTimestamp.Sub(minTimestamp).Milliseconds()) / windowMs`.
+ 5. Create `snapshots := make([]classify.WindowSnapshot, maxWindowIdx+1)`. Initialize each with `Counts: make(map[classify.TrafficClass]int64)` and `WindowIndex: i`.
+ 6. For each event, compute `idx := int(ev.Timestamp.Sub(minTimestamp).Milliseconds()) / windowMs`. Clamp to `[0, maxWindowIdx]` for safety. Increment `snapshots[idx].Counts[ev.Class]++` and `snapshots[idx].TotalPackets++`.
+ 7. If `onSnapshot` is non-nil, call it for each snapshot in order (supports --verbose per D-07).
+ 8. Return `snapshots`.
+
+ **Tests in aggregate/window_test.go** — Add test functions per behavior list. Use helper that creates `classify.ClassifiedPacket` values with specific `Timestamp` values relative to a base time `time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)`. Send packets into a buffered channel, close it, then call `AggregatePcap`.
+
+
+ cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./aggregate/... -run "TestAggregatePcap" -v -count=1
+
+
+ - aggregate/window.go contains `func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot`
+ - aggregate/window_test.go contains `TestAggregatePcapBasic`
+ - aggregate/window_test.go contains `TestAggregatePcapGaps`
+ - aggregate/window_test.go contains `TestAggregatePcapEmpty`
+ - `go test ./aggregate/... -run TestAggregatePcap -v` shows all tests PASS
+ - Gap test verifies that windows with no packets have TotalPackets=0
+
+ AggregatePcap assigns packets to correct time windows using timestamps (D-01). Gap windows produce empty snapshots with TotalPackets=0 (D-02). onSnapshot callback fires for each window (D-07). All aggregation tests pass.
+
+
+
+
+
+Full package compilation and test suite:
+```bash
+cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./... -count=1 && go build ./...
+```
+All existing tests must continue to pass. All new tests must pass. `go build ./...` must succeed.
+
+
+
+1. `ValidateBPFFilter("port 53")` returns nil; `ValidateBPFFilter("invalid xyz")` returns error
+2. `ReadPcapFile` reads programmatically-generated pcap files and emits correct packet count
+3. `ReadPcapFile` with filter only passes matching packets
+4. `AggregatePcap` assigns packets to correct windows and fills gaps with empty snapshots
+5. `OpenCapture` and `StartCapture` accept filter parameter
+6. `ClassifiedPacket` has `Timestamp time.Time` field
+7. `go test ./...` all green, `go build ./...` succeeds
+
+
+
diff --git a/.planning/phases/04-power-user-features/04-02-PLAN.md b/.planning/phases/04-power-user-features/04-02-PLAN.md
new file mode 100644
index 0000000..e731425
--- /dev/null
+++ b/.planning/phases/04-power-user-features/04-02-PLAN.md
@@ -0,0 +1,332 @@
+---
+phase: 04-power-user-features
+plan: 02
+type: execute
+wave: 2
+depends_on: [04-01]
+files_modified:
+ - cmd/netsynth/main.go
+ - cmd/netsynth/main_test.go
+autonomous: true
+requirements: [CAPT-05, CAPT-06]
+
+must_haves:
+ truths:
+ - "User can run netsynth -i eth0 --filter 'port 53' and only matching traffic is captured"
+ - "User can run netsynth --read capture.pcap -o out.mp3 and receive a valid MP3"
+ - "An invalid BPF filter produces a clear error before any capture begins"
+ - "--read and -i are mutually exclusive with a clear error message"
+ - "--read without -o derives output filename from input pcap (capture.pcap -> capture.mp3)"
+ - "--read displays bookend messages: 'Reading ...' at start, summary + 'Saved' at end"
+ - "--verbose works with --read showing per-window activity"
+ artifacts:
+ - path: "cmd/netsynth/main.go"
+ provides: "CLI wiring for --filter and --read flags with branching run logic"
+ contains: ["--filter", "--read", "ReadPcapFile", "AggregatePcap", "deriveOutputPath"]
+ key_links:
+ - from: "cmd/netsynth/main.go"
+ to: "capture/bpf.go"
+ via: "ValidateBPFFilter call before capture"
+ pattern: "capture\\.ValidateBPFFilter"
+ - from: "cmd/netsynth/main.go"
+ to: "capture/pcap_reader.go"
+ via: "ReadPcapFile for --read mode"
+ pattern: "capture\\.ReadPcapFile"
+ - from: "cmd/netsynth/main.go"
+ to: "aggregate/window.go"
+ via: "AggregatePcap for pcap mode"
+ pattern: "aggregate\\.AggregatePcap"
+---
+
+
+Wire --filter and --read flags into the CLI, branching run() into live and pcap paths.
+
+Purpose: Complete the user-facing features CAPT-05 and CAPT-06 by connecting the library functions from Plan 01 into the Cobra CLI.
+Output: Updated cmd/netsynth/main.go with both flags, mutual exclusion, filename derivation, bookend messages, and pcap processing path. Tests for flag interactions.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/04-power-user-features/04-CONTEXT.md
+@.planning/phases/04-power-user-features/04-RESEARCH.md
+@.planning/phases/04-power-user-features/04-01-SUMMARY.md
+
+@cmd/netsynth/main.go
+@cmd/netsynth/main_test.go
+
+
+
+
+From capture/bpf.go (created in Plan 01):
+```go
+func ValidateBPFFilter(expr string) error
+func CompileSoftwareBPF(expr string) (*bpf.VM, error)
+```
+
+From capture/pcap_reader.go (created in Plan 01):
+```go
+func ReadPcapFile(path string, filter string) (<-chan gopacket.Packet, error)
+```
+
+From capture/capture.go (updated in Plan 01):
+```go
+func OpenCapture(ctx context.Context, iface string, filter string) (*pcap.Handle, error)
+func StartCapture(ctx context.Context, iface string, filter string) (<-chan gopacket.Packet, *int64, error)
+```
+
+From aggregate/window.go (updated in Plan 01):
+```go
+func AggregatePcap(events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) []classify.WindowSnapshot
+```
+
+From classify/types.go (updated in Plan 01):
+```go
+type ClassifiedPacket struct {
+ Class TrafficClass
+ SrcPort uint16
+ DstPort uint16
+ Protocol string
+ Length int
+ Timestamp time.Time
+}
+```
+
+Existing from cmd/netsynth/main.go:
+```go
+var (
+ ifaceName string
+ listIfaces bool
+ verbose bool
+ outputPath string
+)
+func run(cmd *cobra.Command, args []string) error
+```
+
+
+
+
+
+
+ Task 1: Add --filter and --read flags with branching run logic
+ cmd/netsynth/main.go, cmd/netsynth/main_test.go
+ cmd/netsynth/main.go, cmd/netsynth/main_test.go, capture/bpf.go, capture/pcap_reader.go, aggregate/window.go
+
+ - TestFlagMutualExclusion: Running with both --read and -i returns error containing "mutually exclusive"
+ - TestMissingSource: Running without --read and without -i returns error containing "interface required" and mentions "--read"
+ - TestDeriveOutputPath: deriveOutputPath("capture.pcap") returns "capture.mp3"; deriveOutputPath("/tmp/net.pcap") returns "/tmp/net.mp3"; deriveOutputPath("noext") returns "noext.mp3"
+ - TestFilterFlagRegistered: The root command has a --filter flag registered
+ - TestReadFlagRegistered: The root command has a --read flag registered
+ - TestInvalidBPFFilter: Running with -i lo --filter "invalid garbage xyz" returns error containing "invalid BPF filter" (no capture started)
+ - TestHelpOutputNewFlags: --help output contains "--filter" and "--read"
+
+
+ **1. Add new global variables and flag registration in main():**
+ ```go
+ var (
+ ifaceName string
+ listIfaces bool
+ verbose bool
+ outputPath string
+ bpfFilter string // NEW: --filter flag
+ readPath string // NEW: --read flag
+ )
+ ```
+ In `main()`, add after existing flag registrations:
+ ```go
+ rootCmd.Flags().StringVar(&bpfFilter, "filter", "", "BPF filter expression (tcpdump syntax, e.g. \"port 53\")")
+ rootCmd.Flags().StringVar(&readPath, "read", "", "Read packets from pcap file instead of live capture")
+ ```
+
+ **2. Add deriveOutputPath helper function:**
+ ```go
+ // deriveOutputPath replaces the file extension with .mp3 per D-05.
+ // "capture.pcap" -> "capture.mp3", "noext" -> "noext.mp3"
+ func deriveOutputPath(readPath string) string {
+ ext := filepath.Ext(readPath)
+ if ext == "" {
+ return readPath + ".mp3"
+ }
+ return strings.TrimSuffix(readPath, ext) + ".mp3"
+ }
+ ```
+ Add `"path/filepath"` to imports.
+
+ **3. Rewrite run() with branching logic:**
+
+ At the top of `run()`, after the `--list-interfaces` check:
+
+ ```go
+ // D-03: --read and -i are mutually exclusive
+ if readPath != "" && ifaceName != "" {
+ return fmt.Errorf("--read and -i are mutually exclusive; use one or the other")
+ }
+ if readPath == "" && ifaceName == "" {
+ return fmt.Errorf("interface required: use -i , --read , or --list-interfaces")
+ }
+
+ // Pre-validate BPF filter before any capture (CAPT-05, Pitfall 1)
+ if bpfFilter != "" {
+ if err := capture.ValidateBPFFilter(bpfFilter); err != nil {
+ return err
+ }
+ }
+
+ // Resolve output path
+ if outputPath == "" {
+ if readPath != "" {
+ outputPath = deriveOutputPath(readPath) // D-05
+ } else {
+ outputPath = fmt.Sprintf("netsynth-%s.mp3", time.Now().Format("20060102-150405"))
+ }
+ }
+ ```
+
+ Then branch into live vs pcap mode:
+
+ ```go
+ if readPath != "" {
+ return runPcapMode(cmd)
+ }
+ return runLiveMode(cmd)
+ ```
+
+ **4. Extract existing live capture logic into runLiveMode(cmd):**
+ Move the existing pipeline code (signal handling, StartCapture, classify goroutine, Aggregate, snapshot collection, summary, encoding, saved message) into `func runLiveMode(cmd *cobra.Command) error`. Pass `bpfFilter` to `capture.StartCapture(ctx, ifaceName, bpfFilter)`.
+
+ **5. Create runPcapMode(cmd):**
+ ```go
+ func runPcapMode(cmd *cobra.Command) error {
+ // D-06: bookend start message
+ fmt.Fprintf(os.Stderr, "Reading %s...\n", readPath)
+
+ // Open pcap file with optional software BPF filter (D-04)
+ packets, err := capture.ReadPcapFile(readPath, bpfFilter)
+ if err != nil {
+ return err
+ }
+
+ // Classify packets (reuse same classifier)
+ classifier := classify.NewClassifier(classify.DefaultRules)
+ classified := make(chan classify.ClassifiedPacket, 1024)
+ go func() {
+ defer close(classified)
+ for pkt := range packets {
+ cp := classifier.Classify(pkt)
+ // Set timestamp from pcap metadata for D-01 window assignment
+ cp.Timestamp = pkt.Metadata().CaptureInfo.Timestamp
+ classified <- cp
+ }
+ }()
+
+ // D-07: --verbose callback (same as live mode)
+ var onSnapshot func(classify.WindowSnapshot)
+ if verbose {
+ onSnapshot = func(snap classify.WindowSnapshot) {
+ aggregate.PrintWindowLine(os.Stderr, snap)
+ }
+ }
+
+ // Timestamp-based aggregation (D-01, D-02)
+ collectedSnapshots := aggregate.AggregatePcap(classified, aggregate.DefaultWindowMs, onSnapshot)
+
+ // Accumulate totals for summary
+ totals := make(map[classify.TrafficClass]int64)
+ for _, snap := range collectedSnapshots {
+ aggregate.AccumulateTotals(totals, snap)
+ }
+
+ // D-06: protocol summary (same format as live mode)
+ aggregate.PrintSummary(os.Stderr, totals)
+
+ // Check for empty pcap (Pitfall 4: better error than generic "no packets captured")
+ if len(collectedSnapshots) == 0 {
+ return fmt.Errorf("pcap file %q contains no packets (after filtering)", readPath)
+ }
+
+ // Encode
+ fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n", len(collectedSnapshots), outputPath)
+ encodeStart := time.Now()
+ if err := encode.RunSynthesis(collectedSnapshots, outputPath); err != nil {
+ return fmt.Errorf("synthesis failed: %w", err)
+ }
+ encodeElapsed := time.Since(encodeStart)
+
+ // D-06: bookend end message
+ audioDuration := float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0
+ info, statErr := os.Stat(outputPath)
+ if statErr != nil {
+ return fmt.Errorf("stat output file: %w", statErr)
+ }
+ fmt.Fprintf(os.Stderr, "Saved %s (%.1fs, %d KB, encoded in %.1fs)\n",
+ outputPath, audioDuration, info.Size()/1024, encodeElapsed.Seconds())
+ return nil
+ }
+ ```
+
+ **6. Update tests in cmd/netsynth/main_test.go:**
+ Add new test functions per behavior list. Update `newTestCmd()` helper to include the new `--filter` and `--read` flags wired to the global `bpfFilter` and `readPath` variables. Tests for mutual exclusion and deriveOutputPath are pure logic tests (no privileges needed). The invalid BPF filter test uses `-i lo --filter "invalid garbage xyz"` — the BPF validation runs before capture starts, so it returns error without needing capture privileges.
+
+ Update the existing `TestMissingInterfaceFlag` test to also verify the error message now mentions `--read`.
+
+
+ cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./cmd/netsynth/... -run "TestFlagMutualExclusion|TestMissingSource|TestDeriveOutputPath|TestFilterFlag|TestReadFlag|TestInvalidBPF|TestHelpOutputNewFlags" -v -count=1 && go build ./...
+
+
+ - cmd/netsynth/main.go contains `rootCmd.Flags().StringVar(&bpfFilter, "filter",`
+ - cmd/netsynth/main.go contains `rootCmd.Flags().StringVar(&readPath, "read",`
+ - cmd/netsynth/main.go contains `"--read and -i are mutually exclusive"`
+ - cmd/netsynth/main.go contains `func deriveOutputPath(readPath string) string`
+ - cmd/netsynth/main.go contains `capture.ValidateBPFFilter(bpfFilter)`
+ - cmd/netsynth/main.go contains `capture.ReadPcapFile(readPath, bpfFilter)`
+ - cmd/netsynth/main.go contains `aggregate.AggregatePcap(classified, aggregate.DefaultWindowMs`
+ - cmd/netsynth/main.go contains `cp.Timestamp = pkt.Metadata().CaptureInfo.Timestamp`
+ - cmd/netsynth/main.go contains `fmt.Fprintf(os.Stderr, "Reading %s...\n", readPath)` (D-06)
+ - cmd/netsynth/main.go contains `capture.StartCapture(ctx, ifaceName, bpfFilter)`
+ - cmd/netsynth/main.go contains `deriveOutputPath(readPath)` (D-05)
+ - cmd/netsynth/main.go contains `"pcap file %q contains no packets"` (Pitfall 4)
+ - cmd/netsynth/main_test.go contains `TestFlagMutualExclusion`
+ - cmd/netsynth/main_test.go contains `TestDeriveOutputPath`
+ - `go test ./cmd/netsynth/... -v` passes all new tests
+ - `go build ./...` succeeds
+ - `go test ./... -count=1` all green (full suite)
+
+ Both --filter and --read flags are wired into the CLI. Mutual exclusion validated (D-03). BPF filter pre-validated before capture (CAPT-05). Pcap mode reads file, classifies with timestamps, aggregates with AggregatePcap, encodes MP3 (CAPT-06). Output filename derived from input (D-05). Bookend messages displayed (D-06). --verbose works in pcap mode (D-07). All tests pass, all packages compile.
+
+
+
+
+
+Full suite verification:
+```bash
+cd /home/dev/workspace/yoloyolo && export PATH="/home/dev/tools/go-install/go/bin:$PATH" && go test ./... -count=1 -v && go build ./...
+```
+
+Phase success criteria from ROADMAP:
+1. `netsynth -i eth0 --filter "port 53"` — filter applied to live capture (requires privileges to test live)
+2. `netsynth --read capture.pcap -o out.mp3` — pcap mode produces valid MP3 (testable without privileges)
+3. Invalid BPF filter produces clear error before capture — verified by unit test
+
+
+
+1. `--filter` flag registered and functional: pre-validates BPF, applies to live handle, applies software BPF to pcap
+2. `--read` flag registered and functional: reads pcap, classifies with timestamps, aggregates, encodes MP3
+3. `--read` and `-i` mutually exclusive with clear error
+4. `--read` without `-o` derives output from input filename (D-05)
+5. Bookend messages: "Reading ..." at start, summary + "Saved" at end (D-06)
+6. `--verbose` works with `--read` (D-07)
+7. Invalid BPF filter errors before capture (CAPT-05)
+8. Empty pcap file produces clear error with file path (Pitfall 4)
+9. `go test ./... -count=1` all green
+10. `go build ./...` succeeds
+
+
+