docs(01-capture-and-classification): create phase plan

This commit is contained in:
2026-03-25 11:30:21 +01:00
parent dc5716db25
commit 5d657342d3
6 changed files with 1277 additions and 3 deletions
+8 -2
View File
@@ -29,7 +29,13 @@ Decimal phases appear between their surrounding integers in numeric order.
3. User running without root/CAP_NET_RAW sees a clear error message with a `sudo` hint — not a panic or silent failure 3. User running without root/CAP_NET_RAW sees a clear error message with a `sudo` hint — not a panic or silent failure
4. On exit, user sees a per-protocol packet count summary printed to stderr 4. On exit, user sees a per-protocol packet count summary printed to stderr
5. User can pass `--verbose` and see per-window protocol activity lines on stderr 5. User can pass `--verbose` and see per-window protocol activity lines on stderr
**Plans**: TBD **Plans:** 4 plans
Plans:
- [ ] 01-01-PLAN.md — Go 1.24 setup, module init, shared types, config-driven classifier with tests
- [ ] 01-02-PLAN.md — Capture package: OpenCapture, ListInterfaces, privilege error handling
- [ ] 01-03-PLAN.md — Aggregation: time-windowed accumulator, exit summary, verbose output
- [ ] 01-04-PLAN.md — CLI wiring: Cobra commands, signal handling, pipeline assembly, smoke test
### Phase 2: Audio Synthesis Engine ### Phase 2: Audio Synthesis Engine
**Goal**: The synthesis and encoding stack produces a valid MP3 from synthetic WindowSnapshot inputs — audio pipeline fully validated before any real traffic flows through it **Goal**: The synthesis and encoding stack produces a valid MP3 from synthetic WindowSnapshot inputs — audio pipeline fully validated before any real traffic flows through it
@@ -71,7 +77,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Capture and Classification | 0/? | Not started | - | | 1. Capture and Classification | 0/4 | Planning complete | - |
| 2. Audio Synthesis Engine | 0/? | Not started | - | | 2. Audio Synthesis Engine | 0/? | Not started | - |
| 3. Pipeline Integration and MVP | 0/? | Not started | - | | 3. Pipeline Integration and MVP | 0/? | Not started | - |
| 4. Power User Features | 0/? | Not started | - | | 4. Power User Features | 0/? | Not started | - |
+2 -1
View File
@@ -25,7 +25,8 @@
"text_mode": false, "text_mode": false,
"research_before_questions": false, "research_before_questions": false,
"discuss_mode": "discuss", "discuss_mode": "discuss",
"skip_discuss": false "skip_discuss": false,
"_auto_chain_active": false
}, },
"hooks": { "hooks": {
"context_warnings": true "context_warnings": true
@@ -0,0 +1,337 @@
---
phase: 01-capture-and-classification
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- go.mod
- go.sum
- classify/rules.go
- classify/classifier.go
- classify/classifier_test.go
- classify/types.go
autonomous: true
requirements: [CLAS-01]
must_haves:
truths:
- "Each of 10+ protocol classes (ICMP, DNS, HTTPS, HTTP, SSH, SMTP, NTP, DHCP, other-TCP, other-UDP, unknown) is correctly identified from packet headers"
- "Classification rules are stored in a config-driven slice/struct, not hardcoded switch statements"
- "Unrecognized traffic is grouped as 'unknown' class"
artifacts:
- path: "classify/types.go"
provides: "TrafficClass type, ClassifiedPacket struct, WindowSnapshot struct"
exports: ["TrafficClass", "ClassifiedPacket", "WindowSnapshot"]
- path: "classify/rules.go"
provides: "Rule struct and DefaultRules slice with 10+ protocol rules"
contains: "DefaultRules"
- path: "classify/classifier.go"
provides: "Classify function that matches packets against rule table"
exports: ["Classify", "NewClassifier"]
- path: "classify/classifier_test.go"
provides: "Unit tests for all 10+ protocol classes"
min_lines: 80
key_links:
- from: "classify/classifier.go"
to: "classify/rules.go"
via: "Classifier uses DefaultRules slice"
pattern: "DefaultRules"
- from: "classify/classifier.go"
to: "classify/types.go"
via: "Returns TrafficClass from Classify function"
pattern: "TrafficClass"
---
<objective>
Bootstrap Go project and implement the protocol classification engine with full test coverage.
Purpose: Establish the Go module, core types shared across all packages, and the config-driven classifier that maps packets to one of 10+ traffic classes. This is the foundation every other plan depends on.
Output: Compiling Go module with classify package, passing unit tests for all protocol classes.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-capture-and-classification/01-CONTEXT.md
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Install Go 1.24, initialize module, and create shared types</name>
<files>go.mod, classify/types.go</files>
<read_first>
.planning/phases/01-capture-and-classification/01-RESEARCH.md
</read_first>
<action>
1. Install Go 1.24+ from https://go.dev/dl/ (NOT apt which gives 1.22). Download the linux-arm64 tarball:
- wget https://go.dev/dl/go1.24.1.linux-arm64.tar.gz
- sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.1.linux-arm64.tar.gz
- Ensure /usr/local/go/bin is in PATH (export PATH=$PATH:/usr/local/go/bin)
- Verify: `go version` must show go1.24.x
2. Initialize the Go module:
- cd /home/dev/workspace/yoloyolo
- go mod init github.com/netsynth/netsynth
- mkdir -p classify capture aggregate cmd/netsynth
3. Create classify/types.go with the following types (these are the contracts for all packages):
```go
package classify
// TrafficClass represents a classified network traffic category.
type TrafficClass string
const (
ClassICMP TrafficClass = "ICMP"
ClassDNS TrafficClass = "DNS"
ClassHTTPS TrafficClass = "HTTPS"
ClassHTTP TrafficClass = "HTTP"
ClassSSH TrafficClass = "SSH"
ClassSMTP TrafficClass = "SMTP"
ClassNTP TrafficClass = "NTP"
ClassDHCP TrafficClass = "DHCP"
ClassOtherTCP TrafficClass = "other-TCP"
ClassOtherUDP TrafficClass = "other-UDP"
ClassUnknown TrafficClass = "unknown"
)
// AllClasses returns all known traffic classes in display order.
func AllClasses() []TrafficClass {
return []TrafficClass{
ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH,
ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, ClassUnknown,
}
}
// ClassifiedPacket pairs a raw packet's classification result with metadata.
type ClassifiedPacket struct {
Class TrafficClass
SrcPort uint16
DstPort uint16
Protocol string // "tcp", "udp", "icmp"
Length int
}
// WindowSnapshot holds aggregated packet counts for a time window.
// This is the contract between the aggregation stage and future audio synthesis (Phase 2).
type WindowSnapshot struct {
Counts map[TrafficClass]int64
TotalPackets int64
WindowIndex int
}
```
4. Fetch Phase 1 dependencies:
- go get github.com/gopacket/gopacket@v1.5.0
- go get github.com/packetcap/go-pcap@latest
- go get github.com/spf13/cobra@v1.10.2
- go mod tidy
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go version | grep -q "go1.24" && go build ./classify/...</automated>
</verify>
<acceptance_criteria>
- `go version` output contains "go1.24"
- go.mod contains `module github.com/netsynth/netsynth`
- go.mod contains `github.com/gopacket/gopacket`
- go.mod contains `github.com/packetcap/go-pcap`
- go.mod contains `github.com/spf13/cobra`
- classify/types.go contains `type TrafficClass string`
- classify/types.go contains `ClassICMP`, `ClassDNS`, `ClassHTTPS`, `ClassHTTP`, `ClassSSH`, `ClassSMTP`, `ClassNTP`, `ClassDHCP`, `ClassOtherTCP`, `ClassOtherUDP`, `ClassUnknown`
- classify/types.go contains `type ClassifiedPacket struct`
- classify/types.go contains `type WindowSnapshot struct`
- `go build ./classify/...` exits 0
</acceptance_criteria>
<done>Go 1.24 installed, module initialized with all Phase 1 deps, classify/types.go compiles with all 11 traffic classes and shared types.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement config-driven classifier with tests for all protocol classes</name>
<files>classify/rules.go, classify/classifier.go, classify/classifier_test.go</files>
<read_first>
classify/types.go
.planning/phases/01-capture-and-classification/01-RESEARCH.md
</read_first>
<behavior>
- TestClassifyICMP: ICMP packet (no port) -> ClassICMP
- TestClassifyDNS_UDP: UDP dst port 53 -> ClassDNS
- TestClassifyDNS_TCP: TCP dst port 53 -> ClassDNS
- TestClassifyHTTPS: TCP dst port 443 -> ClassHTTPS
- TestClassifyHTTP: TCP dst port 80 -> ClassHTTP
- TestClassifySSH: TCP dst port 22 -> ClassSSH
- TestClassifySMTP: TCP dst port 25 -> ClassSMTP
- TestClassifyNTP: UDP dst port 123 -> ClassNTP
- TestClassifyDHCP: UDP dst port 67 or 68 -> ClassDHCP
- TestClassifyOtherTCP: TCP dst port 8080 (unmatched) -> ClassOtherTCP
- TestClassifyOtherUDP: UDP dst port 9999 (unmatched) -> ClassOtherUDP
- TestClassifyUnknown: Packet with no TCP/UDP/ICMP layer -> ClassUnknown
- TestRulesAreOrderDependent: First matching rule wins
</behavior>
<action>
1. Create classify/rules.go with Rule struct and DefaultRules per D-01 and D-02:
```go
package classify
// Rule defines a classification rule. Protocol is "tcp", "udp", or "icmp".
// DstPort 0 means match any port for this protocol.
// Rules are evaluated in order; first match wins.
type Rule struct {
Protocol string
DstPort uint16
Class TrafficClass
}
// DefaultRules is the D-01 protocol map — ordered, first-match-wins.
// Per D-02: config-driven slice, not a switch statement.
var DefaultRules = []Rule{
{Protocol: "icmp", DstPort: 0, Class: ClassICMP},
{Protocol: "udp", DstPort: 53, Class: ClassDNS},
{Protocol: "tcp", DstPort: 53, Class: ClassDNS},
{Protocol: "tcp", DstPort: 443, Class: ClassHTTPS},
{Protocol: "tcp", DstPort: 80, Class: ClassHTTP},
{Protocol: "tcp", DstPort: 22, Class: ClassSSH},
{Protocol: "tcp", DstPort: 25, Class: ClassSMTP},
{Protocol: "udp", DstPort: 123, Class: ClassNTP},
{Protocol: "udp", DstPort: 67, Class: ClassDHCP},
{Protocol: "udp", DstPort: 68, Class: ClassDHCP},
// Catch-alls (must be last):
{Protocol: "tcp", DstPort: 0, Class: ClassOtherTCP},
{Protocol: "udp", DstPort: 0, Class: ClassOtherUDP},
}
```
2. Create classify/classifier.go:
```go
package classify
import (
"github.com/gopacket/gopacket"
"github.com/gopacket/gopacket/layers"
)
// Classifier classifies packets against a rule table.
type Classifier struct {
rules []Rule
}
// NewClassifier creates a classifier with the given rules.
func NewClassifier(rules []Rule) *Classifier {
return &Classifier{rules: rules}
}
// Classify inspects a gopacket.Packet and returns a ClassifiedPacket.
// It checks ICMP first, then extracts TCP/UDP port info and matches against rules.
// Per D-03: returns ClassUnknown if no rule matches.
func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket {
result := ClassifiedPacket{
Class: ClassUnknown,
Length: len(pkt.Data()),
}
// Check ICMP
if pkt.Layer(layers.LayerTypeICMPv4) != nil || pkt.Layer(layers.LayerTypeICMPv6) != nil {
result.Protocol = "icmp"
// Match against rules
for _, rule := range c.rules {
if rule.Protocol == "icmp" {
result.Class = rule.Class
return result
}
}
return result
}
// Check TCP
if tcpLayer := pkt.Layer(layers.LayerTypeTCP); tcpLayer != nil {
tcp := tcpLayer.(*layers.TCP)
result.Protocol = "tcp"
result.SrcPort = uint16(tcp.SrcPort)
result.DstPort = uint16(tcp.DstPort)
for _, rule := range c.rules {
if rule.Protocol == "tcp" && (rule.DstPort == 0 || rule.DstPort == result.DstPort) {
result.Class = rule.Class
return result
}
}
return result
}
// Check UDP
if udpLayer := pkt.Layer(layers.LayerTypeUDP); udpLayer != nil {
udp := udpLayer.(*layers.UDP)
result.Protocol = "udp"
result.SrcPort = uint16(udp.SrcPort)
result.DstPort = uint16(udp.DstPort)
for _, rule := range c.rules {
if rule.Protocol == "udp" && (rule.DstPort == 0 || rule.DstPort == result.DstPort) {
result.Class = rule.Class
return result
}
}
return result
}
// No recognized transport layer -> ClassUnknown per D-03
return result
}
```
3. Write tests in classify/classifier_test.go using gopacket test packet construction:
- Build synthetic packets using gopacket.SerializeBuffer with layers.Ethernet + layers.IPv4 + layers.TCP/UDP/ICMP headers
- Test each of the 10+ protocol classes plus ClassUnknown
- Test that rule order matters (first match wins)
Run RED phase first: write all tests, verify they fail (classifier not yet imported or logic wrong).
Then GREEN: implement until all pass.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 ./classify/...</automated>
</verify>
<acceptance_criteria>
- classify/rules.go contains `type Rule struct`
- classify/rules.go contains `var DefaultRules = []Rule{`
- classify/rules.go contains at least 12 Rule entries (10 specific + 2 catch-all)
- classify/classifier.go contains `func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket`
- classify/classifier.go contains `func NewClassifier(rules []Rule) *Classifier`
- classify/classifier.go does NOT contain `switch` keyword (per D-02)
- classify/classifier_test.go contains `TestClassify` with subtests for ICMP, DNS, HTTPS, HTTP, SSH, SMTP, NTP, DHCP, OtherTCP, OtherUDP, Unknown
- `go test -v -count=1 ./classify/...` exits 0 with all subtests passing
</acceptance_criteria>
<done>Classifier passes tests for all 11 traffic classes. Rules stored as config-driven slice (D-02). First-match-wins ordering verified. No switch statements in classification logic.</done>
</task>
</tasks>
<verification>
- `go version` shows 1.24+
- `go build ./classify/...` compiles without errors
- `go test -v -count=1 ./classify/...` all tests pass
- `go vet ./classify/...` no issues
- No import of `google/gopacket` anywhere (must be `gopacket/gopacket`)
</verification>
<success_criteria>
- Go 1.24 installed and working
- Module initialized with gopacket, go-pcap, cobra dependencies
- 11 TrafficClass constants defined
- ClassifiedPacket and WindowSnapshot types defined (shared contracts)
- Config-driven classifier with 12+ rules in a slice (not switch)
- All protocol class unit tests passing
</success_criteria>
<output>
After completion, create `.planning/phases/01-capture-and-classification/01-01-SUMMARY.md`
</output>
@@ -0,0 +1,263 @@
---
phase: 01-capture-and-classification
plan: 02
type: execute
wave: 2
depends_on: ["01-01"]
files_modified:
- capture/capture.go
- capture/capture_test.go
autonomous: true
requirements: [CAPT-01, CAPT-02, CAPT-04]
must_haves:
truths:
- "User can specify a network interface via -i flag and packets are captured from it"
- "User can list available network interfaces via --list-interfaces"
- "User without root/CAP_NET_RAW sees platform-specific error with sudo hint"
artifacts:
- path: "capture/capture.go"
provides: "OpenCapture, ListInterfaces, permission error handling"
exports: ["OpenCapture", "ListInterfaces", "StartCapture"]
- path: "capture/capture_test.go"
provides: "Tests for ListInterfaces and permission error messages"
min_lines: 60
key_links:
- from: "capture/capture.go"
to: "github.com/packetcap/go-pcap"
via: "pcap.OpenLive for live capture"
pattern: "pcap\\.OpenLive"
- from: "capture/capture.go"
to: "net.Interfaces"
via: "stdlib for interface enumeration (NOT pcap.FindAllDevs)"
pattern: "net\\.Interfaces"
- from: "capture/capture.go"
to: "runtime.GOOS"
via: "OS detection for platform-specific error messages"
pattern: "runtime\\.GOOS"
---
<objective>
Implement the capture package: live packet capture via go-pcap, interface listing via net.Interfaces(), and platform-specific privilege error handling.
Purpose: This package is the data source for the entire pipeline. It wraps go-pcap with proper error handling and provides the channel-based packet source that the classifier consumes.
Output: capture/ package with OpenCapture, ListInterfaces, StartCapture functions and unit tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/01-capture-and-classification/01-CONTEXT.md
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
@.planning/phases/01-capture-and-classification/01-01-SUMMARY.md
<interfaces>
<!-- From classify/types.go (created in Plan 01): -->
```go
package classify
type TrafficClass string
// 11 constants: ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH, ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, ClassUnknown
type ClassifiedPacket struct {
Class TrafficClass
SrcPort uint16
DstPort uint16
Protocol string
Length int
}
type WindowSnapshot struct {
Counts map[TrafficClass]int64
TotalPackets int64
WindowIndex int
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement capture package with OpenCapture, ListInterfaces, and StartCapture</name>
<files>capture/capture.go, capture/capture_test.go</files>
<read_first>
classify/types.go
go.mod
.planning/phases/01-capture-and-classification/01-RESEARCH.md
</read_first>
<behavior>
- TestListInterfaces: net.Interfaces() returns at least one interface (lo always exists)
- TestListInterfacesFormat: Each interface has Name and Flags fields populated
- TestPermissionErrorLinux: When runtime.GOOS=="linux" and OpenLive fails with "operation not permitted", error message contains "sudo setcap cap_net_raw+ep"
- TestPermissionErrorDarwin: When runtime.GOOS=="darwin" and OpenLive fails with "permission denied", error message contains "sudo netsynth"
- TestIsPermissionError: Recognizes "permission denied", "operation not permitted", and "pcap_create" error strings
- TestIsPermissionErrorNegative: Does not flag "interface not found" as permission error
</behavior>
<action>
1. Create capture/capture.go with these functions:
```go
package capture
import (
"context"
"fmt"
"net"
"os"
"runtime"
"strings"
"sync/atomic"
"github.com/gopacket/gopacket"
"github.com/gopacket/gopacket/layers"
pcap "github.com/packetcap/go-pcap"
)
// ListInterfaces returns all network interfaces using Go stdlib.
// Does NOT use pcap.FindAllDevs (go-pcap has no such function).
// Requires no privileges (CAPT-02).
func ListInterfaces() ([]net.Interface, error) {
return net.Interfaces()
}
// OpenCapture opens a live capture handle on the given interface.
// snaplen=65535, promiscuous=false, timeout=0 (block until packet).
// Returns a platform-specific error message if permission is denied (CAPT-04, D-05).
func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error) {
handle, err := pcap.OpenLive(ctx, iface, 65535, false, 0, false)
if err != nil {
if isPermissionError(err) {
return nil, permissionErrorMsg(iface)
}
return nil, fmt.Errorf("failed to open interface %q: %w", iface, err)
}
return handle, nil
}
// StartCapture opens a capture handle and returns a channel of gopacket.Packet.
// The channel is closed when ctx is cancelled (Ctrl+C) or capture ends.
// Uses non-blocking send with drop counter per Pitfall 4 from RESEARCH.md.
// Uses dynamic link type detection per Pitfall 6 from RESEARCH.md.
func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error) {
handle, err := OpenCapture(ctx, iface)
if err != nil {
return nil, nil, err
}
lt := layers.LinkType(handle.LinkType())
packetSource := gopacket.NewPacketSource(handle, lt)
packetSource.NoCopy = true // safe: go-pcap copies internally
packets := make(chan gopacket.Packet, 512)
var droppedPackets int64
go func() {
defer close(packets)
defer handle.Close()
for {
select {
case <-ctx.Done():
return
default:
}
pkt, err := packetSource.NextPacket()
if err != nil {
// Context cancelled or handle closed
return
}
select {
case packets <- pkt:
default:
atomic.AddInt64(&droppedPackets, 1)
}
}
}()
return packets, &droppedPackets, nil
}
// isPermissionError checks if the error indicates insufficient privileges.
func isPermissionError(err error) bool {
s := strings.ToLower(err.Error())
return strings.Contains(s, "permission denied") ||
strings.Contains(s, "operation not permitted") ||
strings.Contains(s, "pcap_create")
}
// permissionErrorMsg returns a platform-specific privilege hint (D-05).
func permissionErrorMsg(iface string) error {
bin, _ := os.Executable()
if bin == "" {
bin = "netsynth"
}
switch runtime.GOOS {
case "linux":
return fmt.Errorf(
"packet capture requires root or CAP_NET_RAW.\n"+
"Run as root: sudo %s -i %s\n"+
"Or grant capability: sudo setcap cap_net_raw+ep %s",
bin, iface, bin)
default: // darwin and others
return fmt.Errorf(
"packet capture requires root privileges.\n"+
"Run as root: sudo %s -i %s", bin, iface)
}
}
```
2. Create capture/capture_test.go:
- TestListInterfaces: Call ListInterfaces(), assert len > 0 (lo always exists), assert first interface has non-empty Name
- TestIsPermissionError: Table-driven test with positive cases ("permission denied", "operation not permitted", "pcap_create: foo") and negative cases ("interface not found", "no such device")
- TestPermissionErrorMsg: Call permissionErrorMsg("eth0") on current OS, verify the error string contains "sudo" and the interface name "eth0". On linux verify it contains "setcap cap_net_raw+ep"
- NOTE: Do NOT test OpenCapture or StartCapture directly in unit tests -- they require root/CAP_NET_RAW. Those are integration-tested manually via `sudo ./netsynth -i eth0`.
Write tests first (RED), then implement (GREEN).
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 ./capture/...</automated>
</verify>
<acceptance_criteria>
- capture/capture.go contains `func ListInterfaces() ([]net.Interface, error)`
- capture/capture.go contains `func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error)`
- capture/capture.go contains `func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error)`
- capture/capture.go contains `net.Interfaces()` (NOT pcap.FindAllDevs)
- capture/capture.go contains `runtime.GOOS` for platform detection
- capture/capture.go contains `"setcap cap_net_raw+ep"` in linux error branch
- capture/capture.go contains `atomic.AddInt64(&droppedPackets, 1)` for non-blocking send
- capture/capture.go contains `layers.LinkType(handle.LinkType())` for dynamic link type
- capture/capture_test.go contains `TestListInterfaces`
- capture/capture_test.go contains `TestIsPermissionError`
- capture/capture_test.go contains `TestPermissionErrorMsg`
- `go test -v -count=1 ./capture/...` exits 0
</acceptance_criteria>
<done>capture package compiles and tests pass. ListInterfaces uses net.Interfaces (no pcap dependency for listing). Permission errors show platform-specific sudo/setcap hints. StartCapture uses non-blocking send with drop counter and dynamic link type.</done>
</task>
</tasks>
<verification>
- `go test -v -count=1 ./capture/...` all tests pass
- `go vet ./capture/...` no issues
- No import of `gopacket/pcap` (must use `packetcap/go-pcap`)
- No import of `google/gopacket` (must use `gopacket/gopacket`)
</verification>
<success_criteria>
- ListInterfaces returns system interfaces without privileges
- Permission error detection covers "permission denied", "operation not permitted", "pcap_create"
- Error messages are platform-specific (Linux: setcap hint, macOS: sudo hint)
- StartCapture uses buffered channel (512) with atomic drop counter
- Dynamic link type detection (not hardcoded LinkTypeEthernet)
- All unit tests passing
</success_criteria>
<output>
After completion, create `.planning/phases/01-capture-and-classification/01-02-SUMMARY.md`
</output>
@@ -0,0 +1,344 @@
---
phase: 01-capture-and-classification
plan: 03
type: execute
wave: 2
depends_on: ["01-01"]
files_modified:
- aggregate/window.go
- aggregate/summary.go
- aggregate/window_test.go
- aggregate/summary_test.go
autonomous: true
requirements: [CLAS-03, CLAS-04]
must_haves:
truths:
- "On exit, user sees a per-protocol packet count summary printed to stderr"
- "User can pass --verbose and see per-window protocol activity lines on stderr"
- "Aggregator accumulates packet counts into time windows and emits WindowSnapshots"
artifacts:
- path: "aggregate/window.go"
provides: "Ticker-driven window aggregator that emits WindowSnapshots"
exports: ["Aggregate"]
- path: "aggregate/summary.go"
provides: "PrintSummary and PrintWindowLine functions for stderr output"
exports: ["PrintSummary", "PrintWindowLine"]
- path: "aggregate/window_test.go"
provides: "Tests for window aggregation logic"
min_lines: 50
- path: "aggregate/summary_test.go"
provides: "Tests for summary formatting and verbose output"
min_lines: 60
key_links:
- from: "aggregate/window.go"
to: "classify/types.go"
via: "Consumes ClassifiedPacket, produces WindowSnapshot"
pattern: "classify\\.ClassifiedPacket"
- from: "aggregate/summary.go"
to: "classify/types.go"
via: "Uses TrafficClass and WindowSnapshot for formatting"
pattern: "classify\\.TrafficClass"
---
<objective>
Implement the aggregation and stats reporting package: time-windowed accumulation, exit summary, and verbose per-window output.
Purpose: This package bridges classified packets and the eventual audio engine (Phase 2). It accumulates per-class counts into time windows, prints verbose activity lines during capture, and prints a final protocol summary on exit.
Output: aggregate/ package with Aggregate, PrintSummary, PrintWindowLine functions and full unit tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/01-capture-and-classification/01-CONTEXT.md
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
@.planning/phases/01-capture-and-classification/01-01-SUMMARY.md
<interfaces>
<!-- From classify/types.go (created in Plan 01): -->
```go
package classify
type TrafficClass string
const (
ClassICMP TrafficClass = "ICMP"
ClassDNS TrafficClass = "DNS"
ClassHTTPS TrafficClass = "HTTPS"
ClassHTTP TrafficClass = "HTTP"
ClassSSH TrafficClass = "SSH"
ClassSMTP TrafficClass = "SMTP"
ClassNTP TrafficClass = "NTP"
ClassDHCP TrafficClass = "DHCP"
ClassOtherTCP TrafficClass = "other-TCP"
ClassOtherUDP TrafficClass = "other-UDP"
ClassUnknown TrafficClass = "unknown"
)
func AllClasses() []TrafficClass { ... }
type ClassifiedPacket struct {
Class TrafficClass
SrcPort uint16
DstPort uint16
Protocol string
Length int
}
type WindowSnapshot struct {
Counts map[TrafficClass]int64
TotalPackets int64
WindowIndex int
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement ticker-driven window aggregator</name>
<files>aggregate/window.go, aggregate/window_test.go</files>
<read_first>
classify/types.go
.planning/phases/01-capture-and-classification/01-RESEARCH.md
</read_first>
<behavior>
- TestAggregateEmitsSnapshot: Send 5 ClassifiedPackets, trigger window tick, receive WindowSnapshot with correct counts
- TestAggregateMultipleClasses: Send packets of 3 different classes, verify snapshot has all 3 with correct counts
- TestAggregateDoneFlushesPartial: Close done channel mid-window, verify final partial snapshot is emitted
- TestAggregateEmptyWindow: Trigger tick with no packets, verify snapshot has zero counts and TotalPackets==0
- TestAggregateWindowIndex: Multiple ticks increment WindowIndex (0, 1, 2...)
</behavior>
<action>
1. Create aggregate/window.go implementing the ticker-driven aggregation pattern from RESEARCH.md Pattern 2:
```go
package aggregate
import (
"time"
"github.com/netsynth/netsynth/classify"
)
// DefaultWindowMs is the default time window duration in milliseconds.
// Claude's discretion per CONTEXT.md.
const DefaultWindowMs = 500
// Aggregate reads ClassifiedPackets from events, accumulates counts per TrafficClass
// in time windows of windowMs duration, and emits a WindowSnapshot per window.
// When done is closed, flushes the current partial window and closes the output channel.
// If verbose is true, calls onSnapshot for each window (used for --verbose stderr output).
func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket,
windowMs int, onSnapshot func(classify.WindowSnapshot)) <-chan classify.WindowSnapshot {
out := make(chan classify.WindowSnapshot, 8)
go func() {
defer close(out)
ticker := time.NewTicker(time.Duration(windowMs) * time.Millisecond)
defer ticker.Stop()
counts := make(map[classify.TrafficClass]int64)
var totalPackets int64
windowIndex := 0
flush := func() {
snap := classify.WindowSnapshot{
Counts: counts,
TotalPackets: totalPackets,
WindowIndex: windowIndex,
}
if onSnapshot != nil {
onSnapshot(snap)
}
out <- snap
counts = make(map[classify.TrafficClass]int64)
totalPackets = 0
windowIndex++
}
for {
select {
case <-done:
flush() // flush final partial window
return
case <-ticker.C:
flush()
case ev, ok := <-events:
if !ok {
flush()
return
}
counts[ev.Class]++
totalPackets++
}
}
}()
return out
}
```
2. Create aggregate/window_test.go:
- Use a done channel and a buffered events channel to control timing without real tickers
- For ticker tests, use a short windowMs (10ms) and sleep briefly to trigger ticks
- For done-flush tests, close done channel immediately and verify output
- Verify WindowSnapshot.Counts map contains expected class keys and values
- Verify WindowSnapshot.TotalPackets matches sum of counts
- Verify WindowIndex increments across windows
Write tests first (RED), then implement (GREEN).
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 -race ./aggregate/...</automated>
</verify>
<acceptance_criteria>
- aggregate/window.go contains `func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket`
- aggregate/window.go contains `const DefaultWindowMs = 500`
- aggregate/window.go contains `time.NewTicker`
- aggregate/window.go imports `github.com/netsynth/netsynth/classify`
- aggregate/window_test.go contains `TestAggregate` with subtests for snapshot emission, multiple classes, done flush, empty window, and window index
- `go test -v -count=1 -race ./aggregate/...` exits 0
</acceptance_criteria>
<done>Aggregate function emits WindowSnapshots on ticker ticks and flushes partial window on done. All timing-based tests pass including race detector.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement exit summary and verbose window output formatting</name>
<files>aggregate/summary.go, aggregate/summary_test.go</files>
<read_first>
classify/types.go
aggregate/window.go
.planning/phases/01-capture-and-classification/01-RESEARCH.md
</read_first>
<behavior>
- TestPrintSummary: Given totals map {ICMP: 10, DNS: 50, HTTPS: 100}, output contains "ICMP", "DNS", "HTTPS" with correct counts and percentages, plus "TOTAL" line
- TestPrintSummaryEmpty: Given empty totals, output contains "TOTAL" with 0 packets
- TestPrintSummarySorted: Output classes appear in stable alphabetical or canonical order (not random map order)
- TestPrintWindowLine: Given WindowSnapshot with {DNS: 5, HTTPS: 10}, output contains window index, class names, and counts on a single line
- TestPrintWindowLineEmpty: Given empty snapshot, output contains window index with no class entries
- TestAccumulateTotals: Given multiple WindowSnapshots, AccumulateTotals produces correct cumulative counts
</behavior>
<action>
1. Create aggregate/summary.go:
```go
package aggregate
import (
"fmt"
"io"
"sort"
"github.com/netsynth/netsynth/classify"
)
// PrintSummary writes a per-protocol packet count summary to w (typically os.Stderr).
// Output format matches tcpdump conventions (CLAS-03).
func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64) {
var totalPackets int64
for _, count := range totals {
totalPackets += count
}
fmt.Fprintln(w, "\n--- Protocol Summary ---")
// Sort classes for stable output
classes := make([]string, 0, len(totals))
for c := range totals {
classes = append(classes, string(c))
}
sort.Strings(classes)
for _, className := range classes {
c := classify.TrafficClass(className)
count := totals[c]
pct := 0.0
if totalPackets > 0 {
pct = float64(count) / float64(totalPackets) * 100.0
}
fmt.Fprintf(w, " %-15s %8d packets (%5.1f%%)\n", c, count, pct)
}
fmt.Fprintf(w, " %-15s %8d packets\n", "TOTAL", totalPackets)
}
// PrintWindowLine writes a single verbose per-window activity line to w (CLAS-04).
// Format: [window N] CLASS:count CLASS:count ... (total: N)
func PrintWindowLine(w io.Writer, snap classify.WindowSnapshot) {
fmt.Fprintf(w, "[window %d]", snap.WindowIndex)
// Sort for stable output
classes := make([]string, 0, len(snap.Counts))
for c := range snap.Counts {
classes = append(classes, string(c))
}
sort.Strings(classes)
for _, className := range classes {
c := classify.TrafficClass(className)
if snap.Counts[c] > 0 {
fmt.Fprintf(w, " %s:%d", c, snap.Counts[c])
}
}
fmt.Fprintf(w, " (total: %d)\n", snap.TotalPackets)
}
// AccumulateTotals merges a WindowSnapshot's counts into cumulative totals.
func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot) {
for class, count := range snap.Counts {
totals[class] += count
}
}
```
2. Create aggregate/summary_test.go:
- Use bytes.Buffer as io.Writer to capture output
- TestPrintSummary: Verify output contains expected class names, counts, percentages, and TOTAL line
- TestPrintSummaryEmpty: Verify output contains "TOTAL" with 0
- TestPrintSummarySorted: Verify output lines are in alphabetical order (capture output, split lines, check ordering)
- TestPrintWindowLine: Verify format "[window 0] DNS:5 HTTPS:10 (total: 15)"
- TestAccumulateTotals: Two snapshots accumulated, verify merged counts
Write tests first (RED), then implement (GREEN).
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test -v -count=1 ./aggregate/...</automated>
</verify>
<acceptance_criteria>
- aggregate/summary.go contains `func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64)`
- aggregate/summary.go contains `func PrintWindowLine(w io.Writer, snap classify.WindowSnapshot)`
- aggregate/summary.go contains `func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot)`
- aggregate/summary.go contains `"--- Protocol Summary ---"`
- aggregate/summary.go contains `sort.Strings` for stable output ordering
- aggregate/summary_test.go contains `TestPrintSummary`, `TestPrintSummaryEmpty`, `TestPrintSummarySorted`
- aggregate/summary_test.go contains `TestPrintWindowLine`, `TestAccumulateTotals`
- `go test -v -count=1 ./aggregate/...` exits 0
</acceptance_criteria>
<done>PrintSummary outputs per-protocol counts with percentages and TOTAL to stderr (CLAS-03). PrintWindowLine outputs per-window activity for --verbose mode (CLAS-04). AccumulateTotals correctly merges snapshots. All formatting tests pass with stable sorted output.</done>
</task>
</tasks>
<verification>
- `go test -v -count=1 -race ./aggregate/...` all tests pass
- `go vet ./aggregate/...` no issues
- Output format matches tcpdump convention (stderr, per-protocol counts with percentages)
</verification>
<success_criteria>
- Aggregate emits WindowSnapshots on time ticks and flushes on done
- PrintSummary prints per-protocol packet counts with percentages and TOTAL to an io.Writer
- PrintWindowLine prints per-window activity for verbose mode
- AccumulateTotals correctly accumulates across multiple snapshots
- All output is sorted for deterministic display
- All unit tests passing (including race detector)
</success_criteria>
<output>
After completion, create `.planning/phases/01-capture-and-classification/01-03-SUMMARY.md`
</output>
@@ -0,0 +1,323 @@
---
phase: 01-capture-and-classification
plan: 04
type: execute
wave: 3
depends_on: ["01-01", "01-02", "01-03"]
files_modified:
- cmd/netsynth/main.go
- cmd/netsynth/main_test.go
autonomous: false
requirements: [CAPT-01, CAPT-02, CAPT-04, CLAS-01, CLAS-03, CLAS-04]
must_haves:
truths:
- "User can run `netsynth -i eth0` and see packets being classified live to stderr"
- "User can run `netsynth --list-interfaces` and see available interfaces"
- "User without root sees platform-specific error with sudo hint"
- "On exit (Ctrl+C), user sees per-protocol packet count summary on stderr"
- "User can pass `--verbose` and see per-window protocol activity on stderr"
artifacts:
- path: "cmd/netsynth/main.go"
provides: "Cobra CLI wiring, signal handling, pipeline assembly"
exports: ["main"]
- path: "cmd/netsynth/main_test.go"
provides: "Tests for flag parsing and list-interfaces output"
min_lines: 30
key_links:
- from: "cmd/netsynth/main.go"
to: "capture/capture.go"
via: "StartCapture for packet channel, ListInterfaces for --list-interfaces"
pattern: "capture\\.StartCapture|capture\\.ListInterfaces"
- from: "cmd/netsynth/main.go"
to: "classify/classifier.go"
via: "NewClassifier + Classify in pipeline goroutine"
pattern: "classify\\.NewClassifier|classifier\\.Classify"
- from: "cmd/netsynth/main.go"
to: "aggregate/window.go"
via: "Aggregate consumes classified packets channel"
pattern: "aggregate\\.Aggregate"
- from: "cmd/netsynth/main.go"
to: "aggregate/summary.go"
via: "PrintSummary on exit, PrintWindowLine for verbose callback"
pattern: "aggregate\\.PrintSummary|aggregate\\.PrintWindowLine"
---
<objective>
Wire all packages into the Cobra CLI: flag parsing, signal handling, capture-classify-aggregate pipeline, verbose output, and exit summary.
Purpose: This is the final integration that makes `netsynth` a runnable tool. It connects capture -> classify -> aggregate stages via channels, handles Ctrl+C gracefully, and prints the exit summary. All Phase 1 success criteria become observable here.
Output: Working `netsynth` binary with -i, --list-interfaces, --verbose flags. Checkpoint for manual smoke test.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/01-capture-and-classification/01-CONTEXT.md
@.planning/phases/01-capture-and-classification/01-RESEARCH.md
@.planning/phases/01-capture-and-classification/01-01-SUMMARY.md
@.planning/phases/01-capture-and-classification/01-02-SUMMARY.md
@.planning/phases/01-capture-and-classification/01-03-SUMMARY.md
<interfaces>
<!-- From capture/capture.go (Plan 02): -->
```go
func ListInterfaces() ([]net.Interface, error)
func OpenCapture(ctx context.Context, iface string) (*pcap.Handle, error)
func StartCapture(ctx context.Context, iface string) (<-chan gopacket.Packet, *int64, error)
```
<!-- From classify/ (Plan 01): -->
```go
type TrafficClass string // 11 constants
type ClassifiedPacket struct { Class TrafficClass; SrcPort, DstPort uint16; Protocol string; Length int }
type WindowSnapshot struct { Counts map[TrafficClass]int64; TotalPackets int64; WindowIndex int }
type Classifier struct { ... }
func NewClassifier(rules []Rule) *Classifier
func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket
var DefaultRules []Rule
```
<!-- From aggregate/ (Plan 03): -->
```go
const DefaultWindowMs = 500
func Aggregate(done <-chan struct{}, events <-chan classify.ClassifiedPacket, windowMs int, onSnapshot func(classify.WindowSnapshot)) <-chan classify.WindowSnapshot
func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64)
func PrintWindowLine(w io.Writer, snap classify.WindowSnapshot)
func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire Cobra CLI with capture-classify-aggregate pipeline and signal handling</name>
<files>cmd/netsynth/main.go, cmd/netsynth/main_test.go</files>
<read_first>
capture/capture.go
classify/classifier.go
classify/rules.go
classify/types.go
aggregate/window.go
aggregate/summary.go
go.mod
.planning/phases/01-capture-and-classification/01-RESEARCH.md
</read_first>
<action>
1. Create cmd/netsynth/main.go with Cobra root command:
```go
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"github.com/spf13/cobra"
"github.com/netsynth/netsynth/aggregate"
"github.com/netsynth/netsynth/capture"
"github.com/netsynth/netsynth/classify"
)
var (
ifaceName string
listIfaces bool
verbose bool
)
func main() {
rootCmd := &cobra.Command{
Use: "netsynth",
Short: "Sonify live network traffic into ambient audio",
Long: "NetSynth captures network traffic, classifies it by protocol, and (in future phases) synthesizes an ambient MP3 soundscape.",
RunE: run,
}
rootCmd.Flags().StringVarP(&ifaceName, "interface", "i", "", "Network interface to capture on (required unless --list-interfaces)")
rootCmd.Flags().BoolVar(&listIfaces, "list-interfaces", false, "List available network interfaces and exit")
rootCmd.Flags().BoolVar(&verbose, "verbose", false, "Print per-window protocol activity to stderr")
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func run(cmd *cobra.Command, args []string) error {
// --list-interfaces mode (CAPT-02)
if listIfaces {
return runListInterfaces()
}
// Require -i flag
if ifaceName == "" {
return fmt.Errorf("interface required: use -i <interface> or --list-interfaces to see available interfaces")
}
// Set up signal handling (Ctrl+C)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Stage 1: Capture (CAPT-01)
fmt.Fprintf(os.Stderr, "Starting capture on %s... (press Ctrl+C to stop)\n", ifaceName)
packets, droppedPtr, err := capture.StartCapture(ctx, ifaceName)
if err != nil {
return err // CAPT-04: permission error already has platform-specific message
}
// Stage 2: Classify (CLAS-01)
classifier := classify.NewClassifier(classify.DefaultRules)
classified := make(chan classify.ClassifiedPacket, 1024)
go func() {
defer close(classified)
for pkt := range packets {
classified <- classifier.Classify(pkt)
}
}()
// Stage 3: Aggregate with optional verbose callback (CLAS-04)
var onSnapshot func(classify.WindowSnapshot)
if verbose {
onSnapshot = func(snap classify.WindowSnapshot) {
aggregate.PrintWindowLine(os.Stderr, snap) // CLAS-04: --verbose output
}
}
snapshots := aggregate.Aggregate(ctx.Done(), classified, aggregate.DefaultWindowMs, onSnapshot)
// Consume snapshots and accumulate totals
totals := make(map[classify.TrafficClass]int64)
for snap := range snapshots {
aggregate.AccumulateTotals(totals, snap)
}
// Print exit summary (CLAS-03)
dropped := atomic.LoadInt64(droppedPtr)
if dropped > 0 {
fmt.Fprintf(os.Stderr, "\nWarning: %d packets dropped (channel buffer full)\n", dropped)
}
aggregate.PrintSummary(os.Stderr, totals)
return nil
}
func runListInterfaces() error {
ifaces, err := capture.ListInterfaces()
if err != nil {
return fmt.Errorf("error listing interfaces: %w", err)
}
if len(ifaces) == 0 {
fmt.Fprintln(os.Stderr, "No interfaces found. If interfaces are missing, run with sudo.")
return nil
}
fmt.Fprintln(os.Stderr, "Available interfaces:")
for _, iface := range ifaces {
addrs, _ := iface.Addrs()
addrStrs := make([]string, len(addrs))
for i, a := range addrs {
addrStrs[i] = a.String()
}
fmt.Fprintf(os.Stderr, " %-15s flags=%s addrs=%s\n",
iface.Name, iface.Flags, strings.Join(addrStrs, ", "))
}
return nil
}
```
2. Create cmd/netsynth/main_test.go with:
- TestListInterfacesFlag: Execute rootCmd with `--list-interfaces`, verify it exits 0 (integration-lite test)
- TestMissingInterfaceFlag: Execute rootCmd with no flags, verify error message contains "interface required"
- TestHelpOutput: Execute rootCmd with `--help`, verify output contains "-i", "--list-interfaces", "--verbose"
3. Build the binary:
- `CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth`
- Verify: `./netsynth --help` shows usage
- Verify: `./netsynth --list-interfaces` shows interfaces
- Verify: `./netsynth -i eth0` without root shows permission error with sudo hint
4. Run full test suite: `go test -v -race ./...`
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth && ./netsynth --list-interfaces 2>&1 | grep -q "flags=" && go test -v -count=1 ./...</automated>
</verify>
<acceptance_criteria>
- cmd/netsynth/main.go contains `rootCmd.Flags().StringVarP(&ifaceName, "interface", "i"`
- cmd/netsynth/main.go contains `rootCmd.Flags().BoolVar(&listIfaces, "list-interfaces"`
- cmd/netsynth/main.go contains `rootCmd.Flags().BoolVar(&verbose, "verbose"`
- cmd/netsynth/main.go contains `signal.NotifyContext`
- cmd/netsynth/main.go contains `capture.StartCapture`
- cmd/netsynth/main.go contains `classify.NewClassifier(classify.DefaultRules)`
- cmd/netsynth/main.go contains `aggregate.Aggregate(`
- cmd/netsynth/main.go contains `aggregate.PrintSummary(os.Stderr`
- cmd/netsynth/main.go contains `aggregate.PrintWindowLine(os.Stderr`
- `CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth` exits 0
- `./netsynth --list-interfaces` output contains interface names and "flags="
- `./netsynth 2>&1` (no flags) output contains "interface required"
- `go test -v -count=1 ./...` exits 0 (all unit tests pass)
</acceptance_criteria>
<done>netsynth binary builds and runs. --list-interfaces shows interfaces. Missing -i shows clear error. Signal handling wires Ctrl+C to clean pipeline shutdown. Exit summary prints per-protocol counts. All tests pass.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Smoke test live capture on real interface</name>
<files>cmd/netsynth/main.go</files>
<action>
This is a human verification checkpoint. The executor should present the smoke test instructions below to the user and wait for approval. No code changes are needed -- this verifies the work done in Task 1.
</action>
<what-built>
Complete Phase 1 pipeline: netsynth CLI with live packet capture, protocol classification, and stderr output.
</what-built>
<how-to-verify>
1. Build: `cd /home/dev/workspace/yoloyolo && CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth`
2. List interfaces: `./netsynth --list-interfaces` -- should show eth0 and lo at minimum
3. Test permission error (non-root): `./netsynth -i eth0` -- should show sudo/setcap hint
4. Test live capture (needs root): `sudo ./netsynth -i eth0 --verbose`
- Generate some traffic in another terminal: `curl https://example.com`, `ping -c 3 8.8.8.8`
- You should see per-window verbose lines on stderr showing HTTPS, ICMP, DNS counts
- Press Ctrl+C
- You should see "--- Protocol Summary ---" with per-protocol packet counts and percentages
5. Verify no-flag error: `./netsynth` -- should say "interface required"
6. Verify help: `./netsynth --help` -- should show -i, --list-interfaces, --verbose flags
</how-to-verify>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && ./netsynth --list-interfaces 2>&1 | grep -q "flags="</automated>
</verify>
<done>User confirmed live capture works: packets classified, verbose output shows per-window activity, Ctrl+C produces protocol summary.</done>
<resume-signal>Type "approved" if live capture works correctly, or describe issues</resume-signal>
</task>
</tasks>
<verification>
- `CGO_ENABLED=0 go build -o netsynth ./cmd/netsynth` compiles successfully
- `./netsynth --list-interfaces` shows system interfaces
- `./netsynth` (no flags) shows clear error about missing -i flag
- `./netsynth --help` shows all three flags
- `go test -v -race ./...` all tests pass across all packages
- No import of `google/gopacket` anywhere
- No switch statements in classify/classifier.go
</verification>
<success_criteria>
- netsynth binary builds with CGO_ENABLED=0 (no CGo needed for Phase 1)
- -i flag accepted, --list-interfaces works, --verbose works
- Permission error shows platform-specific sudo hint
- Live capture classifies packets and prints verbose lines (when --verbose)
- Ctrl+C produces clean exit with per-protocol summary
- All unit tests pass across classify/, capture/, aggregate/, and cmd/netsynth/
</success_criteria>
<output>
After completion, create `.planning/phases/01-capture-and-classification/01-04-SUMMARY.md`
</output>