diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index aca6c0a..6ca4674 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -91,7 +91,10 @@ Plans:
2. A pcap or live capture that triggers Mail traffic produces tones that are audibly in the same timbral family — same waveform type, similar frequency register — while still being distinguishable from each other
3. Users can define `[groups]` in their TOML config to reassign a protocol to a different sound family, and --print-config reflects the reassignment
4. `go test ./...` passes and a listening test on a representative pcap confirms family identity is perceptually clear
-**Plans**: TBD
+**Plans:** 2 plans
+Plans:
+- [ ] 11-01-PLAN.md — Add 21 ClassFreqConfigs entries, update AllClasses() to 35, fix all count tests
+- [ ] 11-02-PLAN.md — Refactor PrintConfig for group headers, add [groups] TOML support
## Progress
@@ -107,4 +110,4 @@ Plans:
| 8. Test and Constant Cleanup | v1.2 | 1/1 | Complete | 2026-03-27 |
| 9. Frequency Design and Group Architecture | v1.2 | 1/2 | In Progress| |
| 10. Classification Layer | v1.2 | 2/2 | Complete | 2026-03-27 |
-| 11. Synthesis and Config Layer | v1.2 | 0/? | Not started | - |
+| 11. Synthesis and Config Layer | v1.2 | 0/2 | In Progress | - |
diff --git a/.planning/phases/11-synthesis-and-config-layer/11-01-PLAN.md b/.planning/phases/11-synthesis-and-config-layer/11-01-PLAN.md
new file mode 100644
index 0000000..553feee
--- /dev/null
+++ b/.planning/phases/11-synthesis-and-config-layer/11-01-PLAN.md
@@ -0,0 +1,224 @@
+---
+phase: 11-synthesis-and-config-layer
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - synth/config.go
+ - classify/types.go
+ - synth/bank_test.go
+ - config/config_test.go
+ - classify/classifier_test.go
+autonomous: true
+requirements:
+ - GRP-02
+ - GRP-03
+
+must_haves:
+ truths:
+ - "ClassFreqConfigs has exactly 35 entries matching AllClasses()"
+ - "Every new class has correct Hz, waveform, pan, and group from frequency allocation table"
+ - "LDAP, Kerberos, Syslog appear in AllClasses() and have Infrastructure group with Triangle waveform"
+ - "go test ./synth/... ./classify/... ./config/... all pass"
+ artifacts:
+ - path: "synth/config.go"
+ provides: "21 new ClassFreqConfigs entries"
+ contains: "classify.ClassIMAP"
+ - path: "classify/types.go"
+ provides: "AllClasses() returns 35 entries including LDAP/Kerberos/Syslog"
+ contains: "ClassLDAP"
+ key_links:
+ - from: "synth/config.go"
+ to: "classify/types.go"
+ via: "ClassFreqConfigs references TrafficClass constants"
+ pattern: "classify\\.Class(IMAP|POP3|SMTPSub|RDP|Telnet|VNC|FTP|SMB|TFTP|MySQL|PostgreSQL|Redis|MongoDB|SIP|QUIC|MDNS|SSDP|SNMP|LDAP|Kerberos|Syslog)"
+---
+
+
+Add all 21 missing ClassFreqConfigs entries and update AllClasses() to include LDAP/Kerberos/Syslog, then fix every hardcoded count assertion across synth, config, and classify test files.
+
+Purpose: This is the foundational data layer for Phase 11 -- all subsequent work (PrintConfig group headers, TOML [groups]) depends on all 35 classes having complete synthesis configs.
+Output: synth/config.go with 35 ClassFreqConfigs entries, classify/types.go with 35-entry AllClasses(), all count-based tests green.
+
+
+
+@$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
+@synth/config.go
+@classify/types.go
+@synth/bank_test.go
+@synth/config_test.go
+@config/config_test.go
+@classify/classifier_test.go
+
+
+
+
+From classify/types.go:
+```go
+type TrafficClass string
+// Constants: ClassIMAP, ClassPOP3, ClassSMTPSub, ClassFTP, ClassSMB, ClassTFTP,
+// ClassRDP, ClassTelnet, ClassVNC, ClassMySQL, ClassPostgreSQL, ClassRedis,
+// ClassMongoDB, ClassMDNS, ClassSSDP, ClassSNMP, ClassSIP, ClassQUIC,
+// ClassLDAP, ClassKerberos, ClassSyslog
+func AllClasses() []TrafficClass
+```
+
+From synth/config.go:
+```go
+type FreqConfig struct {
+ BaseHz float64
+ Harmonics []HarmonicDef
+ Pan float64
+ WaveformType WaveformType
+ Group string
+}
+var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{...}
+func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef
+```
+
+
+
+
+
+
+ Task 1: Add 21 ClassFreqConfigs entries and update AllClasses()
+ synth/config.go, classify/types.go
+ synth/config.go, classify/types.go
+
+**synth/config.go** -- Add 21 new entries to the ClassFreqConfigs map, after the existing entries and before the closing brace. Use WaveformPresetHarmonics() for all entries (per D-01). The exact values from the frequency allocation table (lines 74-110 of synth/config.go):
+
+```
+// --- Infrastructure additions (Triangle, 93-118 Hz) ---
+classify.ClassMDNS: {BaseHz: 93.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 93.0, SampleRate), Pan: 0.3, Group: "Infrastructure"}
+classify.ClassSSDP: {BaseHz: 105.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 105.0, SampleRate), Pan: -0.2, Group: "Infrastructure"}
+classify.ClassSNMP: {BaseHz: 118.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 118.0, SampleRate), Pan: 0.2, Group: "Infrastructure"}
+// --- Web addition (Sawtooth, 190 Hz) ---
+classify.ClassQUIC: {BaseHz: 190.0, WaveformType: WaveformSawtooth, Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 190.0, SampleRate), Pan: -0.2, Group: "Web"}
+// --- Mail additions (Triangle, 241-305 Hz) ---
+classify.ClassIMAP: {BaseHz: 241.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 241.0, SampleRate), Pan: 0.3, Group: "Mail"}
+classify.ClassPOP3: {BaseHz: 271.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 271.0, SampleRate), Pan: 0.4, Group: "Mail"}
+classify.ClassSMTPSub: {BaseHz: 305.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 305.0, SampleRate), Pan: 0.5, Group: "Mail"}
+// --- Remote Access additions (Square, 385-485 Hz) ---
+classify.ClassRDP: {BaseHz: 385.0, WaveformType: WaveformSquare, Harmonics: WaveformPresetHarmonics(WaveformSquare, 385.0, SampleRate), Pan: -0.6, Group: "Remote Access"}
+classify.ClassTelnet: {BaseHz: 432.0, WaveformType: WaveformSquare, Harmonics: WaveformPresetHarmonics(WaveformSquare, 432.0, SampleRate), Pan: -0.5, Group: "Remote Access"}
+classify.ClassVNC: {BaseHz: 485.0, WaveformType: WaveformSquare, Harmonics: WaveformPresetHarmonics(WaveformSquare, 485.0, SampleRate), Pan: -0.4, Group: "Remote Access"}
+// --- File Transfer additions (Square, 545-687 Hz) ---
+classify.ClassFTP: {BaseHz: 545.0, WaveformType: WaveformSquare, Harmonics: WaveformPresetHarmonics(WaveformSquare, 545.0, SampleRate), Pan: 0.5, Group: "File Transfer"}
+classify.ClassSMB: {BaseHz: 612.0, WaveformType: WaveformSquare, Harmonics: WaveformPresetHarmonics(WaveformSquare, 612.0, SampleRate), Pan: 0.6, Group: "File Transfer"}
+classify.ClassTFTP: {BaseHz: 687.0, WaveformType: WaveformSquare, Harmonics: WaveformPresetHarmonics(WaveformSquare, 687.0, SampleRate), Pan: 0.7, Group: "File Transfer"}
+// --- Database additions (Sawtooth, 1543-2182 Hz) ---
+classify.ClassMySQL: {BaseHz: 1543.0, WaveformType: WaveformSawtooth, Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1543.0, SampleRate), Pan: -0.4, Group: "Database"}
+classify.ClassPostgreSQL: {BaseHz: 1732.0, WaveformType: WaveformSawtooth, Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1732.0, SampleRate), Pan: -0.2, Group: "Database"}
+classify.ClassRedis: {BaseHz: 1944.0, WaveformType: WaveformSawtooth, Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1944.0, SampleRate), Pan: 0.2, Group: "Database"}
+classify.ClassMongoDB: {BaseHz: 2182.0, WaveformType: WaveformSawtooth, Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 2182.0, SampleRate), Pan: 0.4, Group: "Database"}
+// --- VoIP (Sine, 2449 Hz) ---
+classify.ClassSIP: {BaseHz: 2449.0, WaveformType: WaveformSine, Harmonics: WaveformPresetHarmonics(WaveformSine, 2449.0, SampleRate), Pan: 0.0, Group: "VoIP"}
+// --- Infrastructure auto-assigned (Triangle, 2950-3250 Hz) per D-02 ---
+classify.ClassLDAP: {BaseHz: 2950.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 2950.0, SampleRate), Pan: -0.2, Group: "Infrastructure"}
+classify.ClassKerberos: {BaseHz: 3250.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 3250.0, SampleRate), Pan: 0.0, Group: "Infrastructure"}
+classify.ClassSyslog: {BaseHz: 3050.0, WaveformType: WaveformTriangle, Harmonics: WaveformPresetHarmonics(WaveformTriangle, 3050.0, SampleRate), Pan: 0.2, Group: "Infrastructure"}
+```
+
+Place new entries in the map grouped by family with section comments matching the existing pattern (e.g., `// --- Infrastructure additions ...`). Insert them logically:
+- Infrastructure additions (mDNS, SSDP, SNMP) after ClassDHCP and before ClassDNS (since 93/105/118 Hz come between DHCP=82 and DNS=133)
+- Web addition (QUIC) after ClassHTTP
+- Mail additions after ClassSMTP
+- Remote Access additions after ClassSSH
+- File Transfer after Unknown entries
+- Database after Unknown entries
+- VoIP after Database
+- LDAP/Kerberos/Syslog at end (auto-assigned range)
+
+**classify/types.go** -- Per D-02 and D-03:
+1. Add ClassLDAP, ClassKerberos, ClassSyslog to AllClasses() in the Infrastructure section, after ClassSNMP.
+2. Remove the comment "Excludes ClassLDAP, ClassKerberos, and ClassSyslog" from the AllClasses() doc comment.
+3. Update the doc comment to say "AllClasses returns all known traffic classes in display order."
+4. The AllClasses() function should now return 35 entries total.
+
+
+ cd /home/dev/workspace/yoloyolo && go build ./synth/... ./classify/...
+
+
+- `grep -c "classify\.Class" synth/config.go` shows at least 35 occurrences in ClassFreqConfigs
+- `grep "ClassLDAP" classify/types.go` appears in AllClasses() return slice
+- `grep "ClassKerberos" classify/types.go` appears in AllClasses() return slice
+- `grep "ClassSyslog" classify/types.go` appears in AllClasses() return slice
+- `go build ./synth/... ./classify/...` succeeds
+
+ ClassFreqConfigs has 35 entries with correct Hz/waveform/pan/group values. AllClasses() returns 35 entries including LDAP/Kerberos/Syslog. Both packages compile.
+
+
+
+ Task 2: Fix all hardcoded count assertions in tests
+ synth/bank_test.go, config/config_test.go, classify/classifier_test.go
+ synth/bank_test.go, config/config_test.go, classify/classifier_test.go
+
+Update all hardcoded count assertions to reflect the new 35-entry state. Per research Pitfall 1 and Pitfall 2:
+
+**synth/bank_test.go:**
+- Line 10: Rename `TestNewBankHas14Layers` to `TestNewBankHasAllLayers`
+- Line 12: Change `len(b.layers) != 14` to `len(b.layers) != len(classify.AllClasses())`
+- Line 13: Change `want 14` to a dynamic message using `len(classify.AllClasses())`
+
+**config/config_test.go:**
+- Line 120: Change `len(cfgs) != 14` to `len(cfgs) != len(classify.AllClasses())` in TestLoadNoConfig
+- Line 149: Change `len(cfgs) != 14` to `len(cfgs) != len(classify.AllClasses())` in TestLoadUnknownClass
+- Line 180: Change `len(cfgs) != 14` to `len(cfgs) != len(classify.AllClasses())` in TestLoadAllDefaultsPresent
+- Line 617: Change `len(result.FreqCfgs) != 14` to `len(result.FreqCfgs) != len(classify.AllClasses())` in TestLoadNoConfigReturnsLoadResult
+- Lines 407-416 in TestPrintConfigContainsAllClasses: Replace the hardcoded `classNames` slice with a loop over `classify.AllClasses()`. Change to:
+ ```go
+ for _, cls := range classify.AllClasses() {
+ if !strings.Contains(output, string(cls)) {
+ t.Errorf("PrintConfig output missing class %q", cls)
+ }
+ }
+ ```
+
+**classify/classifier_test.go:**
+- Line 459 (approximately): Change `want 32` to `want 35` in TestAllClassesCount. Update the assertion value from 32 to 35.
+
+Use `len(classify.AllClasses())` for dynamic counts wherever possible (synth and config tests). For classifier_test.go, use the literal 35 since the test is specifically verifying the count is a known value per D-03.
+
+
+ cd /home/dev/workspace/yoloyolo && go test ./synth/... ./classify/... ./config/...
+
+
+- `go test ./synth/...` passes (0 failures)
+- `go test ./classify/...` passes (0 failures)
+- `go test ./config/...` passes (0 failures)
+- `grep "14" synth/bank_test.go` returns no lines with hardcoded layer counts
+- `grep 'want 14' config/config_test.go` returns no matches
+- `grep 'want 32' classify/classifier_test.go` returns no matches
+
+ All test suites pass with 35 classes. No hardcoded counts of 14 or 32 remain in test assertions. TestNewBankHas14Layers renamed to TestNewBankHasAllLayers.
+
+
+
+
+
+```bash
+cd /home/dev/workspace/yoloyolo && go test ./...
+```
+All tests pass. ClassFreqConfigs has 35 entries matching AllClasses().
+
+
+
+- `go test ./...` passes fully
+- ClassFreqConfigs map has exactly 35 entries
+- AllClasses() returns exactly 35 entries
+- No hardcoded counts of 14 or 32 remain in test files
+- Every new entry uses WaveformPresetHarmonics() (not hand-tuned harmonics)
+- LDAP/Kerberos/Syslog have Group="Infrastructure" and WaveformTriangle
+
+
+
diff --git a/.planning/phases/11-synthesis-and-config-layer/11-02-PLAN.md b/.planning/phases/11-synthesis-and-config-layer/11-02-PLAN.md
new file mode 100644
index 0000000..1831e6a
--- /dev/null
+++ b/.planning/phases/11-synthesis-and-config-layer/11-02-PLAN.md
@@ -0,0 +1,415 @@
+---
+phase: 11-synthesis-and-config-layer
+plan: 02
+type: execute
+wave: 2
+depends_on: ["11-01"]
+files_modified:
+ - config/config.go
+ - config/config_test.go
+autonomous: true
+requirements:
+ - GRP-02
+ - GRP-03
+
+must_haves:
+ truths:
+ - "PrintConfig output groups classes by family with section header comments"
+ - "Groups appear in canonical order: Infrastructure, Web, Mail, Remote Access, File Transfer, Database, Discovery, VoIP, Unknown"
+ - "Within each group, classes are sorted by ascending BaseHz"
+ - "User-defined classes appear under a User-defined section header after all built-in groups"
+ - "Users can define [groups] in TOML to reassign a class to a different group"
+ - "Unknown class names in [groups] produce a warning, not an error"
+ - "Group reassignment only affects PrintConfig grouping, not frequency or waveform"
+ artifacts:
+ - path: "config/config.go"
+ provides: "Group-ordered PrintConfig, [groups] TOML support, applyGroupOverrides function"
+ exports: ["PrintConfig", "Load", "LoadResult"]
+ - path: "config/config_test.go"
+ provides: "Tests for group headers, group reassignment, unknown class warning"
+ contains: "TestPrintConfigGroupHeaders"
+ key_links:
+ - from: "config/config.go"
+ to: "synth/config.go"
+ via: "PrintConfig reads FreqConfig.Group field"
+ pattern: "cfg\\.Group"
+ - from: "config/config.go"
+ to: "classify/types.go"
+ via: "PrintConfig iterates AllClasses() and groups by Group field"
+ pattern: "classify\\.AllClasses"
+---
+
+
+Refactor PrintConfig to group classes by their Group field with section headers (GRP-02), and add [groups] TOML config support for reassigning protocols to different sound families (GRP-03).
+
+Purpose: This is the user-facing output change that makes --print-config show organized, family-coherent class listings, and gives users the ability to rearrange groupings via TOML config.
+Output: PrintConfig emits group headers in canonical order; [groups] TOML table parsed and applied; tests cover group headers, reassignment, and unknown class warnings.
+
+
+
+@$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/11-synthesis-and-config-layer/11-01-SUMMARY.md
+@config/config.go
+@config/config_test.go
+
+
+
+
+From synth/config.go (after Plan 01):
+```go
+type FreqConfig struct {
+ BaseHz float64
+ Harmonics []HarmonicDef
+ Pan float64
+ WaveformType WaveformType
+ Group string // "Infrastructure", "Web", "Mail", "Remote Access", "File Transfer", "Database", "Discovery", "VoIP", "Unknown"
+}
+var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{...} // 35 entries
+```
+
+From classify/types.go (after Plan 01):
+```go
+func AllClasses() []TrafficClass // returns 35 entries including LDAP/Kerberos/Syslog
+```
+
+From config/config.go (current):
+```go
+type rawConfig struct {
+ Sounds map[string]SoundOverride `toml:"sounds"`
+ Rules []RawRule `toml:"rules"`
+}
+type LoadResult struct {
+ FreqCfgs map[classify.TrafficClass]synth.FreqConfig
+ UserRules []classify.Rule
+ ConfigPath string
+ AutoClasses map[classify.TrafficClass]bool
+}
+func Load(configPath string) (LoadResult, error)
+func PrintConfig(result LoadResult) string
+```
+
+
+
+
+
+
+ Task 1: Add [groups] TOML support and refactor PrintConfig for group headers
+ config/config.go
+ config/config.go
+
+Three changes to config/config.go:
+
+**1. Add Groups field to rawConfig struct (per D-07):**
+```go
+type rawConfig struct {
+ Sounds map[string]SoundOverride `toml:"sounds"`
+ Rules []RawRule `toml:"rules"`
+ Groups map[string]string `toml:"groups"`
+}
+```
+
+**2. Add applyGroupOverrides function and wire into Load() (per D-07/D-08/D-09):**
+
+Add a new function `applyGroupOverrides`:
+```go
+// applyGroupOverrides overlays [groups] reassignments onto freqCfgs.Group in-place.
+// Unknown class names produce a warning to stderr (D-09).
+// Unknown group names are silently accepted -- users can invent custom groups (D-09).
+func applyGroupOverrides(cfgs map[classify.TrafficClass]synth.FreqConfig, groups map[string]string) {
+ for className, groupName := range groups {
+ cls := classify.TrafficClass(className)
+ cfg, known := cfgs[cls]
+ if !known {
+ fmt.Fprintf(os.Stderr, "Warning: config: [groups]: unknown class %q (ignored)\n", className)
+ continue
+ }
+ cfg.Group = groupName
+ cfgs[cls] = cfg
+ }
+}
+```
+
+In Load(), after the `merge(freqCfgs, raw.Sounds)` call (around line 101), add:
+```go
+applyGroupOverrides(freqCfgs, raw.Groups)
+```
+
+This goes AFTER merge so that group reassignment is the last transformation before returning. The call should be present in both code paths (with config file). The no-config path (line 77) does not need it since there is no raw.Groups to apply.
+
+**3. Refactor PrintConfig for group-ordered output (per D-04/D-05/D-06):**
+
+Replace the current flat `AllClasses()` iteration (lines 298-321) and the user-defined section (lines 323-333) with group-ordered output.
+
+Add a package-level variable for canonical group order:
+```go
+// groupOrder defines the canonical display order for --print-config section headers (D-04).
+var groupOrder = []string{
+ "Infrastructure", "Web", "Mail", "Remote Access",
+ "File Transfer", "Database", "Discovery", "VoIP", "Unknown",
+}
+```
+
+Replace the built-in and user-defined emission blocks with:
+```go
+// Build group -> []TrafficClass index from AllClasses()
+builtinByGroup := map[string][]classify.TrafficClass{}
+builtinSet := map[classify.TrafficClass]bool{}
+for _, cls := range classify.AllClasses() {
+ builtinSet[cls] = true
+ cfg := result.FreqCfgs[cls]
+ grp := cfg.Group
+ builtinByGroup[grp] = append(builtinByGroup[grp], cls)
+}
+
+// Sort each group by ascending BaseHz (D-05) using result.FreqCfgs (effective Hz, not defaults)
+for grp := range builtinByGroup {
+ classes := builtinByGroup[grp]
+ sort.Slice(classes, func(i, j int) bool {
+ return result.FreqCfgs[classes[i]].BaseHz < result.FreqCfgs[classes[j]].BaseHz
+ })
+}
+
+// Emit built-in classes grouped with headers (D-04)
+for _, grp := range groupOrder {
+ classes, ok := builtinByGroup[grp]
+ if !ok || len(classes) == 0 {
+ continue
+ }
+ fmt.Fprintf(&sb, "# %s\n\n", grp)
+ for _, cls := range classes {
+ cfg := result.FreqCfgs[cls]
+ annotation := classAnnotation(cls, cfg, result.AutoClasses)
+ fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", string(cls), cfg.BaseHz, annotation)
+ fmt.Fprintf(&sb, "[sounds.%s]\n", string(cls))
+ fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
+ fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
+ fmt.Fprintf(&sb, "\n")
+ }
+}
+
+// Check for custom groups (from [groups] reassignment) that are not in groupOrder
+// These classes were already emitted under their reassigned group if the group is canonical.
+// For non-canonical group names (user-invented), collect and emit separately.
+customGroups := map[string][]classify.TrafficClass{}
+for _, cls := range classify.AllClasses() {
+ cfg := result.FreqCfgs[cls]
+ grp := cfg.Group
+ isCanonical := false
+ for _, cg := range groupOrder {
+ if grp == cg {
+ isCanonical = true
+ break
+ }
+ }
+ if !isCanonical {
+ customGroups[grp] = append(customGroups[grp], cls)
+ }
+}
+// Sort and emit custom group sections
+var customGroupNames []string
+for grp := range customGroups {
+ customGroupNames = append(customGroupNames, grp)
+}
+sort.Strings(customGroupNames)
+for _, grp := range customGroupNames {
+ classes := customGroups[grp]
+ sort.Slice(classes, func(i, j int) bool {
+ return result.FreqCfgs[classes[i]].BaseHz < result.FreqCfgs[classes[j]].BaseHz
+ })
+ fmt.Fprintf(&sb, "# %s\n\n", grp)
+ for _, cls := range classes {
+ cfg := result.FreqCfgs[cls]
+ annotation := classAnnotation(cls, cfg, result.AutoClasses)
+ fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", string(cls), cfg.BaseHz, annotation)
+ fmt.Fprintf(&sb, "[sounds.%s]\n", string(cls))
+ fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
+ fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
+ fmt.Fprintf(&sb, "\n")
+ }
+}
+
+// Emit user-defined classes (in FreqCfgs but not in AllClasses) under "# User-defined" (D-06)
+var userClasses []string
+for cls := range result.FreqCfgs {
+ if !builtinSet[cls] {
+ userClasses = append(userClasses, string(cls))
+ }
+}
+sort.Strings(userClasses)
+if len(userClasses) > 0 {
+ fmt.Fprintf(&sb, "# User-defined\n\n")
+ for _, clsStr := range userClasses {
+ cls := classify.TrafficClass(clsStr)
+ cfg := result.FreqCfgs[cls]
+ annotation := classAnnotation(cls, cfg, result.AutoClasses)
+ fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", clsStr, cfg.BaseHz, annotation)
+ fmt.Fprintf(&sb, "[sounds.%s]\n", clsStr)
+ fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
+ fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
+ fmt.Fprintf(&sb, "\n")
+ }
+}
+```
+
+Remove the old `builtinSet` declaration (line 298) since it is now declared in the new block. Remove the old `userClasses` collection and sort (lines 304-310). Remove the old built-in emission loop (lines 313-321) and user-defined emission loop (lines 323-333).
+
+Do NOT modify the header section (lines 273-281) or rules section (lines 283-295) -- those stay unchanged.
+
+Important: the `sort` package is already imported. The `builtinSet` map is now declared inside the new block, so remove the old one.
+
+
+ cd /home/dev/workspace/yoloyolo && go build ./config/...
+
+
+- `grep "groupOrder" config/config.go` returns the canonical group order slice
+- `grep "applyGroupOverrides" config/config.go` returns the function definition
+- `grep 'Groups map\[string\]string' config/config.go` shows the new rawConfig field
+- `grep "# User-defined" config/config.go` shows the user-defined section header
+- `go build ./config/...` succeeds
+
+ PrintConfig emits group-ordered output with section headers. rawConfig has Groups field. applyGroupOverrides function exists and is called in Load(). Compiles successfully.
+
+
+
+ Task 2: Add tests for group headers, group reassignment, and unknown class warning
+ config/config_test.go
+ config/config_test.go, config/config.go
+
+Add 4 new test functions to config/config_test.go:
+
+**TestPrintConfigGroupHeaders** -- Verifies GRP-02 group header output:
+```go
+func TestPrintConfigGroupHeaders(t *testing.T) {
+ t.Chdir(t.TempDir())
+ result, err := config.Load("")
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ output := config.PrintConfig(result)
+
+ // Verify all populated group headers appear
+ expectedGroups := []string{"# Infrastructure", "# Web", "# Mail", "# Remote Access", "# File Transfer", "# Database", "# VoIP", "# Unknown"}
+ for _, header := range expectedGroups {
+ if !strings.Contains(output, header+"\n") {
+ t.Errorf("PrintConfig output missing group header %q", header)
+ }
+ }
+
+ // Verify canonical order: Infrastructure before Web before Mail etc.
+ infraIdx := strings.Index(output, "# Infrastructure\n")
+ webIdx := strings.Index(output, "# Web\n")
+ mailIdx := strings.Index(output, "# Mail\n")
+ remoteIdx := strings.Index(output, "# Remote Access\n")
+ ftIdx := strings.Index(output, "# File Transfer\n")
+ dbIdx := strings.Index(output, "# Database\n")
+ voipIdx := strings.Index(output, "# VoIP\n")
+ unknownIdx := strings.Index(output, "# Unknown\n")
+
+ if infraIdx >= webIdx || webIdx >= mailIdx || mailIdx >= remoteIdx ||
+ remoteIdx >= ftIdx || ftIdx >= dbIdx || dbIdx >= voipIdx || voipIdx >= unknownIdx {
+ t.Errorf("Group headers not in canonical order: infra=%d web=%d mail=%d remote=%d ft=%d db=%d voip=%d unknown=%d",
+ infraIdx, webIdx, mailIdx, remoteIdx, ftIdx, dbIdx, voipIdx, unknownIdx)
+ }
+}
+```
+
+**TestLoadGroupOverride** -- Verifies GRP-03 basic reassignment:
+```go
+func TestLoadGroupOverride(t *testing.T) {
+ path := writeTOML(t, "[groups]\nIMAP = \"Web\"\n")
+ result, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ cfg := result.FreqCfgs[classify.ClassIMAP]
+ if cfg.Group != "Web" {
+ t.Errorf("IMAP Group: got %q, want %q", cfg.Group, "Web")
+ }
+ // Frequency and waveform unchanged (D-08)
+ defaultCfg := synth.ClassFreqConfigs[classify.ClassIMAP]
+ if cfg.BaseHz != defaultCfg.BaseHz {
+ t.Errorf("IMAP BaseHz changed: got %v, want %v (should be unchanged by group reassignment)", cfg.BaseHz, defaultCfg.BaseHz)
+ }
+}
+```
+
+**TestLoadGroupUnknownClass** -- Verifies D-09 warning for unknown class:
+```go
+func TestLoadGroupUnknownClass(t *testing.T) {
+ path := writeTOML(t, "[groups]\nBOGUS = \"Web\"\n")
+ result, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("Load should not error on unknown [groups] class: %v", err)
+ }
+ // Should still have all default classes
+ if len(result.FreqCfgs) != len(classify.AllClasses()) {
+ t.Errorf("FreqCfgs len: got %d, want %d", len(result.FreqCfgs), len(classify.AllClasses()))
+ }
+}
+```
+
+**TestPrintConfigGroupReassignment** -- Verifies PrintConfig reflects reassignment:
+```go
+func TestPrintConfigGroupReassignment(t *testing.T) {
+ path := writeTOML(t, "[groups]\nIMAP = \"Web\"\n")
+ result, err := config.Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ output := config.PrintConfig(result)
+
+ // Find the "# Web" section and check IMAP appears after it
+ webIdx := strings.Index(output, "# Web\n")
+ mailIdx := strings.Index(output, "# Mail\n")
+ imapIdx := strings.Index(output, "[sounds.IMAP]")
+ if imapIdx < webIdx || imapIdx > mailIdx {
+ t.Errorf("IMAP (reassigned to Web) should appear between Web and Mail headers; web=%d imap=%d mail=%d", webIdx, imapIdx, mailIdx)
+ }
+}
+```
+
+Also update `TestPrintConfigContainsAllClasses` if it was not already updated in Plan 01 -- it should use `classify.AllClasses()` loop instead of hardcoded class names. (Plan 01 should have done this, but verify and fix if needed.)
+
+
+ cd /home/dev/workspace/yoloyolo && go test ./config/... -v -run "TestPrintConfigGroup|TestLoadGroup"
+
+
+- `go test ./config/... -run TestPrintConfigGroupHeaders` passes
+- `go test ./config/... -run TestLoadGroupOverride` passes
+- `go test ./config/... -run TestLoadGroupUnknownClass` passes
+- `go test ./config/... -run TestPrintConfigGroupReassignment` passes
+- `go test ./config/...` all tests pass (no regressions)
+
+ Four new tests cover GRP-02 (group headers in canonical order) and GRP-03 (group reassignment, unknown class warning, PrintConfig reflects reassignment). Full config test suite passes.
+
+
+
+
+
+```bash
+cd /home/dev/workspace/yoloyolo && go test ./...
+```
+All tests pass. PrintConfig shows group headers. [groups] TOML works for reassignment.
+
+Manual verification: `go run . --print-config` shows classes organized by group with `# Infrastructure`, `# Web`, `# Mail`, `# Remote Access`, `# File Transfer`, `# Database`, `# VoIP`, `# Unknown` section headers.
+
+
+
+- `go test ./...` passes fully
+- PrintConfig output contains group section headers in canonical order
+- [groups] TOML table reassigns a class's group in PrintConfig output
+- Unknown class names in [groups] produce stderr warning, not error
+- Group reassignment does not change frequency or waveform (D-08)
+- User-defined classes appear under "# User-defined" section
+
+
+