docs(04): create phase plan for notifications et i3bar

This commit is contained in:
Pierre Martin
2026-03-23 21:07:35 +01:00
parent bab681ca30
commit cf252fc634
3 changed files with 520 additions and 4 deletions

View File

@@ -70,13 +70,13 @@ Plans:
**Requirements**: NOTIF-01, NOTIF-02, I3-03 **Requirements**: NOTIF-01, NOTIF-02, I3-03
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. Une notification dunst apparait quand une session passe de "Working" a "Needs Input" 1. Une notification dunst apparait quand une session passe de "Working" a "Needs Input"
2. Le mode focus supprime temporairement les notifications (`vmux focus on/off`) 2. Le mode focus supprime temporairement les notifications (`vmux focus <minutes>`)
3. Un widget i3bar affiche en temps reel le nombre de sessions et combien attendent de l'input 3. Un widget i3bar affiche en temps reel le nombre de sessions et combien attendent de l'input
**Plans**: TBD **Plans**: 2 plans
Plans: Plans:
- [ ] 04-01: TBD - [ ] 04-01-PLAN.md — Notifications dunst (Working->Needs Input), mode focus avec timer, CLI vmux focus
- [ ] 04-02: TBD - [ ] 04-02-PLAN.md — Widget i3bar avec format compact, wrapping i3status, CLI vmux i3bar
## Progress ## Progress

View File

