@cyperx/clawforge 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/VERSION +1 -0
  4. package/bin/attach.sh +98 -0
  5. package/bin/check-agents.sh +343 -0
  6. package/bin/clawforge +257 -0
  7. package/bin/clawforge-dashboard +0 -0
  8. package/bin/clean.sh +257 -0
  9. package/bin/config.sh +111 -0
  10. package/bin/conflicts.sh +224 -0
  11. package/bin/cost.sh +273 -0
  12. package/bin/dashboard.sh +557 -0
  13. package/bin/diff.sh +109 -0
  14. package/bin/doctor.sh +196 -0
  15. package/bin/eval.sh +217 -0
  16. package/bin/history.sh +91 -0
  17. package/bin/init.sh +182 -0
  18. package/bin/learn.sh +230 -0
  19. package/bin/logs.sh +126 -0
  20. package/bin/memory.sh +207 -0
  21. package/bin/merge-helper.sh +174 -0
  22. package/bin/multi-review.sh +215 -0
  23. package/bin/notify.sh +93 -0
  24. package/bin/on-complete.sh +149 -0
  25. package/bin/parse-cost.sh +205 -0
  26. package/bin/pr.sh +167 -0
  27. package/bin/resume.sh +183 -0
  28. package/bin/review-mode.sh +163 -0
  29. package/bin/review-pr.sh +145 -0
  30. package/bin/routing.sh +88 -0
  31. package/bin/scope-task.sh +169 -0
  32. package/bin/spawn-agent.sh +190 -0
  33. package/bin/sprint.sh +320 -0
  34. package/bin/steer.sh +107 -0
  35. package/bin/stop.sh +136 -0
  36. package/bin/summary.sh +182 -0
  37. package/bin/swarm.sh +525 -0
  38. package/bin/templates.sh +244 -0
  39. package/lib/common.sh +302 -0
  40. package/lib/templates/bugfix.json +6 -0
  41. package/lib/templates/migration.json +7 -0
  42. package/lib/templates/refactor.json +6 -0
  43. package/lib/templates/security-audit.json +5 -0
  44. package/lib/templates/test-coverage.json +6 -0
  45. package/package.json +31 -0
  46. package/registry/conflicts.jsonl +0 -0
  47. package/registry/costs.jsonl +0 -0
  48. package/tui/PRD.md +106 -0
  49. package/tui/agent.go +266 -0
  50. package/tui/animation.go +192 -0
  51. package/tui/dashboard.go +219 -0
  52. package/tui/filter.go +68 -0
  53. package/tui/go.mod +25 -0
  54. package/tui/go.sum +46 -0
  55. package/tui/keybindings.go +229 -0
  56. package/tui/main.go +61 -0
  57. package/tui/model.go +166 -0
  58. package/tui/steer.go +69 -0
  59. package/tui/styles.go +69 -0
