docs(02): phase 2 plans
This commit is contained in:
269
.planning/phases/02-daemon-et-i3-bridge/02-01-PLAN.md
Normal file
269
.planning/phases/02-daemon-et-i3-bridge/02-01-PLAN.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
---
|
||||||
|
phase: 02-daemon-et-i3-bridge
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- types.go
|
||||||
|
- protocol.go
|
||||||
|
- protocol_test.go
|
||||||
|
- daemon.go
|
||||||
|
- daemon_test.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [DISC-04, STATE-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Le daemon maintient un registre des sessions a jour (poll toutes les 5s)"
|
||||||
|
- "Le daemon ecoute sur un Unix socket et repond aux requetes JSON"
|
||||||
|
- "Les labels sont persistes dans ~/.vmux/labels.json"
|
||||||
|
- "Le temps d'attente est tracke quand une session passe a NeedsInput"
|
||||||
|
artifacts:
|
||||||
|
- path: "protocol.go"
|
||||||
|
provides: "Types Request, Response, SessionInfo pour le protocole socket"
|
||||||
|
exports: ["Request", "Response", "SessionInfo"]
|
||||||
|
- path: "daemon.go"
|
||||||
|
provides: "SessionRegistry, LabelStore, boucle de poll, Unix socket server"
|
||||||
|
exports: ["SessionRegistry", "LabelStore", "StartDaemon"]
|
||||||
|
- path: "types.go"
|
||||||
|
provides: "Session enrichi avec Workspace, Label, WaitingSince"
|
||||||
|
contains: "Workspace string"
|
||||||
|
key_links:
|
||||||
|
- from: "daemon.go"
|
||||||
|
to: "proc.go"
|
||||||
|
via: "FindClaudeProcesses dans la boucle de poll"
|
||||||
|
pattern: "FindClaudeProcesses"
|
||||||
|
- from: "daemon.go"
|
||||||
|
to: "protocol.go"
|
||||||
|
via: "Request/Response JSON sur le socket"
|
||||||
|
pattern: "Request|Response"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Daemon vmuxd : registre des sessions, Unix socket server, labels persistants et tracking du temps d'attente.
|
||||||
|
|
||||||
|
Purpose: Transformer vmux d'un CLI one-shot en un daemon persistant qui maintient l'etat des sessions.
|
||||||
|
Output: protocol.go (types IPC), daemon.go (registre + socket + poll + labels), types.go enrichi.
|
||||||
|
</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/02-daemon-et-i3-bridge/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-daemon-et-i3-bridge/02-RESEARCH.md
|
||||||
|
@.planning/phases/01-session-discovery/01-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Types Phase 1 que le daemon reutilise -->
|
||||||
|
|
||||||
|
From types.go:
|
||||||
|
```go
|
||||||
|
type SessionState int
|
||||||
|
const (Working SessionState = iota; NeedsInput; Idle; Unknown)
|
||||||
|
type Process struct { PID int; Cmd []string; Cwd string }
|
||||||
|
type Session struct { Process Process; SessionID string; GitBranch string; State SessionState; Preview string; CwdPath string; Worktree string }
|
||||||
|
```
|
||||||
|
|
||||||
|
From proc.go:
|
||||||
|
```go
|
||||||
|
func FindClaudeProcesses(procDir string) ([]Process, error)
|
||||||
|
func EncodePath(path string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
From session.go:
|
||||||
|
```go
|
||||||
|
func FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error)
|
||||||
|
func TailReadJSONL(path string, n int) ([]JSONLMessage, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
From state.go:
|
||||||
|
```go
|
||||||
|
func DetectState(messages []JSONLMessage, now time.Time) SessionState
|
||||||
|
func ExtractPreview(messages []JSONLMessage) string
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Protocol types + SessionRegistry + LabelStore</name>
|
||||||
|
<files>protocol.go, protocol_test.go, daemon.go, daemon_test.go, types.go</files>
|
||||||
|
<read_first>types.go, proc.go, state.go, session.go</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestRequestMarshal: Request{Action:"list"} se serialise/deserialise correctement en JSON
|
||||||
|
- TestResponseWithSessions: Response avec SessionInfo contenant Workspace, Label, WaitingSince
|
||||||
|
- TestRegistryUpdate: Ajouter une session, la retrouver dans List()
|
||||||
|
- TestRegistryWaitingSince: Session passant de Working a NeedsInput enregistre WaitingSince; re-passer a Working la reset
|
||||||
|
- TestRegistryRemoveStale: Sessions absentes du scan sont supprimees du registre
|
||||||
|
- TestLabelStoreSetGet: Set("session-id", "review MR") puis Get("session-id") retourne "review MR"
|
||||||
|
- TestLabelStorePersistence: Set() ecrit sur disque, nouveau LabelStore charge le fichier et retrouve le label
|
||||||
|
- TestLabelStoreLoadMissing: Charger un fichier inexistant retourne un store vide sans erreur
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Enrichir Session dans types.go (per D-08, D-09):
|
||||||
|
- Ajouter `Workspace string`
|
||||||
|
- Ajouter `Label string`
|
||||||
|
- Ajouter `WaitingSince *time.Time`
|
||||||
|
|
||||||
|
2. Creer protocol.go avec les types IPC (per D-01):
|
||||||
|
```go
|
||||||
|
type Request struct {
|
||||||
|
Action string `json:"action"` // "list", "switch", "label", "stop"
|
||||||
|
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 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"`
|
||||||
|
}
|
||||||
|
type SwitchArgs struct { Query string `json:"query"` }
|
||||||
|
type LabelArgs struct { SessionID string `json:"session_id"`; Label string `json:"label"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Creer daemon.go avec SessionRegistry (per D-02, D-09):
|
||||||
|
```go
|
||||||
|
type TrackedSession struct {
|
||||||
|
Info SessionInfo
|
||||||
|
PrevState string
|
||||||
|
WaitingSince time.Time
|
||||||
|
}
|
||||||
|
type SessionRegistry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]*TrackedSession
|
||||||
|
}
|
||||||
|
func NewRegistry() *SessionRegistry
|
||||||
|
func (r *SessionRegistry) Update(info SessionInfo) // track WaitingSince transitions
|
||||||
|
func (r *SessionRegistry) List() []SessionInfo
|
||||||
|
func (r *SessionRegistry) RemoveStale(activeIDs map[string]bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Creer LabelStore dans daemon.go (per D-08):
|
||||||
|
```go
|
||||||
|
type LabelStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
labels map[string]string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
func NewLabelStore(path string) (*LabelStore, error)
|
||||||
|
func (ls *LabelStore) Set(sessionID, label string) error
|
||||||
|
func (ls *LabelStore) Get(sessionID string) string
|
||||||
|
```
|
||||||
|
Persistence dans le fichier JSON. Load au demarrage, save apres chaque Set.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>nix-shell --run "go test -run 'TestRequest|TestResponse|TestRegistry|TestLabelStore' -v ./..."</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -q 'Workspace string' types.go
|
||||||
|
- grep -q 'WaitingSince' types.go
|
||||||
|
- grep -q 'type Request struct' protocol.go
|
||||||
|
- grep -q 'type Response struct' protocol.go
|
||||||
|
- grep -q 'type SessionRegistry struct' daemon.go
|
||||||
|
- grep -q 'type LabelStore struct' daemon.go
|
||||||
|
- grep -q 'func.*LabelStore.*Set' daemon.go
|
||||||
|
- grep -q 'TestRegistryWaitingSince' daemon_test.go
|
||||||
|
- grep -q 'TestLabelStorePersistence' daemon_test.go
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Protocol types compiles, Registry tracke les transitions WaitingSince, LabelStore persiste sur disque, tous les tests passent.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Unix socket server + poll loop + stop handler</name>
|
||||||
|
<files>daemon.go, daemon_test.go</files>
|
||||||
|
<read_first>daemon.go, protocol.go, proc.go, session.go, state.go</read_first>
|
||||||
|
<action>
|
||||||
|
1. Ajouter la boucle de poll dans daemon.go (per D-02):
|
||||||
|
```go
|
||||||
|
func (d *Daemon) scanOnce(procDir, claudeDir string, now time.Time) {
|
||||||
|
// FindClaudeProcesses -> FindSessionForProcess -> DetectState -> registry.Update
|
||||||
|
// resolveWorkspace via d.workspaceResolver (interface, nil-safe pour tests)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Le daemon fait un scan synchrone AVANT d'ecouter sur le socket (per Pitfall 3).
|
||||||
|
Ticker de 5 secondes pour les scans suivants.
|
||||||
|
|
||||||
|
2. Ajouter le socket server (per D-01):
|
||||||
|
```go
|
||||||
|
type Daemon struct {
|
||||||
|
registry *SessionRegistry
|
||||||
|
labels *LabelStore
|
||||||
|
sockPath string
|
||||||
|
procDir string
|
||||||
|
claudeDir string
|
||||||
|
workspaceResolver func(claudePID int) string // nil = pas de workspace
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon
|
||||||
|
func (d *Daemon) Start() error // scan initial, listen, poll loop
|
||||||
|
func (d *Daemon) handleConnection(conn net.Conn) // dispatch Request.Action
|
||||||
|
```
|
||||||
|
Gestion du stale socket (per Pitfall 2): tenter net.Dial avant net.Listen, supprimer si stale.
|
||||||
|
PID file dans ~/.vmux/vmuxd.pid.
|
||||||
|
|
||||||
|
3. Handler pour chaque action:
|
||||||
|
- "list": registry.List() avec labels enrichis
|
||||||
|
- "label": labels.Set(args.SessionID, args.Label)
|
||||||
|
- "stop": fermer le listener, signal stopCh
|
||||||
|
|
||||||
|
4. Tests:
|
||||||
|
- TestDaemonStartStop: demarrer daemon dans goroutine avec tmpdir socket, envoyer "stop", verifier arret propre
|
||||||
|
- TestDaemonListOverSocket: demarrer daemon, envoyer "list", verifier la reponse JSON
|
||||||
|
- TestDaemonLabelOverSocket: envoyer "label", puis "list", verifier que le label apparait
|
||||||
|
|
||||||
|
Note: Le handler "switch" sera ajoute dans le plan 02-03 (depend de i3bridge du plan 02-02).
|
||||||
|
Le workspaceResolver est nil dans les tests de ce plan. Le plan 02-02 fournira l'implementation reelle.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>nix-shell --run "go test -run 'TestDaemon' -v -race ./..."</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -q 'type Daemon struct' daemon.go
|
||||||
|
- grep -q 'func.*Daemon.*Start' daemon.go
|
||||||
|
- grep -q 'func.*Daemon.*handleConnection' daemon.go
|
||||||
|
- grep -q 'net.Listen.*unix' daemon.go
|
||||||
|
- grep -q 'TestDaemonStartStop' daemon_test.go
|
||||||
|
- grep -q 'TestDaemonListOverSocket' daemon_test.go
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Le daemon demarre, ecoute sur un Unix socket, repond a list/label/stop, fait un scan initial synchrone et poll toutes les 5s. Tests avec -race passent.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
nix-shell --run "go test -v -race ./..."
|
||||||
|
grep -q 'type Daemon struct' daemon.go
|
||||||
|
grep -q 'type LabelStore struct' daemon.go
|
||||||
|
grep -q 'type Request struct' protocol.go
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Protocol types (Request, Response, SessionInfo) definis et testes
|
||||||
|
- SessionRegistry tracke les transitions d'etat avec WaitingSince (STATE-04)
|
||||||
|
- LabelStore persiste les labels dans ~/.vmux/labels.json (DISC-04)
|
||||||
|
- Daemon ecoute sur Unix socket, repond aux requetes list/label/stop (D-01)
|
||||||
|
- Boucle de poll toutes les 5s avec scan initial synchrone (D-02)
|
||||||
|
- Tous les tests passent avec -race
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-daemon-et-i3-bridge/02-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
242
.planning/phases/02-daemon-et-i3-bridge/02-02-PLAN.md
Normal file
242
.planning/phases/02-daemon-et-i3-bridge/02-02-PLAN.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
---
|
||||||
|
phase: 02-daemon-et-i3-bridge
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- workspace.go
|
||||||
|
- workspace_test.go
|
||||||
|
- i3bridge.go
|
||||||
|
- i3bridge_test.go
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
autonomous: true
|
||||||
|
requirements: [I3-01, I3-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Un PID Claude Code est resolu vers son workspace i3 via la chaine PPID"
|
||||||
|
- "Le fuzzy match trouve une session par label, branche ou cwd"
|
||||||
|
- "Le switch vers un workspace i3 fonctionne via i3 IPC RunCommand"
|
||||||
|
- "Si i3 ou X11 est absent, le mapping retourne vide sans erreur"
|
||||||
|
artifacts:
|
||||||
|
- path: "workspace.go"
|
||||||
|
provides: "Resolution PID -> workspace via PPID chain + X11 _NET_WM_PID"
|
||||||
|
exports: ["ReadPPID", "ResolveWorkspace", "BuildTerminalWorkspaceMap"]
|
||||||
|
- path: "i3bridge.go"
|
||||||
|
provides: "Interface I3Client, fuzzy match, switch workspace"
|
||||||
|
exports: ["I3Client", "FuzzyMatch", "SwitchToWorkspace"]
|
||||||
|
key_links:
|
||||||
|
- from: "workspace.go"
|
||||||
|
to: "/proc/PID/status"
|
||||||
|
via: "ReadPPID lit le PPid"
|
||||||
|
pattern: "ReadPPID"
|
||||||
|
- from: "i3bridge.go"
|
||||||
|
to: "go.i3wm.org/i3/v4"
|
||||||
|
via: "GetTree, RunCommand"
|
||||||
|
pattern: "i3\\.GetTree|i3\\.RunCommand"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
i3 bridge : mapping PID -> workspace via PPID chain + X11, fuzzy match pour switch, abstraction i3 IPC.
|
||||||
|
|
||||||
|
Purpose: Permettre a vmux de savoir dans quel workspace i3 se trouve chaque session Claude Code et d'y switcher.
|
||||||
|
Output: workspace.go (PPID chain walk + X11 PID resolution), i3bridge.go (interface i3, fuzzy match, switch).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/02-daemon-et-i3-bridge/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-daemon-et-i3-bridge/02-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Types existants necessaires -->
|
||||||
|
|
||||||
|
From types.go:
|
||||||
|
```go
|
||||||
|
type Process struct { PID int; Cmd []string; Cwd string }
|
||||||
|
```
|
||||||
|
|
||||||
|
From protocol.go (cree par plan 02-01, mais ce plan n'en depend pas):
|
||||||
|
```go
|
||||||
|
type SessionInfo struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
GitBranch string `json:"git_branch"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Le plan 02-01 cree un champ workspaceResolver func(int) string dans Daemon.
|
||||||
|
Ce plan fournit l'implementation reelle de ce resolver. -->
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: PPID chain walk + workspace resolution</name>
|
||||||
|
<files>workspace.go, workspace_test.go</files>
|
||||||
|
<read_first>proc.go, proc_test.go</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestReadPPID: Lire le PPid depuis un faux /proc/PID/status -> retourne le bon PPID
|
||||||
|
- TestReadPPIDMissing: Fichier inexistant -> erreur
|
||||||
|
- TestResolveWorkspace: Claude PID 100 -> PPID 50 -> PPID 10 (dans terminalMap) -> retourne "workspace 3"
|
||||||
|
- TestResolveWorkspaceNotFound: Aucun ancetre dans terminalMap -> retourne ""
|
||||||
|
- TestResolveWorkspaceMaxDepth: Chaine > 20 niveaux -> retourne "" (securite, per Pitfall 7)
|
||||||
|
- TestBuildTerminalWorkspaceMapUnit: Avec un mock i3 tree + mock X11 PID resolver, retourne la bonne map
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Creer workspace.go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ReadPPID lit le PPid depuis /proc/PID/status. procDir injectable pour les tests.
|
||||||
|
func ReadPPID(procDir string, pid int) (int, error)
|
||||||
|
```
|
||||||
|
Parser la ligne "PPid:\tNNN" du fichier status.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ResolveWorkspace remonte la chaine PPID depuis claudePID jusqu'a trouver
|
||||||
|
// un PID connu dans terminalWorkspaces. Max 20 niveaux (per Pitfall 7).
|
||||||
|
func ResolveWorkspace(procDir string, claudePID int, terminalWorkspaces map[int]string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// X11PIDResolver abstrait la lecture de _NET_WM_PID pour testabilite.
|
||||||
|
type X11PIDResolver interface {
|
||||||
|
GetPID(windowID uint32) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// I3TreeProvider abstrait i3.GetTree() pour testabilite.
|
||||||
|
type I3TreeProvider interface {
|
||||||
|
GetTree() (*i3.Tree, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTerminalWorkspaceMap construit la map terminalPID -> workspaceName.
|
||||||
|
// Utilise i3 GetTree + X11 _NET_WM_PID (per D-04 corrige par RESEARCH).
|
||||||
|
func BuildTerminalWorkspaceMap(tree I3TreeProvider, x11 X11PIDResolver) (map[int]string, error)
|
||||||
|
```
|
||||||
|
Walk recursif de l'arbre i3 : pour chaque node avec Window > 0, appeler x11.GetPID(node.Window).
|
||||||
|
Tracker le workspace courant via node.Type == i3.WorkspaceNode.
|
||||||
|
|
||||||
|
2. Creer l'implementation reelle X11 avec xgbutil (per RESEARCH):
|
||||||
|
```go
|
||||||
|
type RealX11Resolver struct { xu *xgbutil.XUtil }
|
||||||
|
func NewRealX11Resolver() (*RealX11Resolver, error) // verifie $DISPLAY (per Pitfall 5)
|
||||||
|
func (r *RealX11Resolver) GetPID(windowID uint32) (int, error) // ewmh.WmPidGet
|
||||||
|
func (r *RealX11Resolver) Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Tests avec fake /proc (meme pattern que proc_test.go):
|
||||||
|
- Creer des faux fichiers /proc/PID/status avec PPid
|
||||||
|
- Mock I3TreeProvider et X11PIDResolver pour tester BuildTerminalWorkspaceMap
|
||||||
|
- ReadPPID et ResolveWorkspace testes avec le filesystem reel (tmpdir)
|
||||||
|
|
||||||
|
4. Ajouter go.i3wm.org/i3/v4 dans go.mod (per D-06):
|
||||||
|
```bash
|
||||||
|
nix-shell --run "go get go.i3wm.org/i3/v4@latest"
|
||||||
|
```
|
||||||
|
Et BurntSushi/xgbutil pour ewmh (dependance transitive mais import explicite necessaire).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>nix-shell --run "go test -run 'TestReadPPID|TestResolveWorkspace|TestBuildTerminal' -v ./..."</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -q 'func ReadPPID' workspace.go
|
||||||
|
- grep -q 'func ResolveWorkspace' workspace.go
|
||||||
|
- grep -q 'func BuildTerminalWorkspaceMap' workspace.go
|
||||||
|
- grep -q 'type X11PIDResolver interface' workspace.go
|
||||||
|
- grep -q 'TestResolveWorkspace' workspace_test.go
|
||||||
|
- grep -q 'TestReadPPID' workspace_test.go
|
||||||
|
- grep -q 'go.i3wm.org/i3/v4' go.mod
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>ReadPPID lit le PPid depuis /proc, ResolveWorkspace remonte la chaine PPID avec limite de 20, BuildTerminalWorkspaceMap abstrait i3+X11 derriere des interfaces testables. Tous les tests passent.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Fuzzy match + switch workspace + i3 client interface</name>
|
||||||
|
<files>i3bridge.go, i3bridge_test.go</files>
|
||||||
|
<read_first>workspace.go</read_first>
|
||||||
|
<behavior>
|
||||||
|
- TestFuzzyMatchByLabel: sessions avec labels, query "review" matche la session avec label "review MR !456"
|
||||||
|
- TestFuzzyMatchByBranch: query "auth" matche la session sur branche "feat/auth-flow"
|
||||||
|
- TestFuzzyMatchByCwd: query "vmux" matche la session avec cwd "/home/pierre/Code/vibe/vmux"
|
||||||
|
- TestFuzzyMatchPriority: session avec label "auth" ET autre session avec branche "auth" -> le label gagne
|
||||||
|
- TestFuzzyMatchNoResult: query "inexistant" retourne nil
|
||||||
|
- TestFuzzyMatchCaseInsensitive: query "AUTH" matche "auth" dans la branche
|
||||||
|
- TestSwitchToWorkspace: Mock i3 RunCommand, verifier que "workspace number 3" est envoye
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Creer i3bridge.go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// FuzzyMatch trouve la premiere session matchant query dans : label > branche > cwd.
|
||||||
|
// Case-insensitive. Retourne nil si aucun match (per D-07).
|
||||||
|
func FuzzyMatch(query string, sessions []SessionInfo) *SessionInfo
|
||||||
|
```
|
||||||
|
Priorite : label > GitBranch > Cwd. strings.Contains + strings.ToLower.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// I3Commander abstrait i3.RunCommand pour testabilite.
|
||||||
|
type I3Commander interface {
|
||||||
|
RunCommand(command string) ([]i3.CommandResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchToWorkspace bascule vers le workspace indique via i3 IPC (per D-06).
|
||||||
|
func SwitchToWorkspace(commander I3Commander, wsName string) error
|
||||||
|
```
|
||||||
|
Envoie `workspace number <wsName>`. Verifie le Success du resultat.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RealI3Commander utilise go.i3wm.org/i3/v4 directement.
|
||||||
|
type RealI3Commander struct{}
|
||||||
|
func (c RealI3Commander) RunCommand(cmd string) ([]i3.CommandResult, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Tests :
|
||||||
|
- FuzzyMatch : pure logique, pas de mock i3. Construire des []SessionInfo en dur.
|
||||||
|
- SwitchToWorkspace : mock I3Commander qui enregistre la commande recue.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>nix-shell --run "go test -run 'TestFuzzyMatch|TestSwitchToWorkspace' -v ./..."</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -q 'func FuzzyMatch' i3bridge.go
|
||||||
|
- grep -q 'func SwitchToWorkspace' i3bridge.go
|
||||||
|
- grep -q 'type I3Commander interface' i3bridge.go
|
||||||
|
- grep -q 'TestFuzzyMatchByLabel' i3bridge_test.go
|
||||||
|
- grep -q 'TestFuzzyMatchPriority' i3bridge_test.go
|
||||||
|
- grep -q 'TestSwitchToWorkspace' i3bridge_test.go
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>FuzzyMatch cherche dans label > branche > cwd (case-insensitive), SwitchToWorkspace envoie la commande i3 IPC. Tests couvrent les 3 niveaux de priorite, le no-match et le case-insensitive.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
nix-shell --run "go test -v -race ./..."
|
||||||
|
grep -q 'func ResolveWorkspace' workspace.go
|
||||||
|
grep -q 'func FuzzyMatch' i3bridge.go
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Mapping PID -> workspace via PPID chain fonctionnel et teste (I3-01)
|
||||||
|
- Fuzzy match sur label/branche/cwd avec priorite correcte (I3-02)
|
||||||
|
- Switch workspace via i3 IPC abstrait derriere une interface testable (I3-02)
|
||||||
|
- Fallback gracieux si i3/X11 absent (per Pitfall 4, 5)
|
||||||
|
- go.i3wm.org/i3/v4 ajoute dans go.mod (D-06)
|
||||||
|
- Tous les tests passent avec -race
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-daemon-et-i3-bridge/02-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
265
.planning/phases/02-daemon-et-i3-bridge/02-03-PLAN.md
Normal file
265
.planning/phases/02-daemon-et-i3-bridge/02-03-PLAN.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
---
|
||||||
|
phase: 02-daemon-et-i3-bridge
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["02-01", "02-02"]
|
||||||
|
files_modified:
|
||||||
|
- client.go
|
||||||
|
- client_test.go
|
||||||
|
- main.go
|
||||||
|
- display.go
|
||||||
|
- display_test.go
|
||||||
|
- daemon.go
|
||||||
|
autonomous: false
|
||||||
|
requirements: [DISC-04, I3-01, I3-02, STATE-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "vmux list affiche workspace, label et temps d'attente pour chaque session"
|
||||||
|
- "vmux switch <query> bascule vers le workspace i3 de la session matchee"
|
||||||
|
- "vmux label <session> <texte> attribue un label humain"
|
||||||
|
- "vmux stop arrete le daemon proprement"
|
||||||
|
- "Le daemon demarre automatiquement si absent quand on lance une commande"
|
||||||
|
artifacts:
|
||||||
|
- path: "client.go"
|
||||||
|
provides: "Client Unix socket CLI -> daemon"
|
||||||
|
exports: ["Client", "EnsureDaemon"]
|
||||||
|
- path: "main.go"
|
||||||
|
provides: "Dispatch sous-commandes list/switch/label/stop/daemon"
|
||||||
|
contains: "case \"switch\""
|
||||||
|
- path: "display.go"
|
||||||
|
provides: "Affichage enrichi avec workspace, label, temps d'attente"
|
||||||
|
contains: "WaitingSince"
|
||||||
|
key_links:
|
||||||
|
- from: "client.go"
|
||||||
|
to: "daemon.go"
|
||||||
|
via: "Unix socket ~/.vmux/vmux.sock"
|
||||||
|
pattern: "net.Dial.*unix"
|
||||||
|
- from: "main.go"
|
||||||
|
to: "client.go"
|
||||||
|
via: "Dispatch sous-commandes vers Client"
|
||||||
|
pattern: "client\\."
|
||||||
|
- from: "daemon.go"
|
||||||
|
to: "i3bridge.go"
|
||||||
|
via: "Switch handler appelle FuzzyMatch + SwitchToWorkspace"
|
||||||
|
pattern: "FuzzyMatch|SwitchToWorkspace"
|
||||||
|
- from: "daemon.go"
|
||||||
|
to: "workspace.go"
|
||||||
|
via: "workspaceResolver dans la boucle de poll"
|
||||||
|
pattern: "ResolveWorkspace|BuildTerminalWorkspaceMap"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
CLI complet : client socket, sous-commandes (list/switch/label/stop), autostart daemon, affichage enrichi.
|
||||||
|
|
||||||
|
Purpose: L'utilisateur peut interagir avec vmux via les sous-commandes. Tout est cable de bout en bout.
|
||||||
|
Output: client.go (communication socket), main.go refactore, display.go enrichi, daemon.go complete avec switch + workspace.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/02-daemon-et-i3-bridge/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-daemon-et-i3-bridge/02-RESEARCH.md
|
||||||
|
@.planning/phases/02-daemon-et-i3-bridge/02-01-SUMMARY.md
|
||||||
|
@.planning/phases/02-daemon-et-i3-bridge/02-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- A lire depuis les fichiers reels apres execution de 02-01 et 02-02 -->
|
||||||
|
|
||||||
|
From protocol.go (plan 02-01):
|
||||||
|
```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 int; SessionID string; Cwd string; GitBranch string; State string; Preview string; Workspace string; Label string; WaitingSince *time.Time }
|
||||||
|
type SwitchArgs struct { Query string `json:"query"` }
|
||||||
|
type LabelArgs struct { SessionID string `json:"session_id"`; Label string `json:"label"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
From daemon.go (plan 02-01):
|
||||||
|
```go
|
||||||
|
type Daemon struct { registry *SessionRegistry; labels *LabelStore; sockPath string; ... }
|
||||||
|
func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon
|
||||||
|
func (d *Daemon) Start() error
|
||||||
|
```
|
||||||
|
|
||||||
|
From i3bridge.go (plan 02-02):
|
||||||
|
```go
|
||||||
|
func FuzzyMatch(query string, sessions []SessionInfo) *SessionInfo
|
||||||
|
func SwitchToWorkspace(commander I3Commander, wsName string) error
|
||||||
|
type RealI3Commander struct{}
|
||||||
|
```
|
||||||
|
|
||||||
|
From workspace.go (plan 02-02):
|
||||||
|
```go
|
||||||
|
func ResolveWorkspace(procDir string, claudePID int, terminalWorkspaces map[int]string) string
|
||||||
|
func BuildTerminalWorkspaceMap(tree I3TreeProvider, x11 X11PIDResolver) (map[int]string, error)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Client socket + autostart + daemon switch handler + workspace wiring</name>
|
||||||
|
<files>client.go, client_test.go, daemon.go</files>
|
||||||
|
<read_first>daemon.go, protocol.go, i3bridge.go, workspace.go</read_first>
|
||||||
|
<action>
|
||||||
|
1. Creer client.go (per D-01, D-03):
|
||||||
|
```go
|
||||||
|
type Client struct { sockPath string }
|
||||||
|
func NewClient(sockPath string) *Client
|
||||||
|
func (c *Client) Send(req Request) (*Response, error)
|
||||||
|
// net.Dial("unix", sockPath), encoder req JSON, decoder response JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
func EnsureDaemon(sockPath string) error
|
||||||
|
// Tenter net.DialTimeout. Si echec: lancer os.Executable() + "daemon" en background.
|
||||||
|
// Retry 50ms x 20 (per RESEARCH Pattern 2). Detacher avec Setsid.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Completer daemon.go : ajouter le handler "switch" (per D-07, I3-02):
|
||||||
|
```go
|
||||||
|
case "switch":
|
||||||
|
var args SwitchArgs
|
||||||
|
json.Unmarshal(req.Args, &args)
|
||||||
|
sessions := r.registry.List()
|
||||||
|
match := FuzzyMatch(args.Query, sessions)
|
||||||
|
if match == nil { return Response{Error: "no session matching..."} }
|
||||||
|
if match.Workspace == "" { return Response{Error: "no workspace for session..."} }
|
||||||
|
err := SwitchToWorkspace(d.i3commander, match.Workspace)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Cabler le workspace resolution dans la boucle de poll du daemon:
|
||||||
|
- Au demarrage du daemon, initialiser BuildTerminalWorkspaceMap (i3 GetTree + X11).
|
||||||
|
- Si i3/X11 indisponible: log warning, continuer sans workspace (per Pitfall 4, 5).
|
||||||
|
- Dans scanOnce: appeler ResolveWorkspace(procDir, pid, terminalMap) pour chaque session.
|
||||||
|
- Rafraichir la terminalMap a chaque scan (les fenetres peuvent bouger de workspace).
|
||||||
|
|
||||||
|
4. Tests:
|
||||||
|
- TestClientSendReceive: Demarrer un daemon dans goroutine, creer Client, envoyer "list", verifier Response
|
||||||
|
- TestEnsureDaemonAlreadyRunning: Socket actif -> EnsureDaemon retourne nil immediatement
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>nix-shell --run "go test -run 'TestClient|TestEnsureDaemon' -v -race ./..."</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -q 'type Client struct' client.go
|
||||||
|
- grep -q 'func EnsureDaemon' client.go
|
||||||
|
- grep -q 'case "switch"' daemon.go
|
||||||
|
- grep -q 'FuzzyMatch' daemon.go
|
||||||
|
- grep -q 'ResolveWorkspace\|BuildTerminalWorkspaceMap' daemon.go
|
||||||
|
- grep -q 'TestClientSendReceive' client_test.go
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Client envoie des requetes au daemon via socket, EnsureDaemon autostart le daemon, le handler switch utilise FuzzyMatch + SwitchToWorkspace, la boucle de poll resout les workspaces.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: main.go sous-commandes + display enrichi</name>
|
||||||
|
<files>main.go, display.go, display_test.go</files>
|
||||||
|
<read_first>main.go, display.go, display_test.go, client.go, protocol.go</read_first>
|
||||||
|
<action>
|
||||||
|
1. Refactorer main.go pour supporter les sous-commandes (per D-03, D-07, D-08):
|
||||||
|
```go
|
||||||
|
func main() {
|
||||||
|
// Parse --no-color global
|
||||||
|
// Dispatch sur os.Args:
|
||||||
|
// "list" -> EnsureDaemon + Client.Send(list) + DisplaySessionInfos
|
||||||
|
// "switch" -> EnsureDaemon + Client.Send(switch, query)
|
||||||
|
// "label" -> EnsureDaemon + Client.Send(label, session_id, texte)
|
||||||
|
// "stop" -> Client.Send(stop)
|
||||||
|
// "daemon" -> NewDaemon + Start (mode foreground, lance par autostart)
|
||||||
|
// default -> usage
|
||||||
|
}
|
||||||
|
```
|
||||||
|
sockPath = `~/.vmux/vmux.sock` (per D-01).
|
||||||
|
Pour "label": args[2] = session_id, args[3:] = label text (join avec espace).
|
||||||
|
Pour "switch": args[2] = query string.
|
||||||
|
|
||||||
|
2. Etendre display.go pour afficher les champs enrichis:
|
||||||
|
```go
|
||||||
|
// DisplaySessionInfos affiche les sessions recues du daemon (SessionInfo, pas Session).
|
||||||
|
func DisplaySessionInfos(w io.Writer, sessions []SessionInfo, noColor bool, now time.Time)
|
||||||
|
```
|
||||||
|
Format par session:
|
||||||
|
```
|
||||||
|
[Needs Input] /home/pierre/Code/vibe/vmux (feat/auth) [ws:3] "review MR !456" (depuis 3 min)
|
||||||
|
preview line 1
|
||||||
|
preview line 2
|
||||||
|
```
|
||||||
|
- `[ws:N]` seulement si Workspace non vide (per D-05)
|
||||||
|
- `"label"` entre guillemets seulement si Label non vide (per D-08)
|
||||||
|
- `(depuis X min)` seulement si WaitingSince non nil (per D-09, STATE-04)
|
||||||
|
- Calculer la duree relative : now.Sub(*WaitingSince). Afficher "< 1 min", "3 min", "1 h 5 min".
|
||||||
|
|
||||||
|
3. Mise a jour display_test.go:
|
||||||
|
- TestDisplayWithWorkspace: session avec Workspace "3" -> contient "[ws:3]"
|
||||||
|
- TestDisplayWithLabel: session avec Label "review MR" -> contient "\"review MR\""
|
||||||
|
- TestDisplayWithWaitingSince: session avec WaitingSince 3 min ago -> contient "depuis 3 min"
|
||||||
|
- TestDisplayWithoutOptionalFields: session sans workspace/label/waiting -> pas de [ws:], pas de guillemets, pas de "depuis"
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>nix-shell --run "go test -run 'TestDisplay' -v ./..." && nix-shell --run "go build -o /dev/null ./..."</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- grep -q 'case "switch"' main.go
|
||||||
|
- grep -q 'case "label"' main.go
|
||||||
|
- grep -q 'case "stop"' main.go
|
||||||
|
- grep -q 'case "daemon"' main.go
|
||||||
|
- grep -q 'EnsureDaemon' main.go
|
||||||
|
- grep -q 'func DisplaySessionInfos' display.go
|
||||||
|
- grep -q 'WaitingSince' display.go
|
||||||
|
- grep -q 'TestDisplayWithWorkspace' display_test.go
|
||||||
|
- grep -q 'TestDisplayWithLabel' display_test.go
|
||||||
|
- grep -q 'TestDisplayWithWaitingSince' display_test.go
|
||||||
|
- nix-shell --run "go build -o /dev/null ./..." (compile sans erreur)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>main.go dispatch list/switch/label/stop/daemon. DisplaySessionInfos affiche workspace, label et temps d'attente. Le binaire compile.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 3: Verification manuelle du workflow complet</name>
|
||||||
|
<what-built>
|
||||||
|
Daemon vmuxd complet avec CLI multi-commandes : list (avec workspace, label, temps d'attente), switch (fuzzy match), label, stop, autostart.
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Builder : `nix-shell --run "go build -o vmux ./..."`
|
||||||
|
2. Lancer `./vmux list` (doit autostart le daemon et afficher les sessions avec workspaces)
|
||||||
|
3. Verifier que chaque session affiche son workspace i3 ([ws:N])
|
||||||
|
4. Attribuer un label : `./vmux label <session-id> "test label"`
|
||||||
|
5. Relancer `./vmux list` et verifier que le label apparait
|
||||||
|
6. Tester le switch : `./vmux switch <partie-du-nom>` (doit changer de workspace)
|
||||||
|
7. Verifier que les sessions en attente affichent "depuis X min"
|
||||||
|
8. Arreter : `./vmux stop`
|
||||||
|
9. Verifier que le socket est supprime : `ls ~/.vmux/vmux.sock` (doit echouer)
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" ou decris les problemes constates</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
nix-shell --run "go test -v -race ./..."
|
||||||
|
nix-shell --run "go build -o vmux ./..."
|
||||||
|
./vmux list
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- vmux list affiche workspace, label et temps d'attente (DISC-04, I3-01, STATE-04)
|
||||||
|
- vmux switch bascule vers le bon workspace i3 (I3-02)
|
||||||
|
- vmux label persiste le label (DISC-04)
|
||||||
|
- vmux stop arrete le daemon (D-03)
|
||||||
|
- Autostart fonctionne (D-03)
|
||||||
|
- Le binaire compile et tous les tests passent
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-daemon-et-i3-bridge/02-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user