docs(03): create phase plan for hook server

This commit is contained in:
Pierre Martin
2026-03-23 19:33:24 +01:00
parent f7228922cc
commit 005c6c1214
3 changed files with 465 additions and 3 deletions

View File

@@ -58,10 +58,11 @@ Plans:
1. vmuxd recoit les events hook de Claude Code (PreToolUse, PostToolUse, Stop, Notification) sur un port local
2. Les transitions d'etat apparaissent dans `vmux list` en moins d'une seconde apres l'event reel
3. vmux distingue le type d'attente : permission prompt, question utilisateur, idle prompt
**Plans**: TBD
**Plans**: 2 plans
Plans:
- [ ] 03-01: TBD
- [ ] 03-01-PLAN.md — HookEvent types, processHookEvent mapping, HTTP handler, WaitType dans SessionInfo
- [ ] 03-02-PLAN.md — Hook server integre au daemon, poll dynamique, affichage WaitType
### Phase 4: Notifications et i3bar
**Goal**: L'utilisateur est notifie passivement quand une session a besoin de lui, sans ouvrir vmux
@@ -86,5 +87,5 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4
|-------|----------------|--------|-----------|
| 1. Session Discovery | 0/2 | Not started | - |
| 2. Daemon et i3 Bridge | 0/2 | Not started | - |
| 3. Hook Server | 0/1 | Not started | - |
| 3. Hook Server | 0/2 | Not started | - |
| 4. Notifications et i3bar | 0/2 | Not started | - |

View File