@@ -0,0 +1,268 @@
---
phase: 04-notifications-et-i3bar
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [notify.go, notify_test.go, focus.go, focus_test.go, daemon.go, hook.go, protocol.go, main.go]
autonomous: true
requirements: [NOTIF-01, NOTIF-02]
must_haves:
truths:
- "Une notification dunst apparait quand une session passe de Working a Needs Input"
- "Pas de notification pour Idle -> Needs Input ou Working -> Idle"
- "vmux focus 30 supprime les notifications pendant 30 minutes"
- "Le focus expire automatiquement apres la duree"
- "Le widget i3bar reste visible meme en mode focus"
artifacts:
- path: "notify.go"
provides: "Notifier interface + ExecNotifier (notify-send)"
exports: ["Notifier", "ExecNotifier", "NullNotifier"]
- path: "focus.go"
provides: "FocusTimer struct thread-safe"
exports: ["FocusTimer"]
- path: "notify_test.go"
provides: "Tests notification transitions"
- path: "focus_test.go"
provides: "Tests FocusTimer Set/IsActive/Remaining/expiry"
key_links:
- from: "hook.go"
to: "notify.go"
via: "d.notifier.Notify() apres transition Working -> Needs Input"
pattern: "notifier\\.Notify"
- from: "hook.go"
to: "focus.go"
via: "d.focus.IsActive() pour bloquer les notifications"
pattern: "focus\\.IsActive"
- from: "main.go"
to: "daemon.go"
via: "vmux focus <minutes> envoie action focus au daemon"
pattern: "case \"focus\""
---
<objective>
Notifications desktop et mode focus pour vmux.
Purpose: Alerter l'utilisateur via dunst quand une session passe de Working a Needs Input, avec possibilite de supprimer temporairement les notifications via `vmux focus <minutes>`.
Output: notify.go, focus.go, integration dans daemon/hook, sous-commande `vmux focus`.
</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/04-notifications-et-i3bar/04-RESEARCH.md
<interfaces>
<!-- Existing code contracts needed by executor -->
From daemon.go:
```go
type Daemon struct {
registry *SessionRegistry
labels *LabelStore
sockPath string
procDir string
claudeDir string
workspaceResolver func(claudePID int) string
i3commander I3Commander
pollInterval time.Duration
stopCh chan struct{}
listener net.Listener
hookPort int
httpServer *http.Server
lastHookTime time.Time
mu sync.Mutex
}
func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon
func (d *Daemon) handleConnection(conn net.Conn) // switch req.Action
```
From hook.go:
```go
func (d *Daemon) processHookEvent(event HookEvent) // maps hook -> registry update
func (r *SessionRegistry) UpdateFromHook(sessionID, state, waitType, cwd string) // tracks PrevState
```
From protocol.go:
```go
type Request struct {
Action string `json:"action"`
Args json.RawMessage `json:"args,omitempty"`
}
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Sessions []SessionInfo `json:"sessions,omitempty"`
}
type SessionInfo struct {
PID, SessionID, Cwd, GitBranch, State, Preview, Workspace, Label, WaitType string
WaitingSince *time.Time
}
```
From daemon.go (TrackedSession):
```go
type TrackedSession struct {
Info SessionInfo
PrevState string
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Notifier interface, FocusTimer, et tests</name>
<files>notify.go, notify_test.go, focus.go, focus_test.go</files>
<read_first>
- daemon.go (Daemon struct, pour comprendre ou injecter notifier et focus)
- hook.go (processHookEvent, pour comprendre la transition d'etat)
- protocol.go (SessionInfo, pour le shortName)
</read_first>
<behavior>
- TestExecNotifier_CallsNotifySend: ExecNotifier.Notify() appelle notify-send avec --urgency=critical --app-name=vmux titre body
- TestNullNotifier_DropsAll: NullNotifier.Notify() retourne nil sans side-effect
- TestFocusTimer_Set: apres Set(30*time.Minute), IsActive() retourne true
- TestFocusTimer_Expired: apres Set(0), IsActive() retourne false
- TestFocusTimer_Remaining: apres Set(30min), Remaining() > 0
- TestFocusTimer_ZeroValue: FocusTimer{} non initialisee, IsActive() retourne false
- TestShortName_Label: SessionInfo{Label:"auth"} -> "auth"
- TestShortName_Cwd: SessionInfo{Cwd:"/home/pierre/Code/vibe/vmux"} -> "vmux"
</behavior>
<action>
Creer 4 fichiers. TDD: ecrire les tests d'abord, puis l'implementation.
**notify.go:**
- Interface `Notifier` avec methode `Notify(title, body string) error`
- `ExecNotifier` struct vide. Notify() utilise `exec.CommandContext` avec timeout 5s, appelle `notify-send --urgency=critical --app-name=vmux title body`. Per D-03: notify-send via os/exec.
- `NullNotifier` struct vide. Notify() retourne nil.
- Fonction `shortName(s SessionInfo) string` : retourne Label si non-vide, sinon `filepath.Base(s.Cwd)`.
**focus.go:**
- `FocusTimer` struct avec `mu sync.Mutex` et `expires time.Time`.
- `Set(d time.Duration)` : `expires = time.Now().Add(d)` sous verrou.
- `IsActive() bool` : `time.Now().Before(expires)` sous verrou. Per D-04: timer uniquement.
- `Remaining() time.Duration` : `time.Until(expires)` sous verrou, clamp a 0 si negatif.
Per D-05: le focus ne bloque que les notifications, pas l'i3bar.
</action>
<verify>
<automated>cd /home/pierre/Code/vibe/vmux && nix-shell -p go --run "go test -run 'TestExecNotifier|TestNullNotifier|TestFocusTimer|TestShortName' -count=1 -v"</automated>
</verify>
<acceptance_criteria>
- grep -q "type Notifier interface" notify.go
- grep -q "type ExecNotifier struct" notify.go
- grep -q "type NullNotifier struct" notify.go
- grep -q "notify-send" notify.go
- grep -q "func shortName" notify.go
- grep -q "type FocusTimer struct" focus.go
- grep -q "func.*FocusTimer.*Set" focus.go
- grep -q "func.*FocusTimer.*IsActive" focus.go
- grep -q "func.*FocusTimer.*Remaining" focus.go
- grep -q "TestFocusTimer" focus_test.go
- grep -q "TestShortName" notify_test.go
</acceptance_criteria>
<done>Notifier interface et FocusTimer testees et fonctionnelles. ExecNotifier appelle notify-send avec timeout. FocusTimer thread-safe avec Set/IsActive/Remaining.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Integration notifications dans daemon + CLI focus</name>
<files>daemon.go, hook.go, protocol.go, main.go, daemon_test.go</files>
<read_first>
- notify.go (Notifier interface cree en Task 1)
- focus.go (FocusTimer cree en Task 1)
- daemon.go (Daemon struct, NewDaemon, handleConnection)
- hook.go (processHookEvent, UpdateFromHook, TrackedSession.PrevState)
- main.go (switch CLI, runDaemon, printUsage)
- protocol.go (Request, Response, FocusArgs a ajouter)
</read_first>
<behavior>
- TestNotification_WorkingToNeedsInput: processHookEvent avec prevState=Working, nouvel etat Needs Input -> notifier.Notify appele
- TestNotification_IdleToNeedsInput: processHookEvent avec prevState=Idle, nouvel etat Needs Input -> notifier.Notify PAS appele (per D-01)
- TestNotification_FocusActive: focus.Set(30min), transition Working -> Needs Input -> notifier.Notify PAS appele (per D-05)
- TestNotification_FocusExpired: focus avec duree 0, transition Working -> Needs Input -> notifier.Notify appele
- TestFocusHandler: envoyer action "focus" avec minutes=30 -> response OK, focus.IsActive() true
</behavior>
<action>
**protocol.go:**
- Ajouter `FocusArgs struct { Minutes int \`json:"minutes"\` }`
- Ajouter `FocusRemaining float64 \`json:"focus_remaining,omitempty"\`` dans Response (pour feedback CLI)
**daemon.go:**
- Ajouter champs `notifier Notifier` et `focus *FocusTimer` dans Daemon struct
- Dans NewDaemon: initialiser `focus: &FocusTimer{}` et `notifier: &ExecNotifier{}`
- Dans handleConnection, ajouter case "focus":
- Parser FocusArgs, appeler `d.focus.Set(time.Duration(args.Minutes) * time.Minute)`
- Repondre `Response{OK: true, FocusRemaining: d.focus.Remaining().Minutes()}`
**hook.go — processHookEvent:**
- AVANT l'appel a `d.registry.UpdateFromHook`, lire le PrevState:
```go
d.registry.mu.RLock()
prevState := ""
if ts, ok := d.registry.sessions[event.SessionID]; ok {
prevState = ts.PrevState
}
d.registry.mu.RUnlock()
```
- APRES `d.registry.UpdateFromHook`, si `state == "Needs Input" && prevState == "Working"` (per D-01):
- Si `!d.focus.IsActive()` (per D-05): appeler `d.notifier.Notify("vmux: "+shortName(info), "Session needs input ("+waitType+")")`
- Pour le shortName, utiliser le SessionInfo du registre (qui a Label et Cwd)
**main.go:**
- Ajouter case "focus" dans le switch CLI:
- Parser argument `filteredArgs[1]` comme entier (minutes). Erreur si absent ou invalide. Per D-04: timer uniquement, pas de toggle.
- Envoyer action "focus" avec FocusArgs au daemon via client
- Afficher "Focus mode: notifications suppressed for N minutes"
- Ajouter "focus" dans printUsage: ` focus <minutes> Suppress notifications for N minutes`
- Dans runDaemon: le notifier est deja initialise par NewDaemon, rien a changer
**Pitfall PrevState (de RESEARCH):** Lire PrevState AVANT UpdateFromHook sous verrou separe. La lecture RLock puis l'ecriture Lock dans UpdateFromHook sont safe car RLock se relache avant.
**Pitfall notify-send timeout (de RESEARCH):** Deja gere dans ExecNotifier avec CommandContext 5s.
</action>
<verify>
<automated>cd /home/pierre/Code/vibe/vmux && nix-shell -p go --run "go test -run 'TestNotification|TestFocusHandler' -count=1 -v"</automated>
</verify>
<acceptance_criteria>
- grep -q "notifier Notifier" daemon.go
- grep -q "focus.*FocusTimer" daemon.go
- grep -q "type FocusArgs struct" protocol.go
- grep -q 'case "focus"' daemon.go
- grep -q 'case "focus"' main.go
- grep -q "prevState.*Working" hook.go
- grep -q "notifier.Notify" hook.go
- grep -q "focus.IsActive" hook.go
- grep -q "focus <minutes>" main.go
- grep -q "TestNotification" daemon_test.go
</acceptance_criteria>
<done>Notifications dunst actives sur transition Working -> Needs Input uniquement. Mode focus bloque les notifications. CLI `vmux focus 30` fonctionnel. Tests passent.</done>
</task>
</tasks>
<verification>
- `nix-shell -p go --run "go test ./... -count=1 -race"` passe sans erreur
- `nix-shell -p go --run "go build -o /dev/null ."` compile sans erreur
- grep confirme: Notifier interface, FocusTimer, integration hook, CLI focus
</verification>
<success_criteria>
1. Transition Working -> Needs Input declenche une notification dunst (via notify-send)
2. Transitions Idle -> Needs Input et Working -> Idle ne declenchent PAS de notification
3. `vmux focus 30` supprime les notifications pendant 30 minutes
4. Le focus expire automatiquement
5. Tous les tests passent avec -race
</success_criteria>
<output>
After completion, create `.planning/phases/04-notifications-et-i3bar/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,248 @@
---
phase: 04-notifications-et-i3bar
plan: 02
type: execute
wave: 1
depends_on: []
files_modified: [i3bar.go, i3bar_test.go, main.go]
autonomous: false
requirements: [I3-03]
must_haves:
truths:
- "vmux i3bar affiche le statut des sessions en format i3bar JSON sur stdout"
- "Le format est vmux: auth[!] portal[W] neia[I] avec noms courts"
- "Quand aucune session n'attend: vmux: all working (3)"
- "Couleur rouge si >= 1 session attend, vert sinon"
- "Le widget wrap i3status pour garder les infos systeme"
artifacts:
- path: "i3bar.go"
provides: "I3BarBlock, formatI3BarBlocks, shortName, runI3Bar"
exports: ["I3BarBlock", "formatI3BarBlocks", "runI3Bar"]
- path: "i3bar_test.go"
provides: "Tests formatage i3bar"
key_links:
- from: "i3bar.go"
to: "protocol.go"
via: "formatI3BarBlocks consomme []SessionInfo"
pattern: "formatI3BarBlocks.*SessionInfo"
- from: "main.go"
to: "i3bar.go"
via: "case i3bar appelle runI3Bar"
pattern: "case \"i3bar\""
---
<objective>
Widget i3bar affichant le statut des sessions vmux en temps reel.
Purpose: L'utilisateur voit dans sa barre i3 quelles sessions Claude Code ont besoin de lui, sans ouvrir vmux.
Output: i3bar.go avec formatage et boucle i3bar protocol, sous-commande `vmux i3bar`, wrapping i3status.
</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/04-notifications-et-i3bar/04-RESEARCH.md
<interfaces>
<!-- Existing code contracts needed by executor -->
From protocol.go:
```go
type SessionInfo struct {
PID int `json:"pid"`
SessionID string `json:"session_id"`
Cwd string `json:"cwd"`
GitBranch string `json:"git_branch"`
State string `json:"state"`
Preview string `json:"preview"`
Workspace string `json:"workspace"`
Label string `json:"label,omitempty"`
WaitType string `json:"wait_type,omitempty"`
WaitingSince *time.Time `json:"waiting_since,omitempty"`
}
type Request struct {
Action string `json:"action"`
Args json.RawMessage `json:"args,omitempty"`
}
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Sessions []SessionInfo `json:"sessions,omitempty"`
}
```
From client.go (inferred from main.go usage):
```go
func NewClient(sockPath string) *Client
func (c *Client) Send(req Request) (*Response, error)
```
From main.go:
```go
// CLI dispatch: switch filteredArgs[0] { case "list", "switch", "label", "stop", "daemon" }
// runDaemon(sockPath) starts the daemon
// printUsage() shows help
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Formatage i3bar et tests</name>
<files>i3bar.go, i3bar_test.go</files>
<read_first>
- protocol.go (SessionInfo struct)
- display.go (pattern formatage existant, shortName si deja defini)
- notify.go (shortName si cree en plan 01, sinon le creer ici)
</read_first>
<behavior>
- TestFormatI3BarBlocks_MixedStates: 3 sessions (Working, Needs Input, Idle) -> full_text="vmux: auth[!] portal[W] neia[I]", color="#ff0000"
- TestFormatI3BarBlocks_AllWorking: 3 sessions Working -> full_text="vmux: all working (3)", color="#00ff00" (per D-07)
- TestFormatI3BarBlocks_NoSessions: [] -> full_text="vmux: no sessions", color="#00ff00"
- TestFormatI3BarBlocks_ColorRed: >= 1 Needs Input -> color="#ff0000" (per D-08)
- TestFormatI3BarBlocks_ColorGreen: 0 Needs Input -> color="#00ff00" (per D-08)
- TestFormatI3BarBlocks_UsesLabel: session avec Label="auth" -> "auth[W]" pas le cwd (per D-06)
- TestFormatI3BarBlocks_UsesCwdBase: session sans label, cwd="/home/pierre/Code/vibe/vmux" -> "vmux[W]" (per D-06)
- TestFormatI3BarBlocks_NeedsInputMarker: session Needs Input -> "[!]" (per D-06)
- TestFormatI3BarBlocks_IdleMarker: session Idle -> "[I]" (per D-06)
</behavior>
<action>
Creer 2 fichiers. TDD: tests d'abord.
**i3bar.go:**
- `I3BarBlock` struct avec champs JSON: `full_text`, `short_text` (omitempty), `color`, `name`, `markup` (="none").
- `formatI3BarBlocks(sessions []SessionInfo) []I3BarBlock`:
- Iterer les sessions. Pour chaque, `shortName(s)` (fonction de notify.go ou locale si plan 01 pas encore execute) + suffixe selon State:
- "Needs Input" -> `[!]`, set `hasWaiting = true`
- "Working" -> `[W]`
- "Idle" -> `[I]`
- Si `!hasWaiting && len(sessions) > 0`: text = `vmux: all working (N)` per D-07
- Si `len(sessions) == 0`: text = `vmux: no sessions`
- Sinon: text = `vmux: ` + parts jointes par espace per D-06
- Color: `#ff0000` si hasWaiting, `#00ff00` sinon per D-08
- Retourner un seul bloc `[]I3BarBlock{{FullText: text, Color: color, Name: "vmux"}}`
**Note fichiers partages avec plan 01:** Si plan 01 est execute en parallele et definit `shortName` dans notify.go, cette fonction sera deja disponible. Sinon, definir `shortName` dans i3bar.go (et deplacer plus tard si doublon). Le plan 01 a la priorite sur shortName.
**Pitfall stdout buffering (de RESEARCH):** Pas concerne dans cette tache (formatage pur). Gere en Task 2.
</action>
<verify>
<automated>cd /home/pierre/Code/vibe/vmux && nix-shell -p go --run "go test -run 'TestFormatI3Bar' -count=1 -v"</automated>
</verify>
<acceptance_criteria>
- grep -q "type I3BarBlock struct" i3bar.go
- grep -q "func formatI3BarBlocks" i3bar.go
- grep -q "all working" i3bar.go
- grep -q "#ff0000" i3bar.go
- grep -q "#00ff00" i3bar.go
- grep -q "TestFormatI3BarBlocks" i3bar_test.go
</acceptance_criteria>
<done>formatI3BarBlocks produit le format compact D-06/D-07/D-08. Tous les tests passent.</done>
</task>
<task type="auto">
<name>Task 2: Boucle i3bar protocol et CLI</name>
<files>i3bar.go, main.go</files>
<read_first>
- i3bar.go (I3BarBlock et formatI3BarBlocks crees en Task 1)
- main.go (switch CLI, sockPath, EnsureDaemon pattern)
- protocol.go (Request/Response)
- 04-RESEARCH.md (pattern i3bar protocol, i3status wrapping, pitfall stdout buffering)
</read_first>
<action>
**i3bar.go — runI3Bar(sockPath, i3statusCmd string):**
Boucle infinie qui parle le protocole i3bar v1 sur stdout. Deux modes selon i3statusCmd:
**Mode standalone (i3statusCmd vide):**
1. Ecrire header: `{"version":1}` + newline
2. Ecrire `[` + newline
3. Boucle: query daemon via socket (`action: "list"`), formater avec `formatI3BarBlocks`, json.Marshal le tableau de blocs, ecrire `,` (sauf premier) + ligne JSON + newline. `os.Stdout.Write()` + flush explicite (per pitfall stdout buffering de RESEARCH).
4. Sleep 2 secondes entre chaque iteration.
5. Si le daemon est injoignable, afficher bloc `vmux: daemon offline` en gris.
**Mode wrap i3status (i3statusCmd non-vide):**
1. Lancer i3statusCmd en subprocess (`exec.Command(i3statusCmd)`)
2. Lire et forwarder le header JSON (`{"version":1}`)
3. Lire et forwarder le `[`
4. Pour chaque ligne lue de i3status: trim le `,` initial, json.Unmarshal en `[]I3BarBlock`, prepend le bloc vmux (query daemon), re-Marshal, ecrire avec `,` prefix
5. Si i3status se termine, continuer en mode standalone
**main.go:**
- Ajouter case "i3bar" dans le switch CLI:
- `EnsureDaemon(sockPath)` pour s'assurer que le daemon tourne
- Detecter i3statusCmd: chercher `i3status` dans PATH. Si trouve, utiliser comme wrapping. Sinon, mode standalone.
- Appeler `runI3Bar(sockPath, i3statusCmd)`
- Ajouter dans printUsage: ` i3bar Output i3bar JSON (use as status_command in i3 config)`
**Pitfall stdout buffering:** Utiliser `os.Stdout.Write(data)` suivi de `os.Stdout.Sync()` ou un `bufio.Writer` avec Flush() apres chaque ligne complete.
**Pitfall i3status wrapping (de RESEARCH):** i3status peut prefixer les lignes de blocs avec `,`. Le parser doit les trimmer avant json.Unmarshal.
</action>
<verify>
<automated>cd /home/pierre/Code/vibe/vmux && nix-shell -p go --run "go build -o /dev/null . && echo 'build OK'"</automated>
</verify>
<acceptance_criteria>
- grep -q "func runI3Bar" i3bar.go
- grep -q "version.*1" i3bar.go
- grep -q 'case "i3bar"' main.go
- grep -q "i3bar" main.go
- grep -q "i3status" i3bar.go
- grep -q "Stdout" i3bar.go
</acceptance_criteria>
<done>vmux i3bar fonctionne en mode standalone et wrap i3status. Le protocole i3bar v1 est respecte (header + array JSON). La sous-commande est accessible.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Verification visuelle du widget i3bar</name>
<files>i3bar.go, main.go</files>
<action>
Verification humaine du widget i3bar. Pas de code a ecrire.
Ce qui a ete construit: Widget i3bar vmux avec format compact, couleurs, wrapping i3status.
Etapes de verification:
1. Compiler: `nix-shell -p go --run "go build -o vmux ."`
2. S'assurer que le daemon tourne: `./vmux list`
3. Tester le mode standalone: `./vmux i3bar` (Ctrl+C apres quelques lignes)
- Verifier: header `{"version":1}`, puis `[`, puis lignes JSON avec bloc vmux
- Format attendu: `vmux: auth[!] portal[W]` ou `vmux: all working (N)`
4. Configurer i3bar (optionnel):
- Dans `~/.config/i3/config`, remplacer `status_command i3status` par `status_command /chemin/vers/vmux i3bar`
- Recharger i3: `i3-msg reload`
- Verifier que le bloc vmux apparait dans la barre avec les infos systeme
5. Verifier les couleurs: rouge si session attend, vert sinon
</action>
<verify>User confirms: "approved" or describes issues</verify>
<done>L'utilisateur a valide visuellement que le widget i3bar fonctionne correctement dans sa barre i3.</done>
</task>
</tasks>
<verification>
- `nix-shell -p go --run "go test ./... -count=1 -race"` passe sans erreur
- `nix-shell -p go --run "go build -o /dev/null ."` compile sans erreur
- `vmux i3bar` produit du JSON i3bar valide sur stdout
</verification>
<success_criteria>
1. `vmux i3bar` produit un flux JSON i3bar protocol v1 valide
2. Le format compact respecte D-06 (noms courts + suffixes [!] [W] [I])
3. "all working (N)" quand aucune session n'attend (D-07)
4. Couleurs rouge/vert selon urgence (D-08)
5. Le widget wrap i3status pour conserver les infos systeme
6. Tous les tests passent avec -race
</success_criteria>
<output>
After completion, create `.planning/phases/04-notifications-et-i3bar/04-02-SUMMARY.md`
</output>