package/tui/PRD.md ADDED
@@ -0,0 +1,106 @@
1
+ # ClawForge TUI — Go + Bubble Tea Rewrite
2
+
3
+ ## Goal
4
+ Replace the bash dashboard.sh with a flicker-free Go TUI using Bubble Tea v2 + Lipgloss v2 + Bubbles.
5
+
6
+ ## Architecture
7
+ - Single Go binary: `clawforge-dashboard`
8
+ - Lives in `tui/` directory within the clawforge repo
9
+ - Reads data from clawforge registry (JSON files) + tmux + git
10
+
11
+ ## Data Sources
12
+ - `registry/active-tasks.json` — task list with id, mode, status, branch, description
13
+ - `registry/costs.jsonl` — per-task cost data (inputTokens, outputTokens, totalCost)
14
+ - `registry/conflicts.jsonl` — conflict records
15
+ - `tmux list-sessions -F "#{session_name} #{session_activity}"` — live session status
16
+ - `git -C <worktree> log --oneline -1` — last commit per agent
17
+
18
+ ## File Structure
19
+ ```
20
+ tui/
21
+ ├── main.go # Entry point, tea.NewProgram
22
+ ├── model.go # Model struct, Init, Update, View
23
+ ├── animation.go # Forge startup animation frames + logic
24
+ ├── dashboard.go # Main dashboard view rendering
25
+ ├── agent.go # Agent data structures + loading from registry
26
+ ├── keybindings.go # Key handling + help overlay
27
+ ├── styles.go # Lipgloss styles (amber/orange forge theme)
28
+ ├── steer.go # Steer input modal
29
+ ├── filter.go # Filter input handling
30
+ └── go.mod
31
+ ```
32
+
33
+ ## Phase 1: Animation (animation.go + styles.go)
34
+ - 8-10 frames of ASCII forge art with hammering/sparks effect
35
+ - Amber/orange color scheme via Lipgloss (colors: #FF8C00, #FF6600, #FFA500, #CC5500)
36
+ - Frame cycle: 120ms per frame, ~1.5 seconds total
37
+ - Transition: fade last frame → dashboard view
38
+ - tea.Tick for frame timing
39
+ - --no-anim flag skips directly to dashboard
40
+
41
+ ## Phase 2: Dashboard View (dashboard.go + agent.go)
42
+ - Header: "ClawForge Dashboard" with forge emoji + version
43
+ - Agent table columns:
44
+ | ID | Mode | Status | Branch | Task (truncated) | Cost | CI | Conflicts |
45
+ - Selected row highlighted with reverse video + amber accent
46
+ - Status indicators: 🟢 running, 🟡 idle, 🔴 failed, ⚪ done
47
+ - Cost column: "$X.XX" or "-" if no cost data
48
+ - CI column: ✅/❌/⏳/-
49
+ - Conflicts column: count or "-"
50
+ - Footer status bar: "4 agents | 2 running | $3.42 total | ↑↓ navigate | ? help"
51
+ - Auto-refresh: tea.Tick every 2 seconds triggers data reload
52
+ - Only re-renders changed cells (Bubble Tea handles this automatically)
53
+
54
+ ## Phase 3: Vim Keybindings (keybindings.go)
55
+ - j/k or ↑/↓: navigate agent list
56
+ - Enter: attach to selected agent's tmux session (tea.ExecProcess)
57
+ - s: open steer input modal
58
+ - x: stop selected agent (confirm with y/n)
59
+ - q or Ctrl+C: quit
60
+ - /: open filter input
61
+ - r: force refresh
62
+ - ?: toggle help overlay
63
+ - g: go to top
64
+ - G: go to bottom
65
+ - Esc: close any modal/overlay
66
+
67
+ ## Phase 4: Steer Modal (steer.go)
68
+ - When 's' pressed: show text input at bottom
69
+ - Prompt: "Steer agent #X: "
70
+ - Enter submits: runs `clawforge steer <id> "<message>"`
71
+ - Esc cancels
72
+ - Use Bubbles textinput component
73
+
74
+ ## Phase 5: Filter (filter.go)
75
+ - When '/' pressed: show filter input at top
76
+ - Filters agent list by any column match (fuzzy)
77
+ - Esc clears filter
78
+ - Live filtering as you type
79
+
80
+ ## Phase 6: Help Overlay (keybindings.go)
81
+ - Semi-transparent overlay listing all keybindings
82
+ - Dismiss with ? or Esc
83
+ - Styled with Lipgloss border + padding
84
+
85
+ ## Performance Requirements
86
+ - Zero flicker (Bubble Tea cell-based diff handles this)
87
+ - Startup to dashboard: < 500ms (excluding animation)
88
+ - Data refresh: < 100ms (shell out to tmux/git in background)
89
+ - Smooth animation: consistent 120ms frame timing
90
+ - Handle terminal resize gracefully (tea.WindowSizeMsg)
91
+
92
+ ## Build
93
+ - `go build -o bin/clawforge-dashboard ./tui/`
94
+ - Update `bin/clawforge` to prefer Go binary when available
95
+ - Keep dashboard.sh as fallback
96
+
97
+ ## Styling (Lipgloss)
98
+ - Primary: #FF8C00 (dark orange / amber)
99
+ - Secondary: #FFA500 (orange)
100
+ - Accent: #FF6600 (red-orange)
101
+ - Muted: #CC5500 (brown-orange)
102
+ - Background: terminal default (transparent)
103
+ - Selected row: reverse + amber foreground
104
+ - Header: bold + amber
105
+ - Status bar: dim + muted
106
+ - Borders: rounded, amber
package/tui/agent.go ADDED
@@ -0,0 +1,266 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "strings"
10
+ )
11
+
12
+ // Agent represents a single ClawForge agent with its current state.
13
+ type Agent struct {
14
+ ID string
15
+ ShortID int
16
+ Preview string
17
+ Mode string
18
+ Model string
19
+ Repo string
20
+ Status string
21
+ Branch string
22
+ Task string
23
+ Cost string
24
+ CI string
25
+ Conflicts int
26
+ Description string
27
+ Worktree string
28
+ TmuxSession string
29
+ }
30
+
31
+ // registryTask mirrors the JSON structure in active-tasks.json.
32
+ type registryTask struct {
33
+ ID string `json:"id"`
34
+ ShortID int `json:"short_id"`
35
+ Mode string `json:"mode"`
36
+ Status string `json:"status"`
37
+ Branch string `json:"branch"`
38
+ Description string `json:"description"`
39
+ Worktree string `json:"worktree"`
40
+ TmuxSession string `json:"tmux_session"`
41
+ Agent string `json:"agent"`
42
+ Model string `json:"model"`
43
+ Repo string `json:"repo"`
44
+ CIStatus string `json:"ci_status"`
45
+ }
46
+
47
+ type registryFile struct {
48
+ Tasks []registryTask `json:"tasks"`
49
+ }
50
+
51
+ // costEntry mirrors a line in costs.jsonl.
52
+ type costEntry struct {
53
+ TaskID string `json:"taskId"`
54
+ TotalCost float64 `json:"totalCost"`
55
+ }
56
+
57
+ // conflictEntry mirrors a line in conflicts.jsonl.
58
+ type conflictEntry struct {
59
+ TaskIDs []string `json:"task_ids"`
60
+ Status string `json:"status"`
61
+ Resolved bool `json:"resolved"`
62
+ }
63
+
64
+ // clawforgeDir returns the clawforge root directory.
65
+ func clawforgeDir() string {
66
+ // Check CLAWFORGE_DIR env first, then walk up from executable.
67
+ if d := os.Getenv("CLAWFORGE_DIR"); d != "" {
68
+ return d
69
+ }
70
+ exe, err := os.Executable()
71
+ if err == nil {
72
+ dir := filepath.Dir(exe)
73
+ // If binary is in bin/, go up one level.
74
+ if filepath.Base(dir) == "bin" {
75
+ return filepath.Dir(dir)
76
+ }
77
+ // If binary is in tui/, go up one level.
78
+ if filepath.Base(dir) == "tui" {
79
+ return filepath.Dir(dir)
80
+ }
81
+ return filepath.Dir(dir)
82
+ }
83
+ return "."
84
+ }
85
+
86
+ // LoadAgents reads the registry, cost data, conflict data, and tmux state
87
+ // to produce a full list of Agent records.
88
+ func LoadAgents() []Agent {
89
+ root := clawforgeDir()
90
+ registryPath := filepath.Join(root, "registry", "active-tasks.json")
91
+
92
+ data, err := os.ReadFile(registryPath)
93
+ if err != nil {
94
+ return nil
95
+ }
96
+
97
+ var reg registryFile
98
+ if err := json.Unmarshal(data, &reg); err != nil {
99
+ return nil
100
+ }
101
+
102
+ // Load cost data.
103
+ costs := loadCosts(filepath.Join(root, "registry", "costs.jsonl"))
104
+
105
+ // Load conflict counts.
106
+ conflicts := loadConflictCounts(filepath.Join(root, "registry", "conflicts.jsonl"))
107
+
108
+ // Load tmux sessions.
109
+ tmuxSessions := loadTmuxSessions()
110
+
111
+ agents := make([]Agent, 0, len(reg.Tasks))
112
+ previewLines := 3
113
+ for _, t := range reg.Tasks {
114
+ a := Agent{
115
+ ID: t.ID,
116
+ ShortID: t.ShortID,
117
+ Mode: t.Mode,
118
+ Model: t.Model,
119
+ Repo: t.Repo,
120
+ Status: t.Status,
121
+ Branch: t.Branch,
122
+ Description: t.Description,
123
+ Task: t.Description,
124
+ Worktree: t.Worktree,
125
+ TmuxSession: t.TmuxSession,
126
+ CI: ciIndicator(t.CIStatus),
127
+ Cost: costs[t.ID],
128
+ Conflicts: conflicts[t.ID],
129
+ }
130
+
131
+ // Enrich status from tmux if task is "running".
132
+ if a.Status == "running" && a.TmuxSession != "" {
133
+ if _, ok := tmuxSessions[a.TmuxSession]; !ok {
134
+ a.Status = "failed"
135
+ } else {
136
+ a.Preview = captureTmuxPreview(a.TmuxSession, previewLines)
137
+ }
138
+ }
139
+
140
+ if a.Cost == "" {
141
+ a.Cost = "-"
142
+ }
143
+ if a.Mode == "" {
144
+ a.Mode = "—"
145
+ }
146
+ if a.Model == "" {
147
+ a.Model = "-"
148
+ }
149
+ if a.Repo == "" {
150
+ a.Repo = "-"
151
+ } else {
152
+ a.Repo = filepath.Base(a.Repo)
153
+ }
154
+
155
+ agents = append(agents, a)
156
+ }
157
+
158
+ return agents
159
+ }
160
+
161
+ // loadCosts reads costs.jsonl and sums cost per task.
162
+ func loadCosts(path string) map[string]string {
163
+ result := make(map[string]string)
164
+ data, err := os.ReadFile(path)
165
+ if err != nil {
166
+ return result
167
+ }
168
+
169
+ totals := make(map[string]float64)
170
+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
171
+ if line == "" {
172
+ continue
173
+ }
174
+ var entry costEntry
175
+ if json.Unmarshal([]byte(line), &entry) == nil && entry.TaskID != "" {
176
+ totals[entry.TaskID] += entry.TotalCost
177
+ }
178
+ }
179
+
180
+ for id, total := range totals {
181
+ result[id] = fmt.Sprintf("$%.2f", total)
182
+ }
183
+ return result
184
+ }
185
+
186
+ // loadConflictCounts reads conflicts.jsonl and counts unresolved conflicts per task.
187
+ func loadConflictCounts(path string) map[string]int {
188
+ result := make(map[string]int)
189
+ data, err := os.ReadFile(path)
190
+ if err != nil {
191
+ return result
192
+ }
193
+
194
+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
195
+ if line == "" {
196
+ continue
197
+ }
198
+ var entry conflictEntry
199
+ if json.Unmarshal([]byte(line), &entry) == nil && !entry.Resolved {
200
+ for _, id := range entry.TaskIDs {
201
+ result[id]++
202
+ }
203
+ }
204
+ }
205
+ return result
206
+ }
207
+
208
+ // loadTmuxSessions returns a set of active tmux session names.
209
+ func loadTmuxSessions() map[string]bool {
210
+ sessions := make(map[string]bool)
211
+ out, err := exec.Command("tmux", "list-sessions", "-F", "#{session_name}").Output()
212
+ if err != nil {
213
+ return sessions
214
+ }
215
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
216
+ if line != "" {
217
+ sessions[line] = true
218
+ }
219
+ }
220
+ return sessions
221
+ }
222
+
223
+ // ciIndicator converts a CI status string to a display indicator.
224
+ func ciIndicator(status string) string {
225
+ switch strings.ToLower(status) {
226
+ case "pass", "passed", "success":
227
+ return "✅"
228
+ case "fail", "failed", "failure":
229
+ return "❌"
230
+ case "pending", "running", "in_progress":
231
+ return "⏳"
232
+ default:
233
+ return "-"
234
+ }
235
+ }
236
+
237
+ // statusIndicator returns the colored status emoji for an agent.
238
+ func statusIndicator(status string) string {
239
+ switch strings.ToLower(status) {
240
+ case "running":
241
+ return "🟢"
242
+ case "idle":
243
+ return "🟡"
244
+ case "failed":
245
+ return "🔴"
246
+ case "done":
247
+ return "⚪"
248
+ default:
249
+ return "⚫"
250
+ }
251
+ }
252
+
253
+ // captureTmuxPreview grabs the last N lines from a tmux pane.
254
+ func captureTmuxPreview(session string, lines int) string {
255
+ out, err := exec.Command("tmux", "capture-pane", "-t", session, "-p", "-S", fmt.Sprintf("-%d", lines)).Output()
256
+ if err != nil {
257
+ return ""
258
+ }
259
+ // Strip ANSI escape codes for clean display
260
+ result := strings.TrimSpace(string(out))
261
+ // Basic ANSI strip
262
+ for _, prefix := range []string{"\033[", "\x1b["} {
263
+ _ = prefix // handled by sed-like logic below
264
+ }
265
+ return result
266
+ }
@@ -0,0 +1,192 @@
1
+ package main
2
+
3
+ import (
4
+ "time"
5
+
6
+ tea "charm.land/bubbletea/v2"
7
+ "charm.land/lipgloss/v2"
8
+ )
9
+
10
+ // AnimationTickMsg advances the forge startup animation by one frame.
11
+ type AnimationTickMsg time.Time
12
+
13
+ // AnimationDoneMsg signals that the startup animation has completed.
14
+ type AnimationDoneMsg struct{}
15
+
16
+ const frameDuration = 120 * time.Millisecond
17
+
18
+ // forgeFrames contains the ASCII art frames for the forge startup animation.
19
+ // 10 frames of a hammering/sparks effect at ~120ms each = ~1.2s total.
20
+ var forgeFrames = [...]string{
21
+ // Frame 0: Forge cold
22
+ `
23
+ _______________
24
+ / \
25
+ / C L A W \
26
+ / F O R G E \
27
+ / \
28
+ /_______________________\
29
+ | |
30
+ | |
31
+ | ___ |
32
+ | | | |
33
+ _______|_|___|_|________
34
+ |________________________|
35
+ `,
36
+ // Frame 1: Embers glow
37
+ `
38
+ _______________
39
+ / \
40
+ / C L A W \
41
+ / F O R G E \
42
+ / . \
43
+ /_______________________\
44
+ | |
45
+ | * |
46
+ | ___ |
47
+ | | | |
48
+ _______|_|___|_|________
49
+ |_______.....____________|
50
+ `,
51
+ // Frame 2: Fire rising
52
+ `
53
+ _______________
54
+ / \
55
+ / C L A W \
56
+ / F O R G E \
57
+ / . . \
58
+ /_______________________\
59
+ | * * |
60
+ | /|\ |
61
+ | ___ |
62
+ | |^^^| |
63
+ _______|_|___|_|________
64
+ |______*..^^^..*_________|
65
+ `,
66
+ // Frame 3: Hammer up
67
+ `
68
+ _______________
69
+ / \ _____
70
+ / C L A W \ | |
71
+ / F O R G E \ | ))) |
72
+ / * . * \|_____|
73
+ /_______________________\ |
74
+ | *** * | |
75
+ | /|\ | /
76
+ | ___ | /
77
+ | |^^^| |
78
+ _______|_|___|_|________
79
+ |______*..^^^..*_________|
80
+ `,
81
+ // Frame 4: Hammer strike!
82
+ `
83
+ _______________
84
+ / \
85
+ / C L A W \
86
+ / F O R G E \ _____
87
+ / * * . * * \ | |
88
+ /________________________| ))) |
89
+ | ***** | |_____|
90
+ | /|\ |
91
+ | ___ |
92
+ | |^^^| |
93
+ _______|_|___|_|________
94
+ |______*..^^^..*_________|
95
+ `,
96
+ // Frame 5: SPARKS!
97
+ `
98
+ _______________
99
+ / * * \
100
+ / C L A W * \
101
+ / F O R G E * \ _____
102
+ / * * * . * * * \ | |
103
+ /________________________\| ))) |
104
+ * | ***** | * |_____|
105
+ * | /|\ | *
106
+ | ___ |
107
+ | |^^^| |
108
+ _______|_|___|_|________
109
+ |______*..^^^..*_________|
110
+ `,
111
+ // Frame 6: Sparks fade, hammer lifts
112
+ `
113
+ _______________
114
+ / * \
115
+ / C L A W \
116
+ / F O R G E \ _____
117
+ / * . . * \ | |
118
+ /_______________________\ | ))) |
119
+ | * * * | |_____|
120
+ | /|\ | |
121
+ | ___ | /
122
+ | |^^^| |
123
+ _______|_|___|_|________
124
+ |______*..^^^..*_________|
125
+ `,
126
+ // Frame 7: Second strike!
127
+ `
128
+ _______________
129
+ / \
130
+ / C L A W \
131
+ / F O R G E \ _____
132
+ / * * . * * \ | |
133
+ /________________________| ))) |
134
+ | ***** | |_____|
135
+ | /|\ |
136
+ | ___ |
137
+ | |^^^| |
138
+ _______|_|___|_|________
139
+ |______*..^^^..*_________|
140
+ `,
141
+ // Frame 8: BIG SPARKS!
142
+ `
143
+ * _______________ *
144
+ * / * * * \ *
145
+ / C L A W * * \
146
+ * / F O R G E * \ _____
147
+ / * * * * . * * * * \ | |
148
+ /________________________\| ))) |
149
+ * * | ***** | * * |_____|
150
+ * * | /|\ | * *
151
+ * | ___ | *
152
+ | |^^^| |
153
+ _______|_|___|_|________
154
+ |______*..^^^..*_________|
155
+ `,
156
+ // Frame 9: Forge ready — blade forged
157
+ `
158
+ _______________
159
+ / \
160
+ / C L A W \
161
+ / F O R G E \
162
+ / READY \
163
+ /_______________________\
164
+ | |
165
+ | --- |
166
+ | ___ |
167
+ | |===| |
168
+ _______|_|___|_|________
169
+ |_______ FORGED _________|
170
+ `,
171
+ }
172
+
173
+ // animationTick returns a command that sends an AnimationTickMsg after frameDuration.
174
+ func animationTick() tea.Cmd {
175
+ return tea.Tick(frameDuration, func(t time.Time) tea.Msg {
176
+ return AnimationTickMsg(t)
177
+ })
178
+ }
179
+
180
+ // renderAnimation renders the current animation frame centered in the terminal.
181
+ func renderAnimation(frame int, width, height int) string {
182
+ if frame < 0 || frame >= len(forgeFrames) {
183
+ return ""
184
+ }
185
+
186
+ content := animFrameStyle.Render(forgeFrames[frame])
187
+
188
+ if width > 0 && height > 0 {
189
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
190
+ }
191
+ return content
192
+ }