@@ -0,0 +1,218 @@
---
phase: 03-hook-server
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- hook.go
- hook_test.go
- protocol.go
- protocol_test.go
autonomous: true
requirements: [STATE-03]
must_haves:
truths:
- "HookEvent struct parse le JSON de Claude Code hooks (session_id, hook_event_name, notification_type, etc.)"
- "processHookEvent mappe correctement Notification/permission_prompt vers WaitType=permission"
- "processHookEvent mappe correctement Notification/idle_prompt vers WaitType=idle"
- "processHookEvent mappe correctement Stop vers WaitType=question"
- "processHookEvent mappe correctement PostToolUse/PreToolUse vers State=Working et clear WaitType"
- "UpdateFromHook cree une entree si la session n'existe pas encore dans le registre"
- "SessionInfo contient le champ WaitType serialise en JSON"
- "HTTP handler POST /hook retourne 200 et met a jour le registre"
- "HTTP handler refuse les methodes non-POST (405)"
- "HTTP handler refuse les payloads invalides (400)"
- "HTTP handler limite la taille du body (MaxBytesReader 64KB)"
artifacts:
- path: "hook.go"
provides: "HookEvent struct, handleHook HTTP handler, processHookEvent, UpdateFromHook"
- path: "hook_test.go"
provides: "Tests unitaires pour tous les mappings hook event et le handler HTTP"
- path: "protocol.go"
provides: "SessionInfo avec champ WaitType"
- path: "protocol_test.go"
provides: "Test serialisation JSON de WaitType"
key_links:
- from: "hook.go"
to: "daemon.go"
via: "UpdateFromHook modifie le SessionRegistry"
pattern: "registry\\.UpdateFromHook"
- from: "hook.go"
to: "protocol.go"
via: "SessionInfo.WaitType"
pattern: "WaitType"
---
<objective>
Hook event processing core: types, mapping, handler HTTP, et mise a jour du registre.
Purpose: Poser toute la logique de traitement des events Claude Code hooks. Chaque type d'event (Notification, Stop, PostToolUse, PreToolUse) est mappe vers le bon State et WaitType.
Output: hook.go avec la logique, hook_test.go avec les tests, protocol.go enrichi avec WaitType.
</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-hook-server/03-RESEARCH.md
<interfaces>
<!-- Existing types the executor needs -->
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"`
WaitingSince *time.Time `json:"waiting_since,omitempty"`
}
```
From daemon.go:
```go
type TrackedSession struct {
Info SessionInfo
PrevState string
}
type SessionRegistry struct {
mu sync.RWMutex
sessions map[string]*TrackedSession
}
func (r *SessionRegistry) Update(info SessionInfo)
func (r *SessionRegistry) List() []SessionInfo
func (r *SessionRegistry) RemoveStale(activeIDs map[string]bool)
```
From daemon_test.go:
```go
func newTestDaemon(t *testing.T) *Daemon
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: HookEvent struct, processHookEvent mapping, UpdateFromHook, WaitType dans SessionInfo</name>
<files>hook.go, hook_test.go, protocol.go, protocol_test.go</files>
<read_first>
- protocol.go (SessionInfo actuel, a enrichir avec WaitType)
- daemon.go (SessionRegistry, TrackedSession, pattern Update)
- daemon_test.go (newTestDaemon helper, patterns de test)
- .planning/phases/03-hook-server/03-RESEARCH.md (payload JSON Claude Code, mapping events)
</read_first>
<behavior>
- TestProcessHookNotificationPermission: event Notification + notification_type=permission_prompt donne State="Needs Input", WaitType="permission"
- TestProcessHookNotificationIdle: event Notification + notification_type=idle_prompt donne State="Needs Input", WaitType="idle"
- TestProcessHookNotificationUnknown: event Notification + notification_type inconnu donne State="Needs Input", WaitType="question"
- TestProcessHookStop: event Stop donne State="Needs Input", WaitType="question"
- TestProcessHookPostToolUse: event PostToolUse donne State="Working", WaitType=""
- TestProcessHookPreToolUse: event PreToolUse donne State="Working", WaitType=""
- TestProcessHookIgnoresEmptySessionID: event avec session_id="" ne modifie pas le registre
- TestProcessHookIgnoresUnknownEvent: event avec hook_event_name inconnu ne modifie pas le registre
- TestUpdateFromHookCreatesNewEntry: session_id inconnu du registre cree une nouvelle entree
- TestUpdateFromHookSetsWaitingSince: transition Working vers NeedsInput met WaitingSince
- TestUpdateFromHookClearsWaitingSince: transition NeedsInput vers Working efface WaitingSince
- TestSessionInfoWaitTypeJSON: WaitType="permission" est serialise dans le JSON, WaitType="" est omis (omitempty)
</behavior>
<action>
1. Ajouter `WaitType string json:"wait_type,omitempty"` a SessionInfo dans protocol.go
2. Creer hook.go avec :
- HookEvent struct (session_id, transcript_path, cwd, hook_event_name, notification_type, message, title, last_assistant_message, stop_hook_active, tool_name). Tous les champs JSON avec tags corrects et omitempty pour les optionnels.
- `func (d *Daemon) processHookEvent(event HookEvent)` : switch sur event.HookEventName pour mapper vers state+waitType, puis appel a registry.UpdateFromHook. Ignorer session_id vide et events inconnus.
- `func (r *SessionRegistry) UpdateFromHook(sessionID, state, waitType, cwd string)` : lock, creer l'entree si absente, mettre a jour Info.SessionID/State/WaitType/Cwd, gerer la transition WaitingSince (meme logique que Update mais sans ecraser les autres champs).
3. Creer hook_test.go avec les 12 tests ci-dessus. Utiliser newTestDaemon pour les tests processHookEvent, NewRegistry directement pour les tests UpdateFromHook.
4. Ajouter un test dans protocol_test.go pour la serialisation JSON de WaitType.
NE PAS ajouter le handler HTTP dans cette tache (c'est la tache 2).
NE PAS modifier daemon.go (pas de startHookServer, pas de hookPort).
</action>
<verify>
<automated>nix-shell --run "go test -v -run 'TestProcessHook|TestUpdateFromHook|TestSessionInfoWaitType' ./..."</automated>
</verify>
<acceptance_criteria>
- grep -q 'WaitType.*string.*json:"wait_type' protocol.go
- grep -q 'type HookEvent struct' hook.go
- grep -q 'func.*Daemon.*processHookEvent' hook.go
- grep -q 'func.*SessionRegistry.*UpdateFromHook' hook.go
- grep -q 'TestProcessHookNotificationPermission' hook_test.go
- grep -q 'TestProcessHookStop' hook_test.go
- grep -q 'TestUpdateFromHookCreatesNewEntry' hook_test.go
- grep -q 'TestSessionInfoWaitTypeJSON' protocol_test.go
</acceptance_criteria>
<done>Les 12 tests passent. HookEvent parse le JSON Claude Code. processHookEvent mappe les 4 types d'events vers le bon State/WaitType. UpdateFromHook gere les nouvelles sessions et les transitions WaitingSince. SessionInfo.WaitType est serialise en JSON.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: HTTP handler POST /hook avec validation et protection</name>
<files>hook.go, hook_test.go</files>
<read_first>
- hook.go (HookEvent et processHookEvent crees par Task 1)
- daemon_test.go (newTestDaemon, patterns httptest)
- .planning/phases/03-hook-server/03-RESEARCH.md (handler pattern, MaxBytesReader)
</read_first>
<behavior>
- TestHandleHookPostOK: POST /hook avec payload Notification valide retourne 200 et met a jour le registre
- TestHandleHookMethodNotAllowed: GET /hook retourne 405
- TestHandleHookBadJSON: POST /hook avec body invalide retourne 400
- TestHandleHookBodyTooLarge: POST /hook avec body > 64KB retourne 400
</behavior>
<action>
1. Ajouter dans hook.go :
- `func (d *Daemon) handleHook(w http.ResponseWriter, r *http.Request)` : verifie Method==POST (sinon 405), applique http.MaxBytesReader(w, r.Body, 64*1024), decode JSON dans HookEvent (sinon 400), appelle processHookEvent, retourne 200 OK.
2. Ajouter les 4 tests dans hook_test.go. Utiliser httptest.NewRequest + httptest.NewRecorder pour tester le handler directement (pas besoin d'ouvrir un port).
3. Pour TestHandleHookPostOK, verifier que le registre contient la session avec le bon WaitType apres l'appel.
4. Pour TestHandleHookBodyTooLarge, generer un body de 65KB.
NE PAS demarrer de serveur HTTP dans cette tache. Le handler est teste unitairement via httptest.
</action>
<verify>
<automated>nix-shell --run "go test -v -run 'TestHandleHook' ./..."</automated>
</verify>
<acceptance_criteria>
- grep -q 'func.*Daemon.*handleHook' hook.go
- grep -q 'MaxBytesReader' hook.go
- grep -q 'StatusMethodNotAllowed' hook.go
- grep -q 'TestHandleHookPostOK' hook_test.go
- grep -q 'TestHandleHookMethodNotAllowed' hook_test.go
- grep -q 'TestHandleHookBadJSON' hook_test.go
- grep -q 'TestHandleHookBodyTooLarge' hook_test.go
</acceptance_criteria>
<done>Le handler HTTP /hook accepte les POST valides (200), rejette les non-POST (405), rejette le JSON invalide (400), limite la taille du body a 64KB. 4 tests passent.</done>
</task>
</tasks>
<verification>
nix-shell --run "go test -v -run 'TestProcessHook|TestUpdateFromHook|TestHandleHook|TestSessionInfoWaitType' ./..."
nix-shell --run "go vet ./..."
</verification>
<success_criteria>
- 16 tests passent (12 task 1 + 4 task 2)
- HookEvent struct couvre tous les champs du payload Claude Code
- processHookEvent mappe les 4 types d'events correctement
- UpdateFromHook gere les sessions inconnues et les transitions WaitingSince
- handleHook valide method, body size, et JSON
- SessionInfo.WaitType visible dans le JSON
- Aucune regression sur les tests existants
</success_criteria>
<output>
After completion, create `.planning/phases/03-hook-server/03-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,243 @@
---
phase: 03-hook-server
plan: 02
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- daemon.go
- daemon_test.go
- display.go
- display_test.go
autonomous: true
requirements: [STATE-03]
must_haves:
truths:
- "Daemon.Start() lance le hook server HTTP sur localhost:3119 en goroutine"
- "Le hook server se ferme proprement quand Daemon.Stop() est appele"
- "Si le port 3119 est occupe, le daemon continue sans hook server (graceful degradation)"
- "Le poll interval passe a 20s quand un hook a ete recu dans les 60 dernieres secondes (per D-01)"
- "Le poll revient a 5s si aucun hook depuis 60s"
- "vmux list affiche le WaitType entre parentheses apres le state quand non-vide"
- "Les transitions d'etat hook mettent a jour le registre immediatement (per D-02)"
artifacts:
- path: "daemon.go"
provides: "startHookServer, hookPort, httpServer, lastHookTime, pollInterval dynamique"
- path: "daemon_test.go"
provides: "Tests hook server startup, graceful degradation port occupe, poll slowdown"
- path: "display.go"
provides: "Affichage WaitType dans DisplaySessionInfos"
- path: "display_test.go"
provides: "Test affichage WaitType"
key_links:
- from: "daemon.go"
to: "hook.go"
via: "startHookServer enregistre handleHook"
pattern: "HandleFunc.*handleHook"
- from: "daemon.go"
to: "daemon.go"
via: "pollLoop lit pollInterval dynamiquement"
pattern: "pollInterval|lastHookTime"
- from: "display.go"
to: "protocol.go"
via: "SessionInfo.WaitType affiche"
pattern: "WaitType"
---
<objective>
Integrer le hook server dans le daemon et afficher le WaitType dans la CLI.
Purpose: Le hook server devient une goroutine du daemon. Le poll ralentit quand les hooks sont actifs (D-01). Le WaitType est visible dans `vmux list`. Le daemon degrade gracieusement si le port est occupe.
Output: daemon.go avec hook server integre, display.go avec WaitType affiche.
</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-hook-server/03-RESEARCH.md
@.planning/phases/03-hook-server/03-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 (hook.go) -->
```go
type HookEvent struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Cwd string `json:"cwd"`
HookEventName string `json:"hook_event_name"`
NotificationType string `json:"notification_type,omitempty"`
Message string `json:"message,omitempty"`
Title string `json:"title,omitempty"`
LastAssistantMsg string `json:"last_assistant_message,omitempty"`
StopHookActive bool `json:"stop_hook_active,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
func (d *Daemon) handleHook(w http.ResponseWriter, r *http.Request)
func (d *Daemon) processHookEvent(event HookEvent)
func (r *SessionRegistry) UpdateFromHook(sessionID, state, waitType, cwd string)
```
<!-- From protocol.go (enriched by Plan 01) -->
```go
type SessionInfo struct {
// ... existing fields ...
WaitType string `json:"wait_type,omitempty"` // "permission", "question", "idle", ""
}
```
<!-- From daemon.go (current) -->
```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
}
func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon
func (d *Daemon) Start() error
func (d *Daemon) Stop()
```
<!-- From display.go -->
```go
func DisplaySessionInfos(w io.Writer, sessions []SessionInfo, noColor bool, now time.Time)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Hook server dans Daemon, graceful degradation, poll dynamique</name>
<files>daemon.go, daemon_test.go</files>
<read_first>
- daemon.go (Daemon struct, Start, Stop, pollLoop, NewDaemon)
- hook.go (handleHook, processHookEvent, crees par Plan 01)
- daemon_test.go (newTestDaemon, TestDaemonStartStop)
- .planning/phases/03-hook-server/03-RESEARCH.md (startHookServer pattern, cohabitation poll/hooks)
</read_first>
<behavior>
- TestHookServerStartsWithDaemon: apres Start(), un POST HTTP sur localhost:hookPort/hook retourne 200
- TestHookServerStopsWithDaemon: apres Stop(), le port HTTP n'ecoute plus
- TestHookServerPortBusy: si le port est deja occupe (net.Listen avant), le daemon Start() reussit quand meme (graceful degradation)
- TestPollSlowdown: apres un hook event, pollInterval() retourne 20s. Apres 60s sans hook, retourne 5s.
</behavior>
<action>
1. Ajouter a Daemon struct dans daemon.go :
- `hookPort int` (default 3119)
- `httpServer *http.Server`
- `lastHookTime time.Time`
- `mu sync.Mutex` pour proteger lastHookTime
2. Modifier NewDaemon pour initialiser hookPort=3119.
3. Ajouter `func (d *Daemon) startHookServer() error` dans daemon.go :
- Creer http.ServeMux, enregistrer "/hook" sur d.handleHook
- Creer http.Server avec Addr=127.0.0.1:hookPort, ReadTimeout=5s, WriteTimeout=5s
- Tenter net.Listen("tcp", addr). Si erreur, log warning et retourner nil (graceful degradation, pas de crash).
- Lancer go d.httpServer.Serve(ln)
4. Modifier Daemon.Start() : appeler d.startHookServer() apres le scan initial, avant les goroutines. Si erreur, continuer sans hooks.
5. Modifier Daemon.Stop() : si d.httpServer != nil, appeler d.httpServer.Close()
6. Modifier processHookEvent (dans hook.go) : ajouter `d.mu.Lock(); d.lastHookTime = time.Now(); d.mu.Unlock()` a la fin de processHookEvent (per D-02, la mise a jour est immediate).
7. Ajouter `func (d *Daemon) currentPollInterval() time.Duration` :
- d.mu.Lock/Unlock pour lire lastHookTime
- Si time.Since(lastHookTime) < 60s, retourner 20s
- Sinon retourner 5s
8. Modifier pollLoop pour utiliser currentPollInterval() au lieu du ticker fixe :
- Remplacer le ticker fixe par un time.After(d.currentPollInterval()) dans la boucle select.
9. Pour les tests, modifier newTestDaemon pour assigner hookPort=0 (pas de bind par defaut). Les tests qui testent le hook server assigneront un port dynamique via net.Listen(":0").
NE PAS modifier le format d'affichage (c'est la tache 2).
</action>
<verify>
<automated>nix-shell --run "go test -v -run 'TestHookServer|TestPollSlowdown' ./..."</automated>
</verify>
<acceptance_criteria>
- grep -q 'hookPort' daemon.go
- grep -q 'httpServer' daemon.go
- grep -q 'lastHookTime' daemon.go
- grep -q 'startHookServer' daemon.go
- grep -q 'currentPollInterval' daemon.go
- grep -q 'TestHookServerStartsWithDaemon' daemon_test.go
- grep -q 'TestHookServerPortBusy' daemon_test.go
- grep -q 'TestPollSlowdown' daemon_test.go
</acceptance_criteria>
<done>Le daemon lance le hook server HTTP au demarrage. Le server se ferme proprement au Stop. Si le port est occupe, le daemon continue sans hooks (log warning). Le poll interval passe a 20s quand hooks actifs, revient a 5s sinon. Tous les tests existants + 4 nouveaux passent.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Affichage WaitType dans vmux list</name>
<files>display.go, display_test.go</files>
<read_first>
- display.go (DisplaySessionInfos, format actuel)
- display_test.go (tests existants)
- protocol.go (SessionInfo avec WaitType)
</read_first>
<behavior>
- TestDisplayWaitTypePermission: session avec State="Needs Input" et WaitType="permission" affiche "[Needs Input: permission]"
- TestDisplayWaitTypeQuestion: session avec WaitType="question" affiche "[Needs Input: question]"
- TestDisplayWaitTypeEmpty: session avec WaitType="" affiche "[Needs Input]" (sans le detail)
- TestDisplayWorkingNoWaitType: session Working affiche "[Working]" sans WaitType meme si WaitType est non-vide (securite)
</behavior>
<action>
1. Modifier DisplaySessionInfos dans display.go :
- Quand State=="Needs Input" et WaitType != "", afficher `[Needs Input: permission]` au lieu de `[Needs Input]`.
- Le WaitType s'ajoute apres le state, separe par ": ".
- Si State != "Needs Input", ne pas afficher le WaitType meme s'il est non-vide.
2. Ajouter les 4 tests dans display_test.go. Utiliser un bytes.Buffer pour capturer la sortie et verifier les patterns.
Le format exact est : `[{stateColor}{State}: {WaitType}{resetColor}]` quand WaitType present, `[{stateColor}{State}{resetColor}]` sinon.
</action>
<verify>
<automated>nix-shell --run "go test -v -run 'TestDisplay' ./..."</automated>
</verify>
<acceptance_criteria>
- grep -q 'WaitType' display.go
- grep -q 'TestDisplayWaitTypePermission' display_test.go
- grep -q 'TestDisplayWaitTypeQuestion' display_test.go
- grep -q 'TestDisplayWaitTypeEmpty' display_test.go
</acceptance_criteria>
<done>`vmux list` affiche le type d'attente entre parentheses apres le state. Les 4 tests passent. Les tests display existants ne regressent pas.</done>
</task>
</tasks>
<verification>
nix-shell --run "go test -v -race ./..."
nix-shell --run "go vet ./..."
</verification>
<success_criteria>
- Hook server demarre avec le daemon et se ferme proprement
- Graceful degradation si port occupe (le daemon continue)
- Poll interval dynamique : 20s quand hooks actifs, 5s sinon (per D-01)
- WaitType affiche dans vmux list pour les sessions en attente
- Aucune regression sur la suite de tests complete
- `go test -race` passe sans data race
</success_criteria>
<output>
After completion, create `.planning/phases/03-hook-server/03-02-SUMMARY.md`
</output>