docs(03): create phase plan
This commit is contained in:
@@ -62,7 +62,11 @@ Plans:
|
||||
1. User runs `netsynth -i eth0 -o out.mp3`, generates traffic, presses Ctrl+C, and receives a valid playable MP3 at `out.mp3`
|
||||
2. Unrecognized traffic patterns are automatically assigned distinct drone tones — unknown traffic is not silent or merged into a single undifferentiated layer
|
||||
3. The MP3 audio reflects the actual traffic mix — a session with mostly DNS sounds different from one with mostly HTTPS
|
||||
**Plans**: TBD
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 03-01-PLAN.md — Extend TrafficClass to 14 classes (hash-bucketed unknown-1 through unknown-4), update synth config with dissonant tones
|
||||
- [ ] 03-02-PLAN.md — Wire capture pipeline into RunSynthesis, encoding feedback messages, end-to-end MVP verification
|
||||
|
||||
### Phase 4: Power User Features
|
||||
**Goal**: Users can scope capture with BPF expressions and sonify historical pcap files
|
||||
@@ -77,11 +81,11 @@ Plans:
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4
|
||||
Phases execute in numeric order: 1 -> 2 -> 3 -> 4
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 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 | 0/? | Not started | - |
|
||||
| 3. Pipeline Integration and MVP | 0/2 | In progress | - |
|
||||
| 4. Power User Features | 0/? | Not started | - |
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
---
|
||||
phase: 03-pipeline-integration-and-mvp
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- classify/types.go
|
||||
- classify/classifier.go
|
||||
- classify/classifier_test.go
|
||||
- synth/config.go
|
||||
- synth/bank_test.go
|
||||
- synth/config_test.go
|
||||
autonomous: true
|
||||
requirements: [CLAS-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "AllClasses() returns exactly 14 classes with no ClassUnknown"
|
||||
- "Unrecognized TCP/UDP packets are deterministically assigned to ClassUnknown1-4 via hash-bucketing"
|
||||
- "Packets with no transport layer (ARP etc.) are assigned to an unknown bucket, not silently dropped"
|
||||
- "Each unknown bucket has a distinct tone in the 850-1100 Hz dissonant range"
|
||||
- "GainPerLayer is 1/14 so 14 layers mix without clipping"
|
||||
artifacts:
|
||||
- path: "classify/types.go"
|
||||
provides: "ClassUnknown1-4 constants, 14-element AllClasses()"
|
||||
contains: "ClassUnknown1"
|
||||
- path: "classify/classifier.go"
|
||||
provides: "hashBucket() function routing unrecognized traffic"
|
||||
contains: "func hashBucket"
|
||||
- path: "synth/config.go"
|
||||
provides: "FreqConfig entries for all 14 classes, NumLayers=14"
|
||||
contains: "NumLayers = 14"
|
||||
- path: "synth/config_test.go"
|
||||
provides: "Test ensuring every AllClasses() entry has a FreqConfig"
|
||||
contains: "TestClassFreqConfigsComplete"
|
||||
key_links:
|
||||
- from: "classify/classifier.go"
|
||||
to: "classify/types.go"
|
||||
via: "hashBucket returns ClassUnknown1-4 constants"
|
||||
pattern: "ClassUnknown[1-4]"
|
||||
- from: "synth/config.go"
|
||||
to: "classify/types.go"
|
||||
via: "ClassFreqConfigs map keys include all 14 classes"
|
||||
pattern: "classify\\.ClassUnknown[1-4]"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Extend the traffic classification type system from 11 classes to 14 by replacing ClassUnknown with 4 hash-bucketed unknown classes, and add corresponding synthesis tone configurations.
|
||||
|
||||
Purpose: Implements CLAS-02 (auto-clustering) so unrecognized traffic produces distinct drone tones instead of collapsing into a single undifferentiated layer.
|
||||
Output: Updated classify and synth packages with 14-class type system, hash-bucketing classifier, dissonant tone configs, and comprehensive 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/STATE.md
|
||||
@.planning/phases/03-pipeline-integration-and-mvp/03-CONTEXT.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
From classify/types.go (CURRENT — will be modified):
|
||||
```go
|
||||
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" // TO BE REMOVED
|
||||
)
|
||||
func AllClasses() []TrafficClass // currently returns 11, will return 14
|
||||
```
|
||||
|
||||
From classify/classifier.go (CURRENT — ClassUnknown return sites at lines 23, 37, 52, 67, 71):
|
||||
```go
|
||||
func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket
|
||||
// Falls through to ClassUnknown when no rule matches
|
||||
```
|
||||
|
||||
From synth/config.go (CURRENT — will be modified):
|
||||
```go
|
||||
const NumLayers = 11
|
||||
const GainPerLayer = 1.0 / float64(NumLayers) // ~0.0909
|
||||
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{...} // 11 entries
|
||||
```
|
||||
|
||||
From encode/mp3.go (NOT modified — consumes AllClasses() via synth.NewBank):
|
||||
```go
|
||||
func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Extend TrafficClass type system and write Wave 0 tests</name>
|
||||
<files>classify/types.go, classify/classifier_test.go, synth/config_test.go, synth/bank_test.go</files>
|
||||
<read_first>classify/types.go, classify/classifier_test.go, synth/bank_test.go, synth/config.go</read_first>
|
||||
<behavior>
|
||||
- TestAllClassesCount: `len(AllClasses()) == 14`
|
||||
- TestAllClassesNoPlainUnknown: no element in AllClasses() equals `"unknown"`
|
||||
- TestAllClassesHasUnknownBuckets: AllClasses() contains `"unknown-1"`, `"unknown-2"`, `"unknown-3"`, `"unknown-4"`
|
||||
- TestHashBucketDistribution: calling hashBucket with ports 0-999 and protocol "tcp" produces all 4 ClassUnknown1-4 values
|
||||
- TestClassifyUnknown (UPDATE): buildUnknownPacket returns a class with `strings.HasPrefix(string(got.Class), "unknown-")`, not `ClassUnknown`
|
||||
- TestNewBankHas11Layers (UPDATE): assert `len(b.layers) == 14` (rename to TestNewBankHas14Layers)
|
||||
- TestClassFreqConfigsComplete (NEW in synth/config_test.go): every class from `AllClasses()` has a key in `ClassFreqConfigs`
|
||||
</behavior>
|
||||
<action>
|
||||
**Step 1: Update classify/types.go (per D-04)**
|
||||
|
||||
Remove the `ClassUnknown TrafficClass = "unknown"` constant. Add four new constants:
|
||||
```go
|
||||
ClassUnknown1 TrafficClass = "unknown-1"
|
||||
ClassUnknown2 TrafficClass = "unknown-2"
|
||||
ClassUnknown3 TrafficClass = "unknown-3"
|
||||
ClassUnknown4 TrafficClass = "unknown-4"
|
||||
```
|
||||
|
||||
Update `AllClasses()` to return 14 elements:
|
||||
```go
|
||||
func AllClasses() []TrafficClass {
|
||||
return []TrafficClass{
|
||||
ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH,
|
||||
ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP,
|
||||
ClassUnknown1, ClassUnknown2, ClassUnknown3, ClassUnknown4,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add tests to classify/classifier_test.go**
|
||||
|
||||
Add `TestAllClassesCount` subtest:
|
||||
```go
|
||||
func TestAllClassesCount(t *testing.T) {
|
||||
classes := classify.AllClasses()
|
||||
if len(classes) != 14 {
|
||||
t.Errorf("AllClasses() returned %d classes, want 14", len(classes))
|
||||
}
|
||||
// No plain "unknown" should exist
|
||||
for _, c := range classes {
|
||||
if c == "unknown" {
|
||||
t.Error("AllClasses() still contains plain \"unknown\" — should be removed per D-04")
|
||||
}
|
||||
}
|
||||
// All 4 buckets must be present
|
||||
buckets := map[classify.TrafficClass]bool{
|
||||
classify.ClassUnknown1: false, classify.ClassUnknown2: false,
|
||||
classify.ClassUnknown3: false, classify.ClassUnknown4: false,
|
||||
}
|
||||
for _, c := range classes {
|
||||
if _, ok := buckets[c]; ok {
|
||||
buckets[c] = true
|
||||
}
|
||||
}
|
||||
for bucket, found := range buckets {
|
||||
if !found {
|
||||
t.Errorf("AllClasses() missing bucket %q", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `TestHashBucketDistribution` (will fail until Task 2 implements hashBucket — that's the TDD red phase, both tasks are in the same plan so this is fine):
|
||||
```go
|
||||
func TestHashBucketDistribution(t *testing.T) {
|
||||
// hashBucket is unexported, so test through Classify with unmatched TCP ports
|
||||
c := classify.NewClassifier(classify.DefaultRules)
|
||||
seen := make(map[classify.TrafficClass]bool)
|
||||
// Try a range of unmatched TCP ports to hit all 4 buckets
|
||||
for port := uint16(10000); port < 11000; port++ {
|
||||
pkt := buildTCPPacket(t, port)
|
||||
got := c.Classify(pkt)
|
||||
if strings.HasPrefix(string(got.Class), "unknown-") {
|
||||
seen[got.Class] = true
|
||||
}
|
||||
}
|
||||
for _, bucket := range []classify.TrafficClass{
|
||||
classify.ClassUnknown1, classify.ClassUnknown2,
|
||||
classify.ClassUnknown3, classify.ClassUnknown4,
|
||||
} {
|
||||
if !seen[bucket] {
|
||||
t.Errorf("hashBucket never produced %q across ports 10000-10999", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `"strings"` to the import block in classifier_test.go.
|
||||
|
||||
Update `TestClassifyUnknown` subtest (line 220-226): replace the assertion with:
|
||||
```go
|
||||
t.Run("TestClassifyUnknown", func(t *testing.T) {
|
||||
pkt := buildUnknownPacket(t)
|
||||
got := c.Classify(pkt)
|
||||
if !strings.HasPrefix(string(got.Class), "unknown-") {
|
||||
t.Errorf("Unknown packet: got class %q, want unknown-N bucket", got.Class)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Step 3: Update synth/bank_test.go**
|
||||
|
||||
In `TestNewBankHas11Layers` (line 10-21):
|
||||
- Rename function to `TestNewBankHas14Layers`
|
||||
- Change assertion from `!= 11` to `!= 14`
|
||||
- Change error message from `want 11` to `want 14`
|
||||
|
||||
In `TestMixerNoClip` (line 76-102):
|
||||
- Update comment from `All 11 classes` to `All 14 classes`
|
||||
- Update `TotalPackets` from `11000` to `14000`
|
||||
|
||||
**Step 4: Create synth/config_test.go**
|
||||
|
||||
New file:
|
||||
```go
|
||||
package synth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/netsynth/netsynth/classify"
|
||||
)
|
||||
|
||||
func TestClassFreqConfigsComplete(t *testing.T) {
|
||||
for _, class := range classify.AllClasses() {
|
||||
if _, ok := ClassFreqConfigs[class]; !ok {
|
||||
t.Errorf("ClassFreqConfigs missing entry for class %q", class)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumLayersMatchesAllClasses(t *testing.T) {
|
||||
if NumLayers != len(classify.AllClasses()) {
|
||||
t.Errorf("NumLayers=%d but AllClasses() has %d entries", NumLayers, len(classify.AllClasses()))
|
||||
}
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go vet ./classify/... ./synth/... 2>&1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- classify/types.go contains `ClassUnknown1 TrafficClass = "unknown-1"` through `ClassUnknown4 TrafficClass = "unknown-4"`
|
||||
- classify/types.go does NOT contain `ClassUnknown TrafficClass = "unknown"` (plain unknown removed)
|
||||
- classify/types.go AllClasses() returns slice with 14 elements
|
||||
- classify/classifier_test.go contains `func TestAllClassesCount`
|
||||
- classify/classifier_test.go contains `func TestHashBucketDistribution`
|
||||
- classify/classifier_test.go TestClassifyUnknown uses `strings.HasPrefix(string(got.Class), "unknown-")`
|
||||
- synth/bank_test.go contains `func TestNewBankHas14Layers` (not 11)
|
||||
- synth/bank_test.go TestNewBankHas14Layers asserts `!= 14`
|
||||
- synth/config_test.go contains `func TestClassFreqConfigsComplete`
|
||||
- synth/config_test.go contains `func TestNumLayersMatchesAllClasses`
|
||||
- `go vet ./classify/... ./synth/...` exits 0 (compiles cleanly)
|
||||
</acceptance_criteria>
|
||||
<done>Type system extended to 14 classes, all Wave 0 tests written, code compiles. Some tests will fail (RED phase) until Task 2 implements hashBucket and updates config.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement hash-bucketing and update synth config for 14 layers</name>
|
||||
<files>classify/classifier.go, synth/config.go</files>
|
||||
<read_first>classify/classifier.go, synth/config.go, classify/types.go, classify/classifier_test.go, synth/config_test.go, synth/bank_test.go</read_first>
|
||||
<action>
|
||||
**Step 1: Add hashBucket function to classify/classifier.go (per D-01/D-03)**
|
||||
|
||||
Add this unexported function at the bottom of classifier.go:
|
||||
```go
|
||||
// hashBucket maps an unrecognized packet to one of 4 unknown traffic classes.
|
||||
// Deterministic: same (dstPort, protocol) always maps to the same bucket.
|
||||
// ARP and other non-transport packets have dstPort=0, protocol="" -> bucket 0 (ClassUnknown1).
|
||||
func hashBucket(dstPort uint16, protocol string) TrafficClass {
|
||||
var protoNum uint16
|
||||
switch protocol {
|
||||
case "tcp":
|
||||
protoNum = 6
|
||||
case "udp":
|
||||
protoNum = 17
|
||||
case "icmp":
|
||||
protoNum = 1
|
||||
}
|
||||
h := uint32(dstPort)*31 + uint32(protoNum)*7
|
||||
switch h % 4 {
|
||||
case 0:
|
||||
return ClassUnknown1
|
||||
case 1:
|
||||
return ClassUnknown2
|
||||
case 2:
|
||||
return ClassUnknown3
|
||||
default:
|
||||
return ClassUnknown4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Replace all ClassUnknown return sites in Classify() method**
|
||||
|
||||
There are 4 sites where ClassUnknown is used in classifier.go:
|
||||
|
||||
1. Line 23 — initial default: change `ClassUnknown` to `ClassUnknown1` (temporary; this gets overwritten by the final fallthrough, but the initial value doesn't matter since every code path sets Class before returning. However, to avoid a stale reference, use `""` or just leave the field unset since the default zero value is fine).
|
||||
|
||||
Actually, the simplest approach: change the initial default at line 23 to empty string `""`, then ensure every return path sets the class. The 4 return paths are:
|
||||
|
||||
a. **ICMP no-rule-match (line 37):** `return result` after ICMP rule loop fails. Replace with:
|
||||
```go
|
||||
result.Class = hashBucket(result.DstPort, result.Protocol)
|
||||
return result
|
||||
```
|
||||
|
||||
b. **TCP no-rule-match (line 52):** `return result` after TCP rule loop. Replace with:
|
||||
```go
|
||||
result.Class = hashBucket(result.DstPort, result.Protocol)
|
||||
return result
|
||||
```
|
||||
|
||||
c. **UDP no-rule-match (line 67):** `return result` after UDP rule loop. Replace with:
|
||||
```go
|
||||
result.Class = hashBucket(result.DstPort, result.Protocol)
|
||||
return result
|
||||
```
|
||||
|
||||
d. **No transport layer (line 71):** `return result` at the very end. Replace with:
|
||||
```go
|
||||
result.Class = hashBucket(result.DstPort, result.Protocol)
|
||||
return result
|
||||
```
|
||||
|
||||
Remove the initial `Class: ClassUnknown` at line 23. Instead set `Class: ""` or omit the field (zero value for string is ""). The `Classify` doc comment referencing "Per D-03: returns ClassUnknown" should be updated to "Per D-03: returns ClassUnknown1-4 via hash-bucketing".
|
||||
|
||||
**Step 3: Update synth/config.go (per D-02/D-05/D-06)**
|
||||
|
||||
Replace `NumLayers = 11` with `NumLayers = 14`.
|
||||
|
||||
This automatically changes `GainPerLayer = 1.0 / float64(NumLayers)` to ~0.0714.
|
||||
|
||||
Remove the `classify.ClassUnknown` entry from `ClassFreqConfigs`. Add 4 new entries:
|
||||
|
||||
```go
|
||||
// D-05: Unknown buckets in 850-1100 Hz dissonant range, detuned intervals
|
||||
// D-06: Same dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} for all 4
|
||||
classify.ClassUnknown1: {862.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.6},
|
||||
classify.ClassUnknown2: {920.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.6},
|
||||
classify.ClassUnknown3: {981.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.9},
|
||||
classify.ClassUnknown4: {1047.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.9},
|
||||
```
|
||||
|
||||
Remove the old `classify.ClassUnknown` entry and its comment about 437 Hz / D-04 beating.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go test ./classify/... ./synth/... -count=1 -v 2>&1 | tail -40</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- classify/classifier.go contains `func hashBucket(dstPort uint16, protocol string) TrafficClass`
|
||||
- classify/classifier.go does NOT contain the string `ClassUnknown` without a digit suffix (no bare ClassUnknown references)
|
||||
- classify/classifier.go hashBucket uses `uint32(dstPort)*31 + uint32(protoNum)*7` and `h % 4`
|
||||
- synth/config.go contains `NumLayers = 14`
|
||||
- synth/config.go contains `classify.ClassUnknown1:` through `classify.ClassUnknown4:`
|
||||
- synth/config.go ClassUnknown1 BaseHz is 862.0, ClassUnknown2 is 920.0, ClassUnknown3 is 981.0, ClassUnknown4 is 1047.0
|
||||
- synth/config.go does NOT contain `classify.ClassUnknown:` (old entry removed)
|
||||
- `go test ./classify/... -count=1` passes (all classifier tests green including TestHashBucketDistribution, TestAllClassesCount, TestClassifyUnknown)
|
||||
- `go test ./synth/... -count=1` passes (TestNewBankHas14Layers, TestClassFreqConfigsComplete, TestNumLayersMatchesAllClasses, TestMixerNoClip all green)
|
||||
</acceptance_criteria>
|
||||
<done>Hash-bucketing classifier routes unrecognized traffic to 4 distinct unknown classes. Synth config has tone entries for all 14 classes. All classify and synth tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# All classify and synth tests pass
|
||||
go test ./classify/... ./synth/... -count=1
|
||||
|
||||
# Full suite still passes (no regressions in encode, aggregate, capture)
|
||||
go test ./... -count=1
|
||||
|
||||
# No bare ClassUnknown references remain (should only find ClassUnknown1-4)
|
||||
grep -rn 'ClassUnknown[^1234]' classify/ synth/ encode/ cmd/ aggregate/ | grep -v '_test.go.*unknown-' | grep -v '// '
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- AllClasses() returns 14 classes; ClassUnknown is fully removed
|
||||
- hashBucket deterministically maps (dstPort, protocol) to ClassUnknown1-4
|
||||
- All 4 unknown buckets have FreqConfig entries in 850-1100 Hz range
|
||||
- GainPerLayer = 1/14; TestMixerNoClip passes with 14 layers
|
||||
- All tests in classify/ and synth/ pass
|
||||
- No bare ClassUnknown references remain in production code
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-pipeline-integration-and-mvp/03-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 03-pipeline-integration-and-mvp
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["03-01"]
|
||||
files_modified:
|
||||
- cmd/netsynth/main.go
|
||||
autonomous: false
|
||||
requirements: [CAPT-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User runs netsynth -i <iface> -o out.mp3, generates traffic, presses Ctrl+C, and receives a valid playable MP3"
|
||||
- "Protocol summary prints to stderr BEFORE encoding begins"
|
||||
- "User sees 'Encoding N windows to <path>...' status message during encoding"
|
||||
- "User sees 'Saved <path> (Xs, N KB, encoded in Xs)' confirmation after encoding"
|
||||
- "Zero-packet captures produce an error message, not a corrupt file"
|
||||
artifacts:
|
||||
- path: "cmd/netsynth/main.go"
|
||||
provides: "End-to-end pipeline wiring and encoding feedback"
|
||||
contains: "encode.RunSynthesis"
|
||||
key_links:
|
||||
- from: "cmd/netsynth/main.go"
|
||||
to: "encode/mp3.go"
|
||||
via: "encode.RunSynthesis(collectedSnapshots, outputPath)"
|
||||
pattern: "encode\\.RunSynthesis"
|
||||
- from: "cmd/netsynth/main.go"
|
||||
to: "aggregate/summary.go"
|
||||
via: "PrintSummary called before RunSynthesis (D-08)"
|
||||
pattern: "PrintSummary.*\n.*Encoding"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the capture-classify-aggregate pipeline into the audio synthesis engine, completing the v1 MVP end-to-end flow. Add encoding progress feedback messages per D-07/D-08/D-09.
|
||||
|
||||
Purpose: Implements CAPT-03 — the final integration that makes `netsynth -i eth0 -o out.mp3` produce a real audio file from live network traffic.
|
||||
Output: Updated main.go with RunSynthesis call, encoding status/saved messages, and correct summary ordering.
|
||||
</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/03-pipeline-integration-and-mvp/03-CONTEXT.md
|
||||
@.planning/phases/03-pipeline-integration-and-mvp/03-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
From encode/mp3.go:
|
||||
```go
|
||||
// RunSynthesis consumes a slice of WindowSnapshots, renders audio via OscillatorBank,
|
||||
// and encodes to MP3 at outputPath.
|
||||
// Returns an error if zero packets were captured (OUT-03).
|
||||
func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error
|
||||
```
|
||||
|
||||
From aggregate/accumulator.go:
|
||||
```go
|
||||
const DefaultWindowMs = 500
|
||||
```
|
||||
|
||||
From aggregate/summary.go:
|
||||
```go
|
||||
func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64)
|
||||
func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot)
|
||||
```
|
||||
|
||||
From cmd/netsynth/main.go (CURRENT — lines 91-109 are the integration zone):
|
||||
```go
|
||||
// Accumulate snapshots for synthesis (Phase 3 will pass to encode.RunSynthesis)
|
||||
totals := make(map[classify.TrafficClass]int64)
|
||||
var collectedSnapshots []classify.WindowSnapshot
|
||||
for snap := range snapshots {
|
||||
collectedSnapshots = append(collectedSnapshots, snap)
|
||||
aggregate.AccumulateTotals(totals, snap)
|
||||
}
|
||||
|
||||
// TODO(phase-3): Pass collectedSnapshots to encode.RunSynthesis(collectedSnapshots, outputPath)
|
||||
_ = collectedSnapshots
|
||||
|
||||
// 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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Wire RunSynthesis and add encoding feedback messages</name>
|
||||
<files>cmd/netsynth/main.go</files>
|
||||
<read_first>cmd/netsynth/main.go, encode/mp3.go, aggregate/summary.go</read_first>
|
||||
<action>
|
||||
Modify `cmd/netsynth/main.go` to replace the TODO stub (lines 99-109) with the complete pipeline wiring and encoding feedback. The changes are:
|
||||
|
||||
**Step 1: Add import**
|
||||
|
||||
Add `"github.com/netsynth/netsynth/encode"` to the import block. The `time` import already exists.
|
||||
|
||||
**Step 2: Replace lines 99-109 with the following sequence**
|
||||
|
||||
Remove:
|
||||
```go
|
||||
// TODO(phase-3): Pass collectedSnapshots to encode.RunSynthesis(collectedSnapshots, outputPath)
|
||||
_ = collectedSnapshots
|
||||
|
||||
// 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
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```go
|
||||
// D-08: Print protocol summary BEFORE encoding — user sees stats immediately
|
||||
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)
|
||||
|
||||
// D-07: Encoding status line
|
||||
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-09: Saved confirmation with path, audio duration, file size, encoding time
|
||||
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
|
||||
```
|
||||
|
||||
**Step 3: Update the Long description**
|
||||
|
||||
Change line 32 from:
|
||||
```go
|
||||
Long: "NetSynth captures network traffic, classifies it by protocol, and (in future phases) synthesizes an ambient MP3 soundscape.",
|
||||
```
|
||||
to:
|
||||
```go
|
||||
Long: "NetSynth captures network traffic, classifies it by protocol, and synthesizes an ambient MP3 soundscape.",
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- `encode.RunSynthesis` already handles the zero-packet guard (returns error, no corrupt file created) — we just propagate the error via `fmt.Errorf("synthesis failed: %w", err)`
|
||||
- Audio duration is computed from snapshot count, NOT wall-clock time: `float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0` — uses float64 to avoid integer truncation for short captures (Pitfall 4)
|
||||
- `os.Stat` is only called AFTER confirming `RunSynthesis` returned nil (Pitfall 5)
|
||||
- The `_ = collectedSnapshots` line and the TODO comment are completely removed
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go build -o /tmp/netsynth-test ./cmd/netsynth && echo "BUILD OK" && go test ./... -count=1 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- cmd/netsynth/main.go contains `"github.com/netsynth/netsynth/encode"` in imports
|
||||
- cmd/netsynth/main.go contains `encode.RunSynthesis(collectedSnapshots, outputPath)`
|
||||
- cmd/netsynth/main.go contains `fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n"`
|
||||
- cmd/netsynth/main.go contains `fmt.Fprintf(os.Stderr, "Saved %s (%.1fs, %d KB, encoded in %.1fs)\n"`
|
||||
- cmd/netsynth/main.go does NOT contain `TODO(phase-3)`
|
||||
- cmd/netsynth/main.go does NOT contain `_ = collectedSnapshots`
|
||||
- cmd/netsynth/main.go `PrintSummary` call appears BEFORE `encode.RunSynthesis` call (D-08)
|
||||
- cmd/netsynth/main.go audioDuration uses `float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0` (not integer division)
|
||||
- `go build ./cmd/netsynth` exits 0
|
||||
- `go test ./... -count=1` all packages pass
|
||||
</acceptance_criteria>
|
||||
<done>The complete v1 MVP pipeline is wired: capture -> classify -> aggregate -> synthesize -> encode MP3. Protocol summary prints before encoding. Encoding status and saved confirmation messages display path, audio duration, file size, and encoding time. Binary builds successfully. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Verify end-to-end MVP flow</name>
|
||||
<files>cmd/netsynth/main.go</files>
|
||||
<read_first>cmd/netsynth/main.go</read_first>
|
||||
<action>
|
||||
Human verification of the complete end-to-end MVP flow. The binary has been built and all automated tests pass. This checkpoint verifies the live capture-to-MP3 pipeline works with real network traffic and produces the expected UX output.
|
||||
</action>
|
||||
<what-built>Complete NetSynth v1 MVP: live capture -> classification -> audio synthesis -> MP3 output with encoding feedback</what-built>
|
||||
<how-to-verify>
|
||||
1. Build the binary: `go build -o ./netsynth ./cmd/netsynth`
|
||||
2. Run with loopback interface: `sudo ./netsynth -i lo -o /tmp/test-mvp.mp3`
|
||||
3. In another terminal, generate some traffic: `ping -c 5 127.0.0.1` and `curl http://127.0.0.1:80 2>/dev/null || true`
|
||||
4. Press Ctrl+C in the netsynth terminal
|
||||
5. Verify stderr output shows:
|
||||
- Protocol summary (packet counts per class) printed FIRST
|
||||
- "Encoding N windows to /tmp/test-mvp.mp3..." message
|
||||
- "Saved /tmp/test-mvp.mp3 (Xs, N KB, encoded in Xs)" confirmation
|
||||
6. Verify the MP3 file exists and has non-zero size: `ls -la /tmp/test-mvp.mp3`
|
||||
7. Optionally validate with ffprobe: `ffprobe /tmp/test-mvp.mp3 2>&1 | head -20`
|
||||
8. Test zero-packet case: run and immediately Ctrl+C (no traffic): should show error, no file created
|
||||
</how-to-verify>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go build -o /tmp/netsynth-verify ./cmd/netsynth && echo "Binary builds OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Binary builds without errors
|
||||
- Running with loopback + traffic + Ctrl+C produces a non-zero MP3 file
|
||||
- stderr shows protocol summary, then "Encoding...", then "Saved..." in that order
|
||||
- Zero-packet run shows error message, no file created
|
||||
</acceptance_criteria>
|
||||
<done>User has verified the end-to-end MVP flow works with live traffic on loopback interface.</done>
|
||||
<resume-signal>Type "approved" or describe issues</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# Full test suite
|
||||
go test ./... -count=1
|
||||
|
||||
# Binary builds
|
||||
go build -o /tmp/netsynth-verify ./cmd/netsynth
|
||||
|
||||
# No TODO(phase-3) remaining
|
||||
grep -rn 'TODO(phase-3)' cmd/ classify/ synth/ encode/ aggregate/
|
||||
|
||||
# No bare ClassUnknown references (from Plan 01, verify still clean)
|
||||
grep -rn 'ClassUnknown[^1234]' classify/ synth/ encode/ cmd/ aggregate/ | grep -v test | grep -v '//'
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Binary builds and runs: `netsynth -i lo -o out.mp3` produces a valid MP3
|
||||
- Protocol summary appears before encoding messages on stderr
|
||||
- Saved line shows path, audio duration, file size, encoding time
|
||||
- Zero-packet capture returns error without creating a file
|
||||
- All tests pass (`go test ./... -count=1`)
|
||||
- No TODO(phase-3) stubs remain
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-pipeline-integration-and-mvp/03-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user