docs(01-session-discovery): create phase plan
This commit is contained in:
423
.planning/phases/01-session-discovery/01-02-PLAN.md
Normal file
423
.planning/phases/01-session-discovery/01-02-PLAN.md
Normal file
@@ -0,0 +1,423 @@
|
||||
---
|
||||
phase: 01-session-discovery
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 01-01
|
||||
files_modified:
|
||||
- session.go
|
||||
- session_test.go
|
||||
- state.go
|
||||
- state_test.go
|
||||
- display.go
|
||||
- display_test.go
|
||||
- main.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DISC-03
|
||||
- STATE-01
|
||||
- STATE-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "vmux affiche la branche git de chaque session"
|
||||
- "vmux detecte si une session travaille, attend input ou est idle"
|
||||
- "vmux affiche un apercu des dernieres lignes de sortie"
|
||||
- "vmux list affiche toutes les sessions avec leur etat, cwd, branche et apercu"
|
||||
artifacts:
|
||||
- path: "session.go"
|
||||
provides: "Matching PID->JSONL, lecture metadonnees JSONL"
|
||||
exports: ["FindSessionForProcess", "TailReadJSONL"]
|
||||
- path: "state.go"
|
||||
provides: "Heuristique d'etat basee sur le dernier message JSONL"
|
||||
exports: ["DetectState", "ExtractPreview"]
|
||||
- path: "display.go"
|
||||
provides: "Formatage et affichage CLI avec couleurs"
|
||||
exports: ["DisplaySessions"]
|
||||
- path: "main.go"
|
||||
provides: "Point d'entree CLI vmux list"
|
||||
contains: "func main"
|
||||
key_links:
|
||||
- from: "session.go"
|
||||
to: "~/.claude/projects/<encoded-cwd>/*.jsonl"
|
||||
via: "EncodePath + filepath.Glob"
|
||||
pattern: "EncodePath.*Glob"
|
||||
- from: "state.go"
|
||||
to: "session.go"
|
||||
via: "TailReadJSONL fournit les messages au DetectState"
|
||||
pattern: "DetectState.*JSONLMessage"
|
||||
- from: "main.go"
|
||||
to: "proc.go + session.go + state.go + display.go"
|
||||
via: "Pipeline FindClaudeProcesses -> FindSessionForProcess -> DetectState -> DisplaySessions"
|
||||
pattern: "FindClaudeProcesses.*FindSession.*DetectState.*Display"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Parsing JSONL, heuristique d'etat et CLI `vmux list` fonctionnel.
|
||||
|
||||
Purpose: Completer le pipeline de detection : pour chaque processus Claude trouve en Plan 01, trouver le JSONL correspondant, determiner l'etat, extraire un apercu, et afficher le tout.
|
||||
Output: `vmux list` fonctionnel qui affiche toutes les sessions Claude Code avec etat, cwd, branche git et apercu.
|
||||
</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/01-session-discovery/01-CONTEXT.md
|
||||
@.planning/phases/01-session-discovery/01-RESEARCH.md
|
||||
@.planning/phases/01-session-discovery/01-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Types et fonctions exportees par Plan 01. L'executeur doit les utiliser directement. -->
|
||||
|
||||
From types.go:
|
||||
```go
|
||||
package main
|
||||
|
||||
type SessionState int
|
||||
const (
|
||||
Working SessionState = iota
|
||||
NeedsInput
|
||||
Idle
|
||||
Unknown
|
||||
)
|
||||
func (s SessionState) String() string
|
||||
|
||||
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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Matching PID->JSONL + tail-read + heuristique d'etat</name>
|
||||
<files>session.go, session_test.go, state.go, state_test.go</files>
|
||||
<read_first>
|
||||
types.go
|
||||
proc.go
|
||||
.planning/phases/01-session-discovery/01-RESEARCH.md
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestTailReadJSONL: fichier JSONL de 50 lignes, lire les 3 dernieres -> retourne exactement 3 messages JSON valides.
|
||||
- TestTailReadJSONL_TruncatedLastLine: JSONL dont la derniere ligne est incomplete (pas de \n final) -> ignore la ligne tronquee, retourne les lignes completes precedentes.
|
||||
- TestTailReadJSONL_EmptyFile: fichier vide -> retourne slice vide sans erreur.
|
||||
- TestTailReadJSONL_SmallFile: fichier de 2 lignes, demander 10 -> retourne les 2 lignes.
|
||||
- TestFindSessionForProcess: dossier ~/.claude/projects/ simule avec 2 fichiers JSONL, retourne celui avec le mtime le plus recent.
|
||||
- TestFindSessionForProcess_NoMatch: aucun dossier correspondant -> retourne erreur.
|
||||
- TestDetectState_EndTurnText: message assistant avec stop_reason="end_turn" et content type "text" -> NeedsInput.
|
||||
- TestDetectState_ToolUseAskUser: message assistant avec stop_reason="tool_use" et tool name "AskUserQuestion" -> NeedsInput.
|
||||
- TestDetectState_ToolUseOther: message assistant avec stop_reason="tool_use" et tool name "Read" -> Working.
|
||||
- TestDetectState_Progress: message type="progress" avec data.type="agent_progress" -> Working.
|
||||
- TestDetectState_ToolResult: message type="user" avec content type "tool_result" -> Working.
|
||||
- TestDetectState_IdleThreshold: message timestamp > 60s dans le passe -> Idle.
|
||||
- TestExtractPreview: message assistant avec content text "Voici le resultat..." -> retourne les 3 premieres lignes du texte.
|
||||
</behavior>
|
||||
<action>
|
||||
**session.go** (package main) :
|
||||
|
||||
Structure intermediaire pour le parsing JSONL :
|
||||
```go
|
||||
type JSONLMessage struct {
|
||||
Type string `json:"type"` // "assistant", "user", "progress", "system"
|
||||
Timestamp string `json:"timestamp"` // ISO 8601
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
GitBranch string `json:"gitBranch"`
|
||||
Message *MessagePayload `json:"message,omitempty"`
|
||||
Data *ProgressData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type MessagePayload struct {
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"` // "text", "tool_use", "tool_result"
|
||||
Text string `json:"text,omitempty"`
|
||||
Name string `json:"name,omitempty"` // pour tool_use : nom de l'outil
|
||||
}
|
||||
|
||||
type ProgressData struct {
|
||||
Type string `json:"type"` // "agent_progress", "hook_progress"
|
||||
}
|
||||
```
|
||||
|
||||
`TailReadJSONL(path string, n int) ([]JSONLMessage, error)` :
|
||||
- Ouvrir le fichier, `Seek(0, io.SeekEnd)` pour obtenir la taille.
|
||||
- Lire des blocs de 8192 octets en reculant depuis la fin.
|
||||
- Accumuler les lignes completes (terminees par `\n`). Ignorer la derniere ligne si elle ne se termine pas par `\n` (per pitfall 3 : race condition).
|
||||
- Arreter quand on a `n` lignes completes ou qu'on atteint le debut du fichier.
|
||||
- Parser chaque ligne en `JSONLMessage` via `json.Unmarshal`. Ignorer les lignes qui ne parsent pas (lignes vides, corruptions).
|
||||
- Retourner les messages dans l'ordre chronologique (le plus ancien en premier).
|
||||
|
||||
`FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error)` :
|
||||
- `claudeDir` = chemin vers `~/.claude/projects/` (parametre pour testabilite). En production : `os.Getenv("HOME") + "/.claude/projects/"`.
|
||||
- Encoder `proc.Cwd` via `EncodePath(proc.Cwd)` pour obtenir le nom du dossier.
|
||||
- Lister les `*.jsonl` dans `claudeDir/<encoded>/` (PAS les sous-dossiers, per pitfall 5 : exclure subagents/).
|
||||
- Utiliser `filepath.Glob(filepath.Join(claudeDir, encoded, "*.jsonl"))` puis filtrer ceux qui sont dans un sous-dossier.
|
||||
- Trier par mtime (le plus recent en premier). Prendre le premier = session active (per RESEARCH Pattern 5).
|
||||
- Appeler `TailReadJSONL(path, 5)` sur le JSONL trouve.
|
||||
- Extraire `sessionId` et `gitBranch` depuis la premiere entree retournee.
|
||||
- Retourner le chemin JSONL, les messages, et nil. Si aucun JSONL trouve, retourner une erreur.
|
||||
|
||||
**state.go** (package main) :
|
||||
|
||||
`DetectState(messages []JSONLMessage, now time.Time) SessionState` :
|
||||
- Si `messages` est vide, retourner `Unknown`.
|
||||
- Prendre le dernier message (index len-1).
|
||||
- Si le timestamp du dernier message est > 60 secondes avant `now` -> `Idle` (const `IdleThreshold = 60 * time.Second`).
|
||||
- Si `type == "assistant"` et `message.stop_reason == "end_turn"` -> `NeedsInput` (per D-05 : Claude a fini, attend l'utilisateur).
|
||||
- Si `type == "assistant"` et `message.stop_reason == "tool_use"` :
|
||||
- Scanner `message.content` pour un `ContentBlock` de type `"tool_use"`.
|
||||
- Si `name == "AskUserQuestion"` -> `NeedsInput` (per D-05).
|
||||
- Sinon -> `Working` (outil en cours d'execution, per D-05).
|
||||
- Si `type == "progress"` -> `Working` (per D-05 : hook ou subagent en cours).
|
||||
- Si `type == "user"` et un ContentBlock a `type == "tool_result"` -> `Working` (resultat envoye, Claude va repondre).
|
||||
- Sinon -> `Unknown`.
|
||||
|
||||
Le parametre `now time.Time` permet de tester le seuil Idle sans dependre de l'horloge.
|
||||
|
||||
`ExtractPreview(messages []JSONLMessage) string` :
|
||||
- Parcourir les messages en ordre inverse pour trouver le dernier `type == "assistant"` avec un `ContentBlock` de type `"text"`.
|
||||
- Extraire le champ `text` de ce ContentBlock.
|
||||
- Tronquer a 3 lignes (split sur `\n`, prendre les 3 premieres, rejoin).
|
||||
- Si le texte depasse 200 caracteres, tronquer a 200 et ajouter "...".
|
||||
- Si aucun texte trouve, retourner "" (vide).
|
||||
|
||||
**session_test.go** et **state_test.go** :
|
||||
|
||||
Utiliser des fixtures JSONL dans des fichiers temporaires. Pour chaque test :
|
||||
- Creer un `t.TempDir()`.
|
||||
- Ecrire des lignes JSON valides representant les cas decrits dans behavior.
|
||||
- Les fixtures JSONL doivent reproduire le format exact documente dans RESEARCH (voir section "Code Examples").
|
||||
|
||||
Exemple de fixture pour un message assistant end_turn :
|
||||
```json
|
||||
{"type":"assistant","timestamp":"2026-03-23T12:00:00Z","sessionId":"abc","cwd":"/tmp","gitBranch":"main","message":{"role":"assistant","content":[{"type":"text","text":"Done."}],"stop_reason":"end_turn"}}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/pierre/Code/vibe/vmux && nix-shell --run "go test -v -run 'TestTailRead|TestFindSession|TestDetectState|TestExtractPreview' ./..."</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- session.go contient `func TailReadJSONL(path string, n int) ([]JSONLMessage, error)`
|
||||
- session.go contient `func FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error)`
|
||||
- state.go contient `func DetectState(messages []JSONLMessage, now time.Time) SessionState`
|
||||
- state.go contient `func ExtractPreview(messages []JSONLMessage) string`
|
||||
- state.go contient `IdleThreshold` comme constante a 60 secondes
|
||||
- session_test.go contient `TestTailReadJSONL` et `TestFindSessionForProcess`
|
||||
- state_test.go contient `TestDetectState_EndTurnText`, `TestDetectState_ToolUseAskUser`, `TestDetectState_ToolUseOther`, `TestDetectState_IdleThreshold`
|
||||
- state_test.go contient `TestExtractPreview`
|
||||
- Tous les tests passent
|
||||
</acceptance_criteria>
|
||||
<done>TailReadJSONL lit les N derniers messages sans charger le fichier entier. FindSessionForProcess fait le matching PID->JSONL. DetectState applique l'heuristique d'etat. ExtractPreview extrait un apercu. Tous les tests passent.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Affichage CLI + main.go + test d'integration</name>
|
||||
<files>display.go, display_test.go, main.go</files>
|
||||
<read_first>
|
||||
types.go
|
||||
proc.go
|
||||
session.go
|
||||
state.go
|
||||
</read_first>
|
||||
<action>
|
||||
**display.go** (package main) :
|
||||
|
||||
Constantes ANSI (per D-07, pas de lib externe) :
|
||||
```go
|
||||
const (
|
||||
colorReset = "\033[0m"
|
||||
colorGreen = "\033[32m" // Working
|
||||
colorYellow = "\033[33m" // Needs Input
|
||||
colorRed = "\033[31m" // Needs Input (AskUserQuestion)
|
||||
colorGray = "\033[90m" // Idle / Unknown
|
||||
colorBold = "\033[1m"
|
||||
)
|
||||
```
|
||||
|
||||
`DisplaySessions(w io.Writer, sessions []Session, noColor bool)` :
|
||||
- Si `sessions` est vide, ecrire "No active Claude Code sessions found.\n" et retourner.
|
||||
- Pour chaque session, afficher un bloc :
|
||||
```
|
||||
[STATE] cwd (branch)
|
||||
Preview text here...
|
||||
```
|
||||
- Le `[STATE]` est colore selon l'etat (sauf si `noColor == true`) :
|
||||
- Working -> colorGreen + "Working"
|
||||
- NeedsInput -> colorYellow + "Needs Input"
|
||||
- Idle -> colorGray + "Idle"
|
||||
- Unknown -> colorGray + "Unknown"
|
||||
- Le `cwd` est le `Session.CwdPath`. Afficher aussi le `Worktree` si different du cwd.
|
||||
- Le `(branch)` est `Session.GitBranch`. Si vide, omettre les parentheses.
|
||||
- Le `Preview` est indente de 9 espaces (alignement avec le texte apres [STATE]). Chaque ligne du preview est prefixee de ces espaces.
|
||||
- Separer les sessions par une ligne vide.
|
||||
- Ecrire dans `w` (io.Writer) pour la testabilite. En production : `os.Stdout`.
|
||||
|
||||
`stateColor(state SessionState, noColor bool) string` : helper qui retourne le code ANSI pour un etat, ou "" si noColor.
|
||||
|
||||
**display_test.go** (package main) :
|
||||
|
||||
- `TestDisplaySessions_WithSessions` : 2 sessions (Working + NeedsInput), verifier que la sortie contient les cwds, branches et etats.
|
||||
- `TestDisplaySessions_NoColor` : meme test avec noColor=true, verifier qu'aucun code ANSI n'est present (pas de `\033`).
|
||||
- `TestDisplaySessions_Empty` : sessions vide -> "No active Claude Code sessions found."
|
||||
|
||||
Utiliser un `bytes.Buffer` comme io.Writer pour capturer la sortie.
|
||||
|
||||
**main.go** (package main) :
|
||||
|
||||
```go
|
||||
func main() {
|
||||
noColor := flag.Bool("no-color", false, "Disable colored output")
|
||||
flag.Parse()
|
||||
|
||||
// Aussi detecter NO_COLOR env var (convention standard)
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
*noColor = true
|
||||
}
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) == 0 || args[0] != "list" {
|
||||
fmt.Fprintf(os.Stderr, "Usage: vmux list [--no-color]\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
processes, err := FindClaudeProcesses("/proc")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error scanning processes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
claudeDir := filepath.Join(os.Getenv("HOME"), ".claude", "projects")
|
||||
now := time.Now()
|
||||
var sessions []Session
|
||||
|
||||
for _, proc := range processes {
|
||||
jsonlPath, messages, err := FindSessionForProcess(claudeDir, proc)
|
||||
if err != nil {
|
||||
// Processus sans JSONL -> afficher avec etat Unknown (per pitfall 4)
|
||||
sessions = append(sessions, Session{
|
||||
Process: proc,
|
||||
State: Unknown,
|
||||
CwdPath: proc.Cwd,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
state := DetectState(messages, now)
|
||||
preview := ExtractPreview(messages)
|
||||
|
||||
// Extraire metadonnees du JSONL
|
||||
var sessionID, gitBranch, worktree string
|
||||
for _, msg := range messages {
|
||||
if msg.SessionID != "" {
|
||||
sessionID = msg.SessionID
|
||||
}
|
||||
if msg.GitBranch != "" {
|
||||
gitBranch = msg.GitBranch
|
||||
}
|
||||
}
|
||||
|
||||
// Worktree = git rev-parse --show-toplevel depuis le cwd
|
||||
worktree = resolveWorktree(proc.Cwd)
|
||||
|
||||
_ = jsonlPath // utilise pour le debug futur
|
||||
|
||||
sessions = append(sessions, Session{
|
||||
Process: proc,
|
||||
SessionID: sessionID,
|
||||
GitBranch: gitBranch,
|
||||
State: state,
|
||||
Preview: preview,
|
||||
CwdPath: proc.Cwd,
|
||||
Worktree: worktree,
|
||||
})
|
||||
}
|
||||
|
||||
DisplaySessions(os.Stdout, sessions, *noColor)
|
||||
}
|
||||
|
||||
// resolveWorktree utilise git pour trouver le worktree.
|
||||
// Retourne le cwd si git echoue (pas un repo git).
|
||||
func resolveWorktree(cwd string) string {
|
||||
cmd := exec.Command("git", "-C", cwd, "rev-parse", "--show-toplevel")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return cwd
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
```
|
||||
|
||||
Imports necessaires : `flag`, `fmt`, `os`, `os/exec`, `path/filepath`, `strings`, `time`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/pierre/Code/vibe/vmux && nix-shell --run "go test -v ./... && go build -o vmux ./..."</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- display.go contient `func DisplaySessions(w io.Writer, sessions []Session, noColor bool)`
|
||||
- display.go contient les constantes ANSI colorGreen, colorYellow, colorGray
|
||||
- display_test.go contient `TestDisplaySessions_WithSessions`
|
||||
- display_test.go contient `TestDisplaySessions_NoColor`
|
||||
- display_test.go contient `TestDisplaySessions_Empty`
|
||||
- main.go contient `func main()` avec `flag.Parse()` et sous-commande "list"
|
||||
- main.go contient `FindClaudeProcesses("/proc")`
|
||||
- main.go contient `resolveWorktree` qui appelle `git rev-parse --show-toplevel`
|
||||
- main.go gere le flag `--no-color` et la variable `NO_COLOR`
|
||||
- `go build -o vmux ./...` produit un binaire vmux
|
||||
- `go test -v ./...` : tous les tests passent (proc, session, state, display)
|
||||
</acceptance_criteria>
|
||||
<done>vmux list fonctionne : detecte les processus Claude, lit les JSONL, determine l'etat, affiche le resultat avec couleurs. Le binaire compile. Tous les tests passent.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test -v -race ./...` : tous les tests passent sans race condition
|
||||
- `go build -o vmux ./...` : le binaire compile
|
||||
- `./vmux list` : affiche les sessions Claude Code actives sur le poste (test manuel)
|
||||
- `./vmux list --no-color` : meme sortie sans codes ANSI
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `vmux list` affiche toutes les sessions Claude Code actives (DISC-01)
|
||||
- Chaque session affiche son cwd et worktree git (DISC-02)
|
||||
- Chaque session affiche sa branche git (DISC-03)
|
||||
- Chaque session affiche son etat Working/Needs Input/Idle (STATE-01)
|
||||
- Chaque session affiche un apercu de la derniere sortie (STATE-02)
|
||||
- Couleurs actives par defaut, desactivables avec --no-color (D-07)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-session-discovery/01-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user