From 714bdacf80ed73b8922833ea55cec8977e7c5855 Mon Sep 17 00:00:00 2001 From: kas Date: Tue, 17 Feb 2026 23:15:38 -0600 Subject: [PATCH 01/26] charm: clean slate with 016-kasmos-agent-orchestrator as sole feature Remove old Rust/Zellij-era specs (011-014). Add TUI design artifacts (layout, mockups, keybinds, styles) and technical research (interfaces, schemas, message types, package structure) for the Go/bubbletea rewrite. --- design-artifacts/tui-keybinds.md | 417 ++++++ design-artifacts/tui-layout-spec.md | 535 +++++++ design-artifacts/tui-mockups.md | 503 +++++++ design-artifacts/tui-styles.md | 556 +++++++ .../checklists/requirements.md | 37 - .../contracts/kasmos-serve.json | 332 ----- .../data-model.md | 141 -- .../meta.json | 24 - .../011-mcp-agent-swarm-orchestration/plan.md | 238 --- .../quickstart.md | 107 -- .../research.md | 64 - .../research/evidence-log.csv | 15 - .../research/source-register.csv | 16 - .../011-mcp-agent-swarm-orchestration/spec.md | 246 ---- .../tasks.md | 505 ------- .../tasks/.gitkeep | 0 .../WP01-cli-pivot-and-legacy-tui-gating.md | 312 ---- ...config-feature-resolution-and-preflight.md | 311 ---- ...P03-launch-layout-and-session-bootstrap.md | 283 ---- .../WP04-mcp-serve-bootstrap-and-contracts.md | 268 ---- .../tasks/WP05-repository-feature-locking.md | 257 ---- ...P06-audit-log-persistence-and-retention.md | 290 ---- .../tasks/WP07-worker-lifecycle-tools.md | 332 ----- .../WP08-message-log-and-event-waiting.md | 385 ----- .../WP09-workflow-status-and-transitions.md | 329 ----- ...WP10-setup-command-and-launch-hardening.md | 283 ---- .../WP11-agent-profiles-and-prompt-context.md | 301 ---- ...12-integration-and-acceptance-hardening.md | 279 ---- .../traceability.md | 50 - .../checklists/requirements.md | 36 - .../012-kasmos-new-command/data-model.md | 37 - kitty-specs/012-kasmos-new-command/meta.json | 10 - kitty-specs/012-kasmos-new-command/plan.md | 206 --- .../012-kasmos-new-command/quickstart.md | 59 - .../012-kasmos-new-command/research.md | 73 - kitty-specs/012-kasmos-new-command/spec.md | 96 -- kitty-specs/012-kasmos-new-command/tasks.md | 107 -- .../012-kasmos-new-command/tasks/.gitkeep | 0 .../012-kasmos-new-command/tasks/README.md | 69 - .../tasks/WP01-cli-preflight-prompt-launch.md | 444 ------ .../tasks/WP02-unit-tests.md | 344 ----- .../013-setup-plugin-path-discovery/meta.json | 10 - .../013-setup-plugin-path-discovery/plan.md | 113 -- .../013-setup-plugin-path-discovery/spec.md | 60 - .../tasks/WP01-config-and-autodetect.md | 148 -- .../WP01b-interactive-prompt-and-fixup.md | 169 --- .../tasks/WP02-zjstatus-check.md | 120 -- .../tasks/WP03-documentation.md | 115 -- .../tasks/WP04-tests.md | 240 --- .../014-architecture-pivot-evaluation/plan.md | 377 ----- .../research.md | 232 --- .../014-architecture-pivot-evaluation/spec.md | 259 ---- .../project-context.md | 300 ++++ .../research/tui-technical.md | 1308 +++++++++++++++++ 54 files changed, 3619 insertions(+), 8729 deletions(-) create mode 100644 design-artifacts/tui-keybinds.md create mode 100644 design-artifacts/tui-layout-spec.md create mode 100644 design-artifacts/tui-mockups.md create mode 100644 design-artifacts/tui-styles.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/checklists/requirements.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/contracts/kasmos-serve.json delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/data-model.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/meta.json delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/plan.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/research.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/research/evidence-log.csv delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/research/source-register.csv delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/spec.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/.gitkeep delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP01-cli-pivot-and-legacy-tui-gating.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP02-config-feature-resolution-and-preflight.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP03-launch-layout-and-session-bootstrap.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP04-mcp-serve-bootstrap-and-contracts.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP05-repository-feature-locking.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP06-audit-log-persistence-and-retention.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP07-worker-lifecycle-tools.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP08-message-log-and-event-waiting.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP09-workflow-status-and-transitions.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP10-setup-command-and-launch-hardening.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP11-agent-profiles-and-prompt-context.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP12-integration-and-acceptance-hardening.md delete mode 100644 kitty-specs/011-mcp-agent-swarm-orchestration/traceability.md delete mode 100644 kitty-specs/012-kasmos-new-command/checklists/requirements.md delete mode 100644 kitty-specs/012-kasmos-new-command/data-model.md delete mode 100644 kitty-specs/012-kasmos-new-command/meta.json delete mode 100644 kitty-specs/012-kasmos-new-command/plan.md delete mode 100644 kitty-specs/012-kasmos-new-command/quickstart.md delete mode 100644 kitty-specs/012-kasmos-new-command/research.md delete mode 100644 kitty-specs/012-kasmos-new-command/spec.md delete mode 100644 kitty-specs/012-kasmos-new-command/tasks.md delete mode 100644 kitty-specs/012-kasmos-new-command/tasks/.gitkeep delete mode 100644 kitty-specs/012-kasmos-new-command/tasks/README.md delete mode 100644 kitty-specs/012-kasmos-new-command/tasks/WP01-cli-preflight-prompt-launch.md delete mode 100644 kitty-specs/012-kasmos-new-command/tasks/WP02-unit-tests.md delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/meta.json delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/plan.md delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/spec.md delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/tasks/WP01-config-and-autodetect.md delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/tasks/WP01b-interactive-prompt-and-fixup.md delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/tasks/WP02-zjstatus-check.md delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/tasks/WP03-documentation.md delete mode 100644 kitty-specs/013-setup-plugin-path-discovery/tasks/WP04-tests.md delete mode 100644 kitty-specs/014-architecture-pivot-evaluation/plan.md delete mode 100644 kitty-specs/014-architecture-pivot-evaluation/research.md delete mode 100644 kitty-specs/014-architecture-pivot-evaluation/spec.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/project-context.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md diff --git a/design-artifacts/tui-keybinds.md b/design-artifacts/tui-keybinds.md new file mode 100644 index 0000000..efb3f4f --- /dev/null +++ b/design-artifacts/tui-keybinds.md @@ -0,0 +1,417 @@ +# kasmos TUI — Keybinding Specification + +> Complete keybinding map, context-dependent activation rules, and implementable +> Go code for `keys.go`. Integrates with `bubbles/help` for short/full help display. + +## Keybind Map + +### Global Keys (always active, any context) + +| Key | Action | Notes | +|----------|------------------|------------------------------------------| +| `ctrl+c` | Force quit | Immediate exit, no confirmation | +| `?` | Toggle help | Full help overlay on/off | +| `tab` | Next panel | Cycles focus through visible panels | +| `S-tab` | Previous panel | Reverse cycle | + +### Dashboard — Table Focused + +| Key | Action | Enabled When | +|-------|-----------------|---------------------------------------| +| `j/↓` | Move down | Always | +| `k/↑` | Move up | Always | +| `s` | Spawn worker | Always | +| `x` | Kill worker | Selected worker is running | +| `c` | Continue session | Selected worker is exited/done | +| `r` | Restart worker | Selected worker is failed/killed | +| `g` | Generate prompt | Task source loaded, task selected | +| `a` | Analyze failure | Selected worker is failed | +| `f` | Fullscreen output | Any worker selected | +| `b` | Batch spawn | Task source loaded, tasks available | +| `enter`| View output | Any worker selected (→ fullscreen) | +| `q` | Quit | Always (confirms if workers running) | + +### Dashboard — Viewport Focused + +| Key | Action | Notes | +|-------|------------------|----------------------------------------| +| `j/↓` | Scroll down | One line | +| `k/↑` | Scroll up | One line | +| `d` | Half page down | Standard vim motion | +| `u` | Half page up | Standard vim motion | +| `G` | Jump to bottom | Re-enables auto-follow | +| `g` | Jump to top | | +| `/` | Search output | Opens search input overlay on viewport | +| `f` | Fullscreen | Expand viewport to full terminal | +| `q` | Quit | | + +### Dashboard — Task List Focused (wide mode) + +| Key | Action | Notes | +|--------|------------------|---------------------------------------| +| `j/↓` | Move down | Next task in list | +| `k/↑` | Move up | Previous task | +| `/` | Filter tasks | Activates bubbles/list built-in filter| +| `enter`| Assign + spawn | Opens spawn dialog pre-filled | +| `s` | Spawn from task | Same as enter | +| `b` | Batch spawn | Multi-select then spawn all | +| `q` | Quit | | + +### Full-Screen Output View + +| Key | Action | Notes | +|--------|-------------------|--------------------------------------| +| `esc` | Back to dashboard | Returns to split view | +| `j/↓` | Scroll down | | +| `k/↑` | Scroll up | | +| `d` | Half page down | | +| `u` | Half page up | | +| `G` | Jump to bottom | | +| `g` | Jump to top | | +| `/` | Search | | +| `c` | Continue session | If worker is exited/done | +| `r` | Restart | If worker is failed/killed | + +### Overlay Active (spawn, continue, help, quit) + +| Key | Action | Notes | +|--------|-------------------|--------------------------------------| +| `esc` | Dismiss overlay | Returns to previous view | +| `ctrl+c` | Force quit | Still works through overlays | + +Form-specific keys (spawn/continue dialogs) are handled by huh: +- `tab` / `S-tab` → next/prev form field +- `enter` → confirm/select +- Arrow keys → navigate within select fields +- Text input → standard editing keys + +--- + +## Implementation: keys.go + +```go +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + // Navigation + Up key.Binding + Down key.Binding + NextPanel key.Binding + PrevPanel key.Binding + + // Worker actions + Spawn key.Binding + Kill key.Binding + Continue key.Binding + Restart key.Binding + Batch key.Binding + + // Output + Fullscreen key.Binding + ScrollDown key.Binding + ScrollUp key.Binding + HalfDown key.Binding + HalfUp key.Binding + GotoBottom key.Binding + GotoTop key.Binding + Search key.Binding + + // AI helpers + GenPrompt key.Binding + Analyze key.Binding + + // Task list + Filter key.Binding + Select key.Binding + + // General + Help key.Binding + Quit key.Binding + ForceQuit key.Binding + Back key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("↓/j", "down"), + ), + NextPanel: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next panel"), + ), + PrevPanel: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("S-tab", "prev panel"), + ), + Spawn: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "spawn worker"), + ), + Kill: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "kill worker"), + ), + Continue: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "continue session"), + ), + Restart: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "restart worker"), + ), + Batch: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "batch spawn"), + ), + Fullscreen: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "fullscreen"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("↓/j", "scroll down"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("↑/k", "scroll up"), + ), + HalfDown: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ), + HalfUp: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ), + GotoBottom: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "bottom"), + ), + GotoTop: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "top"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), + GenPrompt: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "gen prompt (AI)"), + ), + Analyze: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "analyze failure (AI)"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "quit"), + ), + ForceQuit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "force quit"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + } +} +``` + +### help.KeyMap Implementation + +The help bubble reads from these methods to render short and full help views. + +```go +// ShortHelp — shown in the help bar at the bottom of the dashboard +// Returns context-dependent bindings based on current focus and state +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Spawn, k.Kill, k.Continue, k.Restart, + k.GenPrompt, k.Analyze, k.Fullscreen, + k.NextPanel, k.Help, k.Quit, + } +} + +// FullHelp — shown in the ? overlay, grouped into columns +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + // Column 1: Navigation + {k.Up, k.Down, k.NextPanel, k.PrevPanel, k.Select, k.Back}, + // Column 2: Worker actions + {k.Spawn, k.Kill, k.Continue, k.Restart, k.Batch, k.GenPrompt, k.Analyze}, + // Column 3: Output + {k.Fullscreen, k.ScrollDown, k.ScrollUp, k.GotoBottom, k.GotoTop, k.Search}, + // Column 4: General + Tasks + {k.Help, k.Quit, k.ForceQuit, k.Filter}, + } +} +``` + +--- + +## Context-Dependent Key Activation + +Keys are enabled/disabled based on current state. Disabled keys don't appear in help and don't match in `key.Matches()`. + +```go +func (m *Model) updateKeyStates() { + hasWorkers := len(m.workers) > 0 + selected := m.selectedWorker() // may be nil + + // Worker action keys — depend on selected worker's state + m.keys.Kill.SetEnabled(selected != nil && selected.State == StateRunning) + m.keys.Continue.SetEnabled(selected != nil && + (selected.State == StateExited || selected.State == StateFailed)) + m.keys.Restart.SetEnabled(selected != nil && + (selected.State == StateFailed || selected.State == StateKilled)) + m.keys.Analyze.SetEnabled(selected != nil && selected.State == StateFailed) + m.keys.Fullscreen.SetEnabled(hasWorkers && selected != nil) + + // AI helpers — depend on task source + m.keys.GenPrompt.SetEnabled(m.hasTaskSource()) + m.keys.Batch.SetEnabled(m.hasTaskSource() && m.hasUnassignedTasks()) + + // Task keys — only in wide mode with task source + hasTaskPanel := m.hasTaskSource() && m.width >= 160 + m.keys.Filter.SetEnabled(hasTaskPanel && m.focused == panelTasks) +} +``` + +Call `updateKeyStates()` at the end of every `Update()` cycle, after state changes. + +--- + +## Key Routing in Update + +```go +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // ── Phase 0: Overlay intercepts ALL input ── + if m.showOverlay() { + return m.updateOverlay(msg) + } + + switch msg := msg.(type) { + case tea.KeyMsg: + // ── Phase 1: Global keys (always handled) ── + switch { + case key.Matches(msg, m.keys.ForceQuit): + return m, tea.Quit + case key.Matches(msg, m.keys.Help): + m.showHelp = !m.showHelp + return m, nil + case key.Matches(msg, m.keys.NextPanel): + m.cyclePanel(1) + return m, nil + case key.Matches(msg, m.keys.PrevPanel): + m.cyclePanel(-1) + return m, nil + case key.Matches(msg, m.keys.Quit): + if m.hasRunningWorkers() { + m.showQuitConfirm = true + return m, nil + } + return m, m.gracefulShutdown() + } + + // ── Phase 2: Full-screen mode keys ── + if m.fullScreen { + return m.updateFullScreen(msg) + } + + // ── Phase 3: Panel-specific keys ── + switch m.focused { + case panelTable: + return m.updateTableKeys(msg) + case panelViewport: + return m.updateViewportKeys(msg) + case panelTasks: + return m.updateTaskKeys(msg) + } + + // ── Non-key messages: always process ── + case tea.WindowSizeMsg: + // ... resize handling + case workerExitedMsg: + // ... worker state update + case spinner.TickMsg: + // ... spinner animation + case tickMsg: + // ... periodic refresh + } + + // Delegate to focused sub-model for unhandled messages + return m.updateFocused(msg) +} +``` + +--- + +## Key Conflict Resolution + +Some keys have different meanings depending on focused panel: + +| Key | Table Focus | Viewport Focus | Task Focus | +|-----|------------------|----------------|----------------| +| `g` | Gen prompt (AI) | Jump to top | Gen prompt | +| `/` | (unused) | Search output | Filter tasks | +| `j` | Table row down | Scroll down | List item down | +| `k` | Table row up | Scroll up | List item up | + +The `g` key conflict (gen prompt vs goto top) is resolved by panel context: `g` means "gen prompt" when table is focused, "goto top" when viewport is focused. In the full help overlay, both are listed in their respective columns. + +For the `g` dual binding, use separate `key.Binding` objects and enable only the appropriate one: + +```go +// In updateKeyStates(): +if m.focused == panelViewport || m.fullScreen { + m.keys.GotoTop.SetEnabled(true) + m.keys.GenPrompt.SetEnabled(false) +} else { + m.keys.GotoTop.SetEnabled(false) + m.keys.GenPrompt.SetEnabled(m.hasTaskSource()) +} +``` + +--- + +## Message Types for Key Actions + +Each key action produces a Msg or triggers a Cmd: + +```go +// messages.go — key-triggered messages + +type spawnRequestedMsg struct{} // s key → open spawn dialog +type killRequestedMsg struct{ workerID string } // x key → kill worker +type continueRequestedMsg struct{ workerID string } // c key → open continue dialog +type restartRequestedMsg struct{ workerID string } // r key → open restart dialog +type batchSpawnRequestedMsg struct{} // b key → open batch dialog +type analyzeRequestedMsg struct{ workerID string } // a key → start AI analysis +type genPromptRequestedMsg struct{ taskID string } // g key → start AI prompt gen +type fullscreenToggledMsg struct{} // f key → toggle fullscreen +type quitRequestedMsg struct{} // q key → quit (or confirm) +``` + +These messages flow through Update and trigger state changes (opening dialogs, spawning commands, etc.) rather than performing side effects directly in the key handler. diff --git a/design-artifacts/tui-layout-spec.md b/design-artifacts/tui-layout-spec.md new file mode 100644 index 0000000..224a38e --- /dev/null +++ b/design-artifacts/tui-layout-spec.md @@ -0,0 +1,535 @@ +# kasmos TUI — Layout Specification + +> Responsive layout system, breakpoint rules, dimension arithmetic, and panel +> composition patterns. All measurements are in terminal cells (characters). + +## Layout Architecture + +The TUI uses a fixed vertical structure with a responsive horizontal content area: + +``` +┌─────────────────────────────────────────────────┐ +│ Header (2 lines: title + optional subtitle) │ fixed +├─────────────────────────────────────────────────┤ +│ │ +│ Content Area (responsive, fills remaining) │ flexible +│ │ +├─────────────────────────────────────────────────┤ +│ Status Bar (1 line) │ fixed +├─────────────────────────────────────────────────┤ +│ Help Bar (1 line) │ fixed +└─────────────────────────────────────────────────┘ +``` + +### Vertical Dimension Math + +```go +const ( + headerLines = 2 // gradient title + blank line (3 if subtitle present) + statusBarLines = 1 // purple background bar + helpBarLines = 1 // keybind hints + chromeTotal = 4 // headerLines + statusBarLines + helpBarLines +) + +// Available content height +contentHeight := m.height - chromeTotal + +// When subtitle is present (spec-kitty/GSD mode): +// headerLines = 3 (title + source line + blank) +// chromeTotal = 5 +``` + +--- + +## Responsive Breakpoints + +Four layout modes based on terminal width. Height minimum is 24 for all modes. + +### Breakpoint Summary + +| Width | Mode | Columns | Content Layout | +|---------------|----------------|---------|-------------------------------------| +| < 80 | Too Small | — | Centered "resize" message | +| 80–99 | Narrow | 1 | Stacked: table above viewport | +| 100–159 | Standard | 2 | Split: table (40%) + viewport (60%) | +| ≥ 160 | Wide | 3 | Three-col: tasks + table + viewport | + +### Too Small (< 80 cols or < 24 rows) + +```go +if m.width < 80 || m.height < 24 { + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + warnStyle.Render("Terminal too small 🫧\nMinimum: 80×24\nCurrent: "+ + fmt.Sprintf("%d×%d", m.width, m.height)), + ) +} +``` + +No panels rendered. No keybinds active except `ctrl+c` and `q`. + +### Narrow Mode (80–99 cols) + +``` +┌──────────────────────────────────────────────┐ +│ Header │ +├──────────────────────────────────────────────┤ +│ ╭─ Workers ────────────────────────────────╮ │ +│ │ table (full width, ~45% content height) │ │ +│ ╰──────────────────────────────────────────╯ │ +│ ╭─ Output ─────────────────────────────────╮ │ +│ │ viewport (full width, ~55% content ht) │ │ +│ ╰──────────────────────────────────────────╯ │ +├──────────────────────────────────────────────┤ +│ Status Bar │ +│ Help Bar (compact) │ +└──────────────────────────────────────────────┘ +``` + +**Dimension math:** + +```go +// Narrow mode: stacked layout +panelWidth := m.width // full width + +// Table gets 45% of content, viewport gets 55% +tableOuterHeight := int(float64(contentHeight) * 0.45) +viewportOuterHeight := contentHeight - tableOuterHeight + +// Inner dimensions (subtract border + padding) +// RoundedBorder: 2 vertical (top + bottom), Padding(0,1): 0 vertical +borderV := 2 +paddingV := 0 +tableInnerHeight := tableOuterHeight - borderV - paddingV +viewportInnerHeight := viewportOuterHeight - borderV - paddingV + +borderH := 2 +paddingH := 2 // Padding(0,1) = 1 each side +tableInnerWidth := panelWidth - borderH - paddingH +viewportInnerWidth := panelWidth - borderH - paddingH +``` + +**Table columns (narrow):** + +| Column | Width | Visible | +|----------|-------|---------| +| ID | 8 | ✓ | +| Status | 13 | ✓ | +| Role | 10 | ✓ | +| Duration | 8 | ✓ | +| Task | — | ✗ hidden | + +### Standard Mode (100–159 cols) + +``` +┌────────────────────────────────────────────────────────────────────────────────┐ +│ Header │ +├────────────────────────────────────────────────────────────────────────────────┤ +│ ╭─ Workers ─────────────────────╮ ╭─ Output ─────────────────────────────────╮ │ +│ │ table (40% width) │ │ viewport (60% width) │ │ +│ │ full content height │ │ full content height │ │ +│ ╰───────────────────────────────╯ ╰─────────────────────────────────────────╯ │ +├────────────────────────────────────────────────────────────────────────────────┤ +│ Status Bar │ +│ Help Bar │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +**Dimension math:** + +```go +// Standard mode: side-by-side +leftWidthRatio := 0.40 +rightWidthRatio := 0.60 + +// Gap between panels: 1 space +gap := 1 + +leftOuterWidth := int(float64(m.width) * leftWidthRatio) +rightOuterWidth := m.width - leftOuterWidth - gap + +panelHeight := contentHeight // both panels same height + +// Inner dimensions +leftInnerWidth := leftOuterWidth - borderH - paddingH +leftInnerHeight := panelHeight - borderV - paddingV +rightInnerWidth := rightOuterWidth - borderH - paddingH +rightInnerHeight := panelHeight - borderV - paddingV +``` + +**Table columns (standard):** + +| Column | Width | Visible | +|----------|---------|-------------| +| ID | 8 | ✓ | +| Status | 13 | ✓ | +| Role | 10 | ✓ | +| Duration | 8 | ✓ | +| Task | remaining | ✓ (truncated) | + +### Wide Mode (≥ 160 cols) + +Only activates when a task source is loaded (spec-kitty or GSD). Without a task source, uses Standard mode even at wide terminals. + +``` +┌────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Header + subtitle │ +├────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ ╭─ Tasks ──────────╮ ╭─ Workers ──────────────────────╮ ╭─ Output ────────────────────────────────────────╮│ +│ │ list (25%) │ │ table (35%) │ │ viewport (40%) ││ +│ │ full content ht │ │ full content ht │ │ full content ht ││ +│ ╰──────────────────╯ ╰────────────────────────────────╯ ╰────────────────────────────────────────────────╯│ +├────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ Status Bar │ +│ Help Bar │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +**Dimension math:** + +```go +// Wide mode: three columns +gap := 1 // between each panel + +tasksWidthRatio := 0.25 +workersWidthRatio := 0.35 +outputWidthRatio := 0.40 + +totalGaps := gap * 2 // two gaps between three panels + +availableWidth := m.width - totalGaps +tasksOuterWidth := int(float64(availableWidth) * tasksWidthRatio) +workersOuterWidth := int(float64(availableWidth) * workersWidthRatio) +outputOuterWidth := availableWidth - tasksOuterWidth - workersOuterWidth + +panelHeight := contentHeight +``` + +**Table columns (wide):** + +| Column | Width | Visible | +|----------|----------|---------| +| ID | 8 | ✓ | +| Status | 13 | ✓ | +| Role | 10 | ✓ | +| Duration | 8 | ✓ | +| Task | remaining | ✓ | + +--- + +## Panel Specifications + +### Header + +```go +func (m Model) renderHeader() string { + // Line 1: gradient title + version (right-aligned) + title := gradientRender("kasmos") // see styles doc for gradient + version := faintStyle.Render("v0.1.0") + titleLine := title + " " + dimStyle.Render("agent orchestrator") + + strings.Repeat(" ", m.width-lipgloss.Width(title)-lipgloss.Width(version)-20) + + version + + // Line 2: task source subtitle (conditional) + var subtitle string + if m.taskSource != nil { + subtitle = "\n" + faintStyle.Render(fmt.Sprintf(" %s: %s", + m.taskSource.Type(), m.taskSource.Path())) + } + + return titleLine + subtitle + "\n" +} +``` + +**Height:** 2 lines (no task source) or 3 lines (with task source). +**Width:** Full terminal width. + +### Worker Table Panel + +``` +╭─ Workers ──────────────────────────────────╮ +│ │ ← Padding(0, 1) +│ ID Status Role Duration │ +│ ──────────────────────────────────────── │ ← header border (purple, bottom only) +│ w-001 ✓ done coder 4m 12s │ +│ >w-002 ⣾ running reviewer 1m 48s │ ← selected row (purple bg) +│ w-003 ⟳ running planner 0m 34s │ +│ │ +╰────────────────────────────────────────────╯ +``` + +**Border:** RoundedBorder. Purple when focused, darkGray when unfocused. +**Title:** `" Workers "` or `" Workers (N) "` in border top — rendered via panel title pattern (not built-in bubbles/table title). +**Padding:** `Padding(0, 1)` — 1 cell horizontal, 0 vertical. +**Table height:** Set via `table.WithHeight(innerHeight)`. +**Table width:** Set via `t.SetWidth(innerWidth)` or column sum. + +**Column width allocation:** + +```go +func (m Model) workerTableColumns() []table.Column { + available := innerWidth + fixed := 0 + + cols := []table.Column{ + {Title: "ID", Width: 10}, // "w-001" or "├─w-005" + {Title: "Status", Width: 14}, // "⣾ running" or "✗ failed(1)" + {Title: "Role", Width: 10}, // role badge + {Title: "Duration", Width: 9}, // "4m 12s" or " — " + } + for _, c := range cols { + fixed += c.Width + } + + // Task column gets remaining space (hidden if < threshold) + remaining := available - fixed - len(cols) // account for cell padding + if remaining >= 15 && m.width >= 100 { + cols = append(cols, table.Column{Title: "Task", Width: remaining}) + } + + return cols +} +``` + +### Output Viewport Panel + +``` +╭─ Output: w-002 reviewer ──────────────────╮ +│ │ +│ [14:32:01] Reviewing changes in auth/... │ +│ [14:32:03] Found 3 files modified: │ +│ • internal/auth/middleware.go │ +│ • internal/auth/handler.go │ +│ │ +╰────────────────────────────────────────────╯ +``` + +**Border:** RoundedBorder. Purple when focused, darkGray when unfocused. +**Title:** Dynamic — `" Output: {id} {role} "` or `" Output "` when no worker selected, or `" Analysis: {id} {role} "` in analysis mode. +**Padding:** `Padding(0, 1)`. +**Content:** Set via `viewport.SetContent(string)`. +**Auto-follow:** Track `viewport.AtBottom()` before setting content. If true, call `GotoBottom()` after. + +### Task List Panel (Wide mode only) + +``` +╭─ Tasks (6) ───────────────╮ +│ │ +│ WP-001 Auth middleware │ ← list item: Title() +│ JWT RS256 validation │ ← list item: Description() +│ deps: none │ ← custom delegate extra line +│ ✓ done │ ← status badge +│ │ +│ >WP-002 Login endpoint │ ← selected (purple highlight) +│ POST /api/v1/login │ +│ deps: WP-001 │ +│ ○ unassigned │ +│ │ +╰───────────────────────────╯ +``` + +**Component:** `bubbles/list` with custom `list.ItemDelegate`. +**Border:** RoundedBorder. Purple when focused, darkGray when unfocused. +**Title:** `" Tasks (N) "` showing count. +**Item height:** 4 lines per item (title, description, deps, status) + 1 blank separator. +**Filtering:** Built-in `/` search using `FilterValue()` which returns task title. + +### Status Bar + +``` + ⣾ 2 running ✓ 1 done ✗ 1 failed ☠ 1 killed ○ 1 pending mode: ad-hoc scroll: 100% +``` + +**Style:** Purple background, cream foreground, full terminal width. +**Content:** Left-aligned worker counts, right-aligned mode + scroll percentage. +**Padding:** `Padding(0, 1)`. + +```go +func (m Model) renderStatusBar() string { + // Left: worker state counts + counts := m.workerCounts() + left := fmt.Sprintf(" ⣾ %d running ✓ %d done ✗ %d failed ☠ %d killed ○ %d pending", + counts.running, counts.done, counts.failed, counts.killed, counts.pending) + + // Right: mode + scroll + scrollStr := "—" + if m.focused == panelViewport && m.viewport.TotalLineCount() > 0 { + scrollStr = fmt.Sprintf("%.0f%%", m.viewport.ScrollPercent()*100) + } + right := fmt.Sprintf("mode: %s scroll: %s ", m.modeName(), scrollStr) + + // Fill gap + gap := strings.Repeat(" ", max(0, m.width-lipgloss.Width(left)-lipgloss.Width(right))) + + return statusBarStyle.Width(m.width).Render(left + gap + right) +} +``` + +### Help Bar + +**Component:** `bubbles/help` in short mode (`ShowAll = false`). +**Style:** Keys in purple bold, descriptions in midGray, `·` separators in darkGray. + +**Context-dependent content:** +- Dashboard focused on table: `s spawn · x kill · c continue · r restart · g gen prompt · a analyze · f fullscreen · tab panel · ? help · q quit` +- Dashboard focused on viewport: `f fullscreen · j/k scroll · / search · tab panel · ? help · q quit` +- Full-screen output: `esc back · c continue · r restart · j/k scroll · G bottom · g top · / search` +- Empty dashboard: `s spawn · ? help · q quit` +- Analysis active: `r restart with suggestion · c continue · esc dismiss · ? help · q quit` + +```go +// Disable keys based on state +m.keys.Kill.SetEnabled(m.hasRunningSelected()) +m.keys.Continue.SetEnabled(m.hasCompletedSelected()) +m.keys.Restart.SetEnabled(m.hasFailedOrKilledSelected()) +m.keys.Analyze.SetEnabled(m.hasFailedSelected()) +m.keys.GenPrompt.SetEnabled(m.hasTaskSelected()) +``` + +--- + +## Overlay Layout + +All overlays (spawn dialog, continue dialog, help, quit confirmation) use the same centering pattern: + +```go +func (m Model) renderOverlay(content string, borderColor lipgloss.TerminalColor, borderType lipgloss.Border) string { + dialog := lipgloss.NewStyle(). + Border(borderType). + BorderForeground(borderColor). + Padding(1, 2). + Render(content) + + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + dialog, + lipgloss.WithWhitespaceChars("░"), + lipgloss.WithWhitespaceForeground(darkGray), + ) +} +``` + +**Overlay widths:** + +| Overlay | Width | Border | Border Color | +|--------------------|-------|-----------------|--------------| +| Spawn dialog | 70 | RoundedBorder | hotPink | +| Continue dialog | 65 | RoundedBorder | hotPink | +| Help overlay | 78 | RoundedBorder | hotPink | +| Quit confirmation | 36 | ThickBorder | orange | + +--- + +## Focus System + +### Panel Enumeration + +```go +type panel int + +const ( + panelTable panel = iota // Worker table + panelViewport // Output viewport + panelTasks // Task list (only in wide mode with task source) +) +``` + +### Focus Cycling + +```go +func (m Model) cyclablePanels() []panel { + panels := []panel{panelTable, panelViewport} + if m.hasTaskSource() && m.width >= 160 { + panels = []panel{panelTasks, panelTable, panelViewport} + } + return panels +} + +// Tab: next panel +// Shift+Tab: previous panel +``` + +### Visual Focus Indicator + +Focused panel gets purple RoundedBorder. All unfocused panels get darkGray RoundedBorder. The border color is the ONLY focus indicator — no other visual change. + +### Focus Routing + +Only the focused panel receives `tea.KeyMsg` for navigation (j/k/enter/etc). Global keys (?, q, ctrl+c, tab) are handled before panel routing. + +When an overlay is visible (spawn dialog, help, quit confirm), ALL key input goes to the overlay. No panel receives input. + +--- + +## Full-Screen Toggle + +Press `f` to expand the output viewport to full screen. Press `esc` to return. + +```go +// Full-screen dimensions +vpWidth := m.width - borderH - paddingH // or m.width - 4 for some breathing room +vpHeight := contentHeight - borderV - paddingV +``` + +Full-screen viewport gets purple RoundedBorder (always focused). The table and task panels are not rendered. + +--- + +## Resize Handling + +```go +case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + if !m.ready { + m.ready = true + } + + // Recalculate all panel dimensions + m.recalculateLayout() + return m, nil +``` + +`recalculateLayout()` recomputes all panel dimensions based on the current breakpoint and sets them on sub-models: + +```go +func (m *Model) recalculateLayout() { + contentHeight := m.height - m.chromeHeight() + + switch { + case m.width >= 160 && m.hasTaskSource(): + m.layoutMode = layoutWide + // ... set 3-col dimensions + case m.width >= 100: + m.layoutMode = layoutStandard + // ... set 2-col dimensions + case m.width >= 80: + m.layoutMode = layoutNarrow + // ... set stacked dimensions + default: + m.layoutMode = layoutTooSmall + } + + // Apply to sub-models + m.table.SetWidth(m.tableInnerWidth) + m.table.SetHeight(m.tableInnerHeight) + m.table.SetColumns(m.workerTableColumns()) + m.viewport.Width = m.viewportInnerWidth + m.viewport.Height = m.viewportInnerHeight + if m.hasTaskSource() { + m.taskList.SetSize(m.tasksInnerWidth, m.tasksInnerHeight) + } +} +``` + +### Minimum Viable Dimensions + +| Component | Min Width | Min Height | +|----------------|-----------|------------| +| Worker table | 45 | 6 | +| Output viewport| 30 | 5 | +| Task list | 25 | 8 | +| Spawn dialog | 70 | 24 | +| Help overlay | 78 | 22 | +| Quit dialog | 36 | 11 | diff --git a/design-artifacts/tui-mockups.md b/design-artifacts/tui-mockups.md new file mode 100644 index 0000000..996ab5b --- /dev/null +++ b/design-artifacts/tui-mockups.md @@ -0,0 +1,503 @@ +# kasmos TUI — View Mockups + +> ASCII-art reference for every TUI view state. Each mockup shows exact border +> characters (lipgloss.RoundedBorder ╭╮╰╯, ThickBorder ┏┓┗┛), content placement, +> and component boundaries. Implementation should match these layouts character-for-character +> at the specified terminal widths. + +## Table of Contents + +- [V1: Main Dashboard (split, ≥120 cols)](#v1-main-dashboard) +- [V2: Spawn Worker Dialog (overlay)](#v2-spawn-worker-dialog) +- [V3: Full-Screen Output Viewport](#v3-full-screen-output) +- [V4: Task Source Panel (3-col, ≥160 cols)](#v4-task-source-panel) +- [V5: Continue Session Dialog (overlay)](#v5-continue-session-dialog) +- [V6: Help Overlay](#v6-help-overlay) +- [V7: Worker Continuation Chains](#v7-worker-chains) +- [V8: Narrow/Stacked Layout (<100 cols)](#v8-narrow-layout) +- [V9: AI Failure Analysis](#v9-failure-analysis) +- [V10: Daemon Mode Output](#v10-daemon-mode) +- [V11: Empty Dashboard (fresh launch)](#v11-empty-dashboard) +- [V12: Quit Confirmation Dialog](#v12-quit-confirmation) + +--- + +## V1: Main Dashboard + +**Trigger:** Default view after launch with workers present. +**Layout:** 2-column split. Left ~40%, Right ~60%. +**Components:** header (custom), table (bubbles/table, focused), viewport (bubbles/viewport, unfocused), status bar (lipgloss bg), help bar (bubbles/help short mode). +**Terminal:** 130×30 shown. Scales to ≥120 cols. + +``` + kasmos agent orchestrator v0.1.0 + +╭─ Workers ───────────────────────────────────────╮ ╭─ Output: w-002 reviewer ──────────────────────────────────────╮ +│ │ │ │ +│ ID Status Role Duration Task │ │ ──────────────────────────────────────────────────────────── │ +│ ────────────────────────────────────────────── │ │ [reviewer] session: ses_a8f3k2 │ +│ w-001 ✓ done coder 4m 12s Auth… │ │ [14:32:01] Reviewing changes in auth/... │ +│ >w-002 ⣾ running reviewer 1m 48s Revi… │ │ [14:32:03] Found 3 files modified: │ +│ w-003 ⟳ running planner 0m 34s Plan… │ │ • internal/auth/middleware.go │ +│ w-004 ✗ failed(1) coder 2m 01s Fix … │ │ • internal/auth/handler.go │ +│ w-005 ○ pending release — Tag … │ │ • internal/auth/token.go │ +│ w-006 ☠ killed coder 6m 44s Refa… │ │ [14:32:05] middleware.go: LGTM, clean implementation │ +│ │ │ [14:32:07] handler.go: Suggestion: Extract token │ +│ │ │ validation into a separate function for reuse. │ +│ │ │ [14:32:09] token.go: Suggestion: Add expiry check │ +│ │ │ before signature verification to fail fast. │ +│ │ │ [14:32:11] Running test suite... │ +│ │ │ [14:32:14] ✓ All 42 tests passed │ +│ │ │ [14:32:15] Verdict: verified with suggestions │ +│ │ │ │ +╰─────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────╯ + ⣾ 2 running ✓ 1 done ✗ 1 failed ☠ 1 killed ○ 1 pending mode: ad-hoc scroll: 100% + s spawn · x kill · c continue · r restart · g gen prompt · a analyze · f fullscreen · tab panel · ? help · q quit +``` + +**Key details:** +- `>` prefix or highlighted row = `table.Selected` row (purple bg + cream fg) +- `⣾` in status column = `spinner.View()` inline for running workers +- Viewport title includes worker ID + role of selected worker +- Status bar: full-width purple background, cream text +- Help bar: purple bold keys, gray descriptions, `·` separators +- Table header: hot pink bold text, purple bottom border via `s.Header.BorderBottom(true)` + +--- + +## V2: Spawn Worker Dialog + +**Trigger:** Press `s` from dashboard. +**Layout:** Centered overlay via `lipgloss.Place()`. Fills background with `░` in dark gray. +**Components:** huh.Form with ThemeCharm(). Fields: Select (role), Text (prompt), Input (files), Confirm. +**Dialog:** 70 chars wide. Hot pink RoundedBorder. + +``` +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░░░╭─ Spawn Worker ──────────────────────────────────────────────────╮░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ Agent Role │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ○ planner Research and planning, read-only filesystem │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ● coder Implementation, full tool access │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ○ reviewer Code review, read-only + test execution │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ○ release Merge, finalization, cleanup operations │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ Prompt │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ╭──────────────────────────────────────────────────────────╮ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │ Implement the auth middleware as described in │ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │ WP-003. Use JWT with RS256 signing. Ensure all │ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │ endpoints in /api/v1/ are protected.▎ │ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │ │ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ╰──────────────────────────────────────────────────────────╯ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ Attach Files (optional) │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ╭──────────────────────────────────────────────────────────╮ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │ path/to/file.go, another/file.go │ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ ╰──────────────────────────────────────────────────────────╯ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ [ Spawn! ] [ Cancel ] │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░ +░░░░░░░░░░░╰────────────────────────────────────────────────────────────────╯░░░░░░░░░░░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +``` + +**Key details:** +- `●` = selected radio (purple), `○` = unselected (midGray) +- Prompt textarea: purple RoundedBorder when focused, darkGray when blurred +- `▎` = cursor in textarea (hot pink) +- `[ Spawn! ]` = active button (purple bg), `[ Cancel ]` = inactive (darkGray bg) +- File input: single-line textinput with comma-separated paths +- Esc dismisses without spawning + +--- + +## V3: Full-Screen Output + +**Trigger:** Press `f` on dashboard, or `enter` on a worker. +**Layout:** Viewport fills full terminal minus header (2 lines), status bar (1 line), help bar (1 line). +**Components:** viewport (bubbles/viewport, focused), status bar, help bar. + +``` + kasmos agent orchestrator v0.1.0 + +╭─ Output: w-001 coder ─ Implement auth middleware ─────────────────────────────────────────────────────────────────╮ +│ │ +│ [14:28:01] [coder] Starting task: Implement auth middleware │ +│ [14:28:02] Reading project structure... │ +│ [14:28:04] Analyzing existing auth patterns in internal/... │ +│ [14:28:06] Creating internal/auth/middleware.go │ +│ [14:28:08] Writing JWT validation middleware with RS256... │ +│ [14:28:15] Creating internal/auth/handler.go │ +│ [14:28:22] Creating internal/auth/token.go │ +│ [14:28:30] Modifying cmd/server/main.go — adding auth middleware to router │ +│ [14:28:35] Running go build ./... │ +│ [14:28:38] ✓ Build succeeded │ +│ [14:28:39] Running go test ./internal/auth/... │ +│ [14:28:44] PASS TestMiddleware_ValidToken │ +│ [14:28:44] PASS TestMiddleware_ExpiredToken │ +│ [14:28:44] PASS TestMiddleware_InvalidSignature │ +│ [14:28:44] PASS TestMiddleware_MissingHeader │ +│ [14:28:45] PASS TestHandler_Login │ +│ [14:28:45] PASS TestHandler_Refresh │ +│ [14:28:45] PASS TestToken_Generate │ +│ [14:28:45] PASS TestToken_Verify │ +│ [14:28:46] ✓ All 8 tests passed (7.2s) │ +│ [14:28:47] Done. Auth middleware implemented and tested. │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + w-001 coder ✓ done exit(0) duration: 4m 12s session: ses_j4m9x1 parent: — scroll: 100% + esc back · c continue · r restart · j/k scroll · G bottom · g top · / search +``` + +**Key details:** +- Viewport title includes: worker ID, role, task description (truncated to fit) +- Status bar includes: worker ID, role, status, exit code, duration, session ID, parent reference +- Auto-follow: if `viewport.AtBottom()` is true when new content arrives, call `GotoBottom()` +- If user scrolls up, auto-follow pauses. Status shows current scroll percentage instead of 100% +- `PASS` lines in green, `FAIL` in orange, timestamps in lightGray/faint, filenames in lightBlue + +--- + +## V4: Task Source Panel + +**Trigger:** `kasmos kitty-specs/015-auth-overhaul/` or `kasmos tasks.md` +**Layout:** 3-column at ≥160 cols. Tasks ~25%, Workers ~35%, Output ~40%. +**Components:** list (bubbles/list, filterable), table (bubbles/table), viewport (bubbles/viewport). + +``` + kasmos agent orchestrator v0.1.0 + spec-kitty: kitty-specs/015-auth-overhaul/plan.md + +╭─ Tasks (6) ──────────────────╮ ╭─ Workers ─────────────────────────────────╮ ╭─ Output ──────────────────────────────────────╮ +│ │ │ │ │ │ +│ WP-001 Auth middleware │ │ ID Status Role Duration │ │ Select a worker to view output │ +│ JWT RS256 validation layer │ │ ───────────────────────────────────── │ │ │ +│ deps: none │ │ w-001 ✓ done coder 4m 12s │ │ │ +│ ✓ done │ │ w-002 ⣾ running reviewer 1m 48s │ │ │ +│ │ │ w-003 ⟳ running planner 0m 34s │ │ │ +│ >WP-002 Login endpoint │ │ │ │ │ +│ POST /api/v1/login handler │ │ │ │ │ +│ deps: WP-001 │ │ │ │ │ +│ ○ unassigned │ │ │ │ │ +│ │ │ │ │ │ +│ WP-003 Token refresh │ │ │ │ │ +│ Refresh token rotation │ │ │ │ │ +│ deps: WP-001, WP-002 │ │ │ │ │ +│ ○ blocked (WP-002) │ │ │ │ │ +│ │ │ │ │ │ +│ WP-004 RBAC middleware │ │ │ │ │ +│ Role-based access control │ │ │ │ │ +│ deps: WP-001 │ │ │ │ │ +│ ○ unassigned │ │ │ │ │ +│ │ │ │ │ │ +╰──────────────────────────────╯ ╰───────────────────────────────────────────╯ ╰───────────────────────────────────────────────╯ + tasks: 1 done · 1 in-progress · 4 pending workers: 2 running · 1 done mode: spec-kitty scroll: — + s spawn from task · enter assign role · / filter tasks · tab switch panel · b batch spawn · ? help · q quit +``` + +**Key details:** +- Task list uses `bubbles/list` with custom delegate rendering multi-line items +- Task status badges: ✓ done (green bg), ⟳ in-progress (purple bg), ○ unassigned (midGray bg), ⊘ blocked (orange text) +- `>` = selected task in list (purple highlight) +- Pressing `s` on a selected task opens spawn dialog pre-filled with task's suggested role and description +- Pressing `b` opens batch spawn: select multiple tasks, assign roles, spawn all +- `/` activates list's built-in filter using `FilterValue()` on task titles +- At ≥120 but <160 cols: tasks panel collapses, switch to 2-col (workers + output) with task as a toggleable sidebar + +--- + +## V5: Continue Session Dialog + +**Trigger:** Press `c` on a completed/exited worker. +**Layout:** Centered overlay, same pattern as spawn dialog. +**Components:** huh.Form with read-only parent info section + textarea for follow-up message. + +``` +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░░░░░╭─ Continue Session ─────────────────────────────────────────────╮░░░░░░ +░░░░░░░░░░░░░│ │░░░░░░ +░░░░░░░░░░░░░│ Parent Worker │░░░░░░ +░░░░░░░░░░░░░│ │░░░░░░ +░░░░░░░░░░░░░│ ID: w-002 Role: reviewer Status: ✓ done │░░░░░░ +░░░░░░░░░░░░░│ Session: ses_a8f3k2 │░░░░░░ +░░░░░░░░░░░░░│ Result: Verified with suggestions │░░░░░░ +░░░░░░░░░░░░░│ │░░░░░░ +░░░░░░░░░░░░░│ Follow-up Message │░░░░░░ +░░░░░░░░░░░░░│ │░░░░░░ +░░░░░░░░░░░░░│ ╭───────────────────────────────────────────────────────╮ │░░░░░░ +░░░░░░░░░░░░░│ │ Apply suggestions 1 and 3. Skip suggestion 2. │ │░░░░░░ +░░░░░░░░░░░░░│ │ For suggestion 1, use a helper function rather │ │░░░░░░ +░░░░░░░░░░░░░│ │ than inline logic.▎ │ │░░░░░░ +░░░░░░░░░░░░░│ │ │ │░░░░░░ +░░░░░░░░░░░░░│ ╰───────────────────────────────────────────────────────╯ │░░░░░░ +░░░░░░░░░░░░░│ │░░░░░░ +░░░░░░░░░░░░░│ [ Continue ] [ Cancel ] │░░░░░░ +░░░░░░░░░░░░░│ │░░░░░░ +░░░░░░░░░░░░░╰───────────────────────────────────────────────────────────────╯░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +``` + +**Key details:** +- Parent info section is read-only styled text (not form fields) +- Role badge uses same color coding as table (coder=purple, reviewer=lightBlue, planner=green, release=purple variant) +- On confirm, spawns: `opencode run --continue -s ses_a8f3k2 "Apply suggestions 1 and 3..."` +- New worker appears in table with parent reference: `├─w-007` under `w-002` + +--- + +## V6: Help Overlay + +**Trigger:** Press `?` from any view. +**Layout:** Centered overlay, same backdrop pattern. +**Components:** bubbles/help with `ShowAll = true`, wrapped in hot pink RoundedBorder. + +``` +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░╭─ Keybindings ──────────────────────────────────────────────────────╮░░░░░░ +░░░░░░░░░│ │░░░░░░ +░░░░░░░░░│ Navigation Workers Output │░░░░░░ +░░░░░░░░░│ │░░░░░░ +░░░░░░░░░│ ↑/k move up s spawn worker f fullscreen │░░░░░░ +░░░░░░░░░│ ↓/j move down x kill worker j/k scroll │░░░░░░ +░░░░░░░░░│ tab next panel c continue G bottom │░░░░░░ +░░░░░░░░░│ S-tab prev panel r restart g top │░░░░░░ +░░░░░░░░░│ enter select b batch spawn / search │░░░░░░ +░░░░░░░░░│ esc back/close g gen prompt esc back │░░░░░░ +░░░░░░░░░│ a analyze fail │░░░░░░ +░░░░░░░░░│ │░░░░░░ +░░░░░░░░░│ General Tasks │░░░░░░ +░░░░░░░░░│ │░░░░░░ +░░░░░░░░░│ ? toggle help / filter tasks │░░░░░░ +░░░░░░░░░│ q quit kasmos enter assign + spawn │░░░░░░ +░░░░░░░░░│ ctrl+c force quit │░░░░░░ +░░░░░░░░░│ │░░░░░░ +░░░░░░░░░│ press ? or esc to close │░░░░░░ +░░░░░░░░░╰───────────────────────────────────────────────────────────────────╯░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +``` + +**Key details:** +- FullHelp returns `[][]key.Binding` — each inner slice is one column +- Column headers (Navigation, Workers, etc.) are hot pink bold +- Key names are purple bold, descriptions are gray/faint +- This replaces the dashboard — full overlay. Esc returns. + +--- + +## V7: Worker Chains + +**Trigger:** When continuation workers exist, the table renders parent-child relationships. +**Layout:** Same as V1 (2-column split). The ID column width expands to accommodate tree glyphs. + +``` +╭─ Workers ───────────────────────────────────────╮ ╭─ Output: w-007 coder ← w-002 ────────────────────────────────╮ +│ │ │ │ +│ ID Status Role Duration │ │ ← continued from w-002 (reviewer) │ +│ ─────────────────────────────────────────── │ │ ──────────────────────────────────────────────────────────── │ +│ w-001 ✓ done coder 4m 12s │ │ [14:34:22] Applying suggestions from review... │ +│ w-002 ✓ done reviewer 3m 20s │ │ [14:34:24] Suggestion 1: Extracting token │ +│ ├─w-005 ✓ done coder 1m 45s │ │ validation into validateToken() helper │ +│ │ └─w-006 ✓ done reviewer 2m 10s │ │ [14:34:28] Suggestion 3: Adding expiry check │ +│ └──w-007 ⣾ running coder 0m 52s │ │ before signature verification... │ +│ w-003 ⟳ running planner 5m 02s │ │ [14:34:30] Modifying token.go │ +│ w-004 ✗ failed(1) coder 2m 01s │ │ [14:34:33] Running tests... │ +│ │ │ │ +╰─────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────╯ +``` + +**Key details:** +- Tree glyphs: `├─` (sibling continues), `└─` (last child), `│ ` (connector), spaces for depth +- Tree glyphs rendered in midGray/faint — not prominent, just structural +- Viewport title shows chain: `w-007 coder ← w-002` +- First line of viewport shows continuation badge: `← continued from w-002 (reviewer)` in lightBlue +- Status bar shows `chain depth: N` when a chained worker is selected +- Workers with children are collapsible (future: press `h` to collapse, `l` to expand) + +--- + +## V8: Narrow Layout + +**Trigger:** Terminal width <100 cols. +**Layout:** Stacked — table on top (~50% height), viewport on bottom (~50% height). +**Components:** Same as V1 but JoinVertical instead of JoinHorizontal. + +``` + kasmos agent orchestrator v0.1.0 + +╭─ Workers ──────────────────────────────────────╮ +│ │ +│ ID Status Role Duration │ +│ ──────────────────────────────────────────── │ +│ w-001 ✓ done coder 4m 12s │ +│ >w-002 ⣾ running reviewer 1m 48s │ +│ w-003 ⟳ running planner 0m 34s │ +│ w-004 ✗ failed(1) coder 2m 01s │ +│ │ +╰────────────────────────────────────────────────╯ +╭─ Output: w-002 reviewer ──────────────────────╮ +│ [14:32:05] Reviewing changes in auth/... │ +│ [14:32:07] Found 3 files modified │ +│ [14:32:09] middleware.go: LGTM │ +│ [14:32:11] handler.go: Suggestion: Extract... │ +│ [14:32:14] Running test suite... │ +│ │ +╰────────────────────────────────────────────────╯ + 2 running · 1 done · 1 failed ad-hoc 100% + s spawn · x kill · c continue · ? help · q quit +``` + +**Key details:** +- "Task" column hidden (not enough width) +- "Prompt" column hidden +- Help bar shows fewer bindings (only essentials) +- Status bar is more compact + +--- + +## V9: AI Failure Analysis + +**Trigger:** Press `a` on a failed worker. +**Layout:** Same 2-column split as V1. Viewport shows analysis results instead of raw output. +**Components:** viewport content is formatted analysis from on-demand AI helper. + +``` +╭─ Workers ─────────────────────────────────╮ ╭─ Analysis: w-004 coder ────────────────────────────────────────╮ +│ │ │ │ +│ (table content same as V1) │ │ 🔍 Failure Analysis │ +│ │ │ ────────────────────────────────────────────────────────────── │ +│ >w-004 ✗ failed(1) coder 2m 01s │ │ │ +│ │ │ Root Cause: Compilation error in │ +│ │ │ internal/auth/validator.go — undefined │ +│ │ │ reference to ValidateCredentials function. │ +│ │ │ │ +│ │ │ The function was renamed to CheckCredentials │ +│ │ │ in commit a3f8e21 but the worker's codebase │ +│ │ │ snapshot predates this change. │ +│ │ │ │ +│ │ │ Suggested Fix: │ +│ │ │ Restart with updated prompt: "Fix login │ +│ │ │ validation. Note: ValidateCredentials was │ +│ │ │ renamed to CheckCredentials in the latest │ +│ │ │ main branch. Use the new function name." │ +│ │ │ │ +│ │ │ Press r to restart with suggested prompt │ +╰───────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ + analysis complete 100% + r restart with suggestion · c continue · esc dismiss · ? help · q quit +``` + +**Key details:** +- While AI is analyzing, viewport shows spinner: `⣾ Analyzing failure...` +- "Root Cause:" label in orange bold, "Suggested Fix:" in green bold +- File/function references in lightBlue +- Commit refs in lightBlue +- Press `r` → opens restart dialog with suggested prompt pre-filled +- Press `esc` → returns to normal output view for that worker +- The analysis viewport title says "Analysis:" not "Output:" + +--- + +## V10: Daemon Mode + +**Trigger:** `kasmos -d` or non-interactive terminal detected. +**Layout:** No TUI. Pure stdout lines. +**Components:** None (WithoutRenderer). JSON events to stdout. + +``` +$ kasmos -d --tasks tasks.md --spawn-all --format json +{"ts":"2026-02-17T14:28:00Z","event":"session_start","mode":"gsd","source":"tasks.md","tasks":4} +{"ts":"2026-02-17T14:28:01Z","event":"worker_spawn","id":"w-001","role":"coder","task":"Implement auth"} +{"ts":"2026-02-17T14:28:01Z","event":"worker_spawn","id":"w-002","role":"coder","task":"Fix login flow"} +{"ts":"2026-02-17T14:28:01Z","event":"worker_spawn","id":"w-003","role":"reviewer","task":"Review PR #42"} +{"ts":"2026-02-17T14:28:01Z","event":"worker_spawn","id":"w-004","role":"planner","task":"Plan DB schema"} +{"ts":"2026-02-17T14:30:12Z","event":"worker_exit","id":"w-003","code":0,"duration":"2m11s","session":"ses_k2m9"} +{"ts":"2026-02-17T14:32:14Z","event":"worker_exit","id":"w-001","code":0,"duration":"4m13s","session":"ses_j4m9"} +{"ts":"2026-02-17T14:33:01Z","event":"worker_exit","id":"w-004","code":0,"duration":"5m00s","session":"ses_m7x2"} +{"ts":"2026-02-17T14:34:02Z","event":"worker_exit","id":"w-002","code":1,"duration":"6m01s","session":"ses_p1q3"} +{"ts":"2026-02-17T14:34:02Z","event":"session_end","total":4,"passed":3,"failed":1,"duration":"6m02s","exit_code":1} +$ echo $? +1 +``` + +**Key details:** +- One JSON object per line (NDJSON) +- Timestamps are RFC3339 +- `session_end` event includes aggregate stats +- Process exit code = 0 if all workers passed, 1 if any failed +- `--format default` outputs human-readable instead of JSON (simpler one-liners) + +--- + +## V11: Empty Dashboard + +**Trigger:** Fresh `kasmos` launch with no arguments and no prior session. +**Layout:** Same 2-column split as V1, but with empty states. + +``` + kasmos agent orchestrator v0.1.0 + +╭─ Workers ───────────────────────────────────────╮ ╭─ Output ─────────────────────────────────────────────────────╮ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ 🫧 Welcome to kasmos! │ +│ No workers yet │ │ │ +│ │ │ Spawn your first worker to get started. │ +│ Press s to spawn your first worker │ │ Select a worker to view its output here. │ +│ │ │ │ +│ │ │ Tip: Run kasmos setup to scaffold │ +│ │ │ agent configurations if you haven't yet. │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +╰─────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────╯ + 0 workers mode: ad-hoc scroll: — + s spawn · ? help · q quit +``` + +**Key details:** +- Empty table shows centered dim text: "No workers yet" and "Press s to spawn your first worker" +- Viewport shows welcome message with 🫧 emoji (the Charm bubblegum signature) +- Help bar only shows available actions (spawn, help, quit) — context-dependent keys are disabled +- "kasmos setup" in lightBlue in the welcome text + +--- + +## V12: Quit Confirmation + +**Trigger:** Press `q` while workers are still running. +**Layout:** Small centered dialog. Uses ThickBorder (┏┓┗┛) for urgency. +**Components:** huh.Confirm or custom dialog. + +``` +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░░░░┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓░░░░░░░░░ +░░░░░░░░░░░░┃ ┃░░░░░░░░░ +░░░░░░░░░░░░┃ ⚠ Quit kasmos? ┃░░░░░░░░░ +░░░░░░░░░░░░┃ ┃░░░░░░░░░ +░░░░░░░░░░░░┃ 2 workers are still running. ┃░░░░░░░░░ +░░░░░░░░░░░░┃ They will be terminated. ┃░░░░░░░░░ +░░░░░░░░░░░░┃ ┃░░░░░░░░░ +░░░░░░░░░░░░┃ [ Force Quit ] [ Cancel ] ┃░░░░░░░░░ +░░░░░░░░░░░░┃ ┃░░░░░░░░░ +░░░░░░░░░░░░┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛░░░░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +``` + +**Key details:** +- ThickBorder (┏━┓┃┗━┛) in orange — the only view that uses ThickBorder +- `⚠` in orange bold +- "Force Quit" button in orange bg, "Cancel" in darkGray bg +- If no workers running, pressing `q` exits immediately without this dialog +- Force Quit: SIGTERM all workers → 3s grace → SIGKILL → persist state → exit +- Esc or Cancel returns to dashboard diff --git a/design-artifacts/tui-styles.md b/design-artifacts/tui-styles.md new file mode 100644 index 0000000..fcd3a9d --- /dev/null +++ b/design-artifacts/tui-styles.md @@ -0,0 +1,556 @@ +# kasmos TUI — Style & Color Specification + +> Complete color palette, semantic mappings, component styles, and status indicator +> definitions. This file contains production-ready Go code for `styles.go`. + +## Palette + +### Core Colors + +All hex values are the canonical Charm bubblegum palette. + +```go +package tui + +import "github.com/charmbracelet/lipgloss" + +// ── Core Charm Bubblegum Palette ── + +var ( + colorPurple = lipgloss.Color("#7D56F4") // Primary accent: focus, selection, interactive + colorHotPink = lipgloss.Color("#F25D94") // Headers, dialog borders, emphasis + colorGreen = lipgloss.Color("#73F59F") // Success, done, positive + colorLightBlue = lipgloss.Color("#82CFFF") // Info, file paths, session refs + colorYellow = lipgloss.Color("#EDFF82") // Warnings, suggestions, attention + colorOrange = lipgloss.Color("#FF9F43") // Errors, failed states, urgent + colorCream = lipgloss.Color("#FFFDF5") // Primary text on dark/colored backgrounds + colorWhite = lipgloss.Color("#FAFAFA") // Bright text + colorDarkGray = lipgloss.Color("#383838") // Unfocused borders, subtle backgrounds + colorMidGray = lipgloss.Color("#5C5C5C") // Faint text, disabled elements, separators + colorLightGray = lipgloss.Color("#9B9B9B") // Secondary text, timestamps +) +``` + +### Adaptive Colors (light/dark terminal support) + +```go +var ( + subtleColor = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} + highlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + specialColor = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"} +) +``` + +### Semantic State Colors + +```go +var ( + colorRunning = colorPurple // ⣾ / ⟳ — active worker + colorDone = colorGreen // ✓ — exited successfully + colorFailed = colorOrange // ✗ — exited with non-zero code + colorKilled = colorHotPink // ☠ — terminated by user + colorPending = colorMidGray // ○ — waiting to spawn + colorWarning = colorYellow // ⚠ — needs attention +) +``` + +### Semantic UI Colors + +```go +var ( + colorFocusBorder = colorPurple // Focused panel border + colorUnfocusBorder = colorDarkGray // Unfocused panel border + colorDialogBorder = colorHotPink // Dialog overlay borders + colorAlertBorder = colorOrange // Warning/quit dialogs (ThickBorder) + colorHeader = colorHotPink // Section headers, titles + colorHelp = colorMidGray // Help text, hints + colorAccent = colorLightBlue // Info badges, file paths, links + colorTimestamp = colorLightGray // Log timestamps +) +``` + +### Role Badge Colors + +```go +// Role badge: colored background with contrasting text +var roleBadgeColors = map[string]struct{ bg, fg lipgloss.TerminalColor }{ + "planner": {bg: lipgloss.Color("#2D6A4F"), fg: colorCream}, + "coder": {bg: colorPurple, fg: colorCream}, + "reviewer": {bg: colorLightBlue, fg: lipgloss.Color("#0a0a18")}, + "release": {bg: lipgloss.Color("#8B5CF6"), fg: colorCream}, +} +``` + +--- + +## Style Definitions + +### Panel Styles + +```go +// Panel border styles — the primary visual organizer +var ( + focusedPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorFocusBorder). + Padding(0, 1) + + unfocusedPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorUnfocusBorder). + Padding(0, 1) +) + +// Helper to get panel style based on focus state +func panelStyle(focused bool) lipgloss.Style { + if focused { + return focusedPanelStyle + } + return unfocusedPanelStyle +} +``` + +### Header Styles + +```go +var ( + // App title: gradient rendered (see gradient section below) + titleBaseStyle = lipgloss.NewStyle().Bold(true) + + // "agent orchestrator" subtitle + dimSubtitleStyle = lipgloss.NewStyle(). + Foreground(colorMidGray) + + // Version number (right-aligned) + versionStyle = lipgloss.NewStyle(). + Foreground(colorLightGray) + + // Task source subtitle: "spec-kitty: kitty-specs/015/plan.md" + sourceSubtitleStyle = lipgloss.NewStyle(). + Foreground(colorLightGray). + MarginLeft(2) +) +``` + +### Title Gradient + +The app title "kasmos" uses a character-by-character gradient from hot pink to purple. + +```go +import "github.com/muesli/gamut" + +func renderGradientTitle(text string) string { + colors := gamut.Blends( + colorToColor(colorHotPink), // start: hot pink + colorToColor(colorPurple), // end: purple + len(text), + ) + + var out strings.Builder + for i, ch := range text { + hex := gamut.ToHex(colors[i]) + out.WriteString( + titleBaseStyle.Foreground(lipgloss.Color(hex)).Render(string(ch)), + ) + } + return out.String() +} + +// Render: " kasmos agent orchestrator" +// The leading space + gradient "kasmos" + 2 spaces + dim "agent orchestrator" +``` + +### Table Styles + +```go +func workerTableStyles() table.Styles { + s := table.DefaultStyles() + + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colorPurple). + BorderBottom(true). + Bold(true). + Foreground(colorHotPink) + + s.Selected = s.Selected. + Foreground(colorCream). + Background(colorPurple). + Bold(false) + + s.Cell = s.Cell. + Padding(0, 1) + + return s +} +``` + +### Status Bar Style + +```go +var statusBarStyle = lipgloss.NewStyle(). + Foreground(colorCream). + Background(colorPurple). + Padding(0, 1). + Bold(false) +``` + +### Help Bar Styles + +```go +import "github.com/charmbracelet/bubbles/help" + +func styledHelp() help.Model { + h := help.New() + h.ShowAll = false + + h.Styles.ShortKey = lipgloss.NewStyle(). + Foreground(colorPurple). + Bold(true) + + h.Styles.ShortDesc = lipgloss.NewStyle(). + Foreground(colorMidGray) + + h.Styles.ShortSeparator = lipgloss.NewStyle(). + Foreground(colorDarkGray) + + h.Styles.FullKey = lipgloss.NewStyle(). + Foreground(colorPurple). + Bold(true) + + h.Styles.FullDesc = lipgloss.NewStyle(). + Foreground(colorLightGray) + + h.Styles.FullSeparator = lipgloss.NewStyle(). + Foreground(colorDarkGray) + + return h +} +``` + +### Dialog Styles + +```go +var ( + // Standard dialog (spawn, continue) + dialogStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorDialogBorder). + Padding(1, 2) + + // Alert dialog (quit confirmation) + alertDialogStyle = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderForeground(colorAlertBorder). + Padding(1, 2) + + // Dialog section header + dialogHeaderStyle = lipgloss.NewStyle(). + Foreground(colorHotPink). + Bold(true) + + // Active button: purple bg + activeButtonStyle = lipgloss.NewStyle(). + Foreground(colorCream). + Background(colorPurple). + Padding(0, 2). + Bold(true) + + // Inactive button: darkGray bg + inactiveButtonStyle = lipgloss.NewStyle(). + Foreground(colorLightGray). + Background(colorDarkGray). + Padding(0, 2) + + // Alert button: orange bg + alertButtonStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#0a0a18")). + Background(colorOrange). + Padding(0, 2). + Bold(true) +) +``` + +### Text Input / Textarea Styles + +```go +func styledTextInput() textinput.Model { + ti := textinput.New() + ti.PromptStyle = lipgloss.NewStyle().Foreground(colorPurple) + ti.TextStyle = lipgloss.NewStyle().Foreground(colorCream) + ti.Cursor.Style = lipgloss.NewStyle().Foreground(colorHotPink) + ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colorMidGray) + return ti +} + +func styledTextArea() textarea.Model { + ta := textarea.New() + ta.FocusedStyle.CursorLine = lipgloss.NewStyle(). + Background(colorDarkGray) + ta.FocusedStyle.Base = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPurple) + ta.BlurredStyle.Base = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorDarkGray) + return ta +} +``` + +### Spinner Style + +```go +func styledSpinner() spinner.Model { + s := spinner.New() + s.Spinner = spinner.Dot // ⣾⣽⣻⢿⡿⣟⣯⣷ — the Charm classic + s.Style = lipgloss.NewStyle().Foreground(colorPurple) + return s +} +``` + +--- + +## Status Indicators + +### Worker State Indicators (for table cells) + +```go +type WorkerState int + +const ( + StatePending WorkerState = iota + StateSpawning + StateRunning + StateExited // exit code 0 + StateFailed // exit code != 0 + StateKilled +) + +// Static indicator (used for non-running states in table rows) +func statusIndicator(state WorkerState, exitCode int) string { + switch state { + case StateRunning: + // NOTE: for running workers, use spinner.View() + " running" instead + return lipgloss.NewStyle().Foreground(colorRunning).Render("⟳ running") + case StateExited: + return lipgloss.NewStyle().Foreground(colorDone).Render("✓ done") + case StateFailed: + return lipgloss.NewStyle().Foreground(colorFailed).Render( + fmt.Sprintf("✗ failed(%d)", exitCode)) + case StateKilled: + return lipgloss.NewStyle().Foreground(colorKilled).Render("☠ killed") + case StatePending: + return lipgloss.NewStyle().Foreground(colorPending).Render("○ pending") + case StateSpawning: + return lipgloss.NewStyle().Foreground(colorPurple).Render("◌ spawning") + default: + return lipgloss.NewStyle().Foreground(colorMidGray).Render("? unknown") + } +} + +// For running workers in table: inline spinner +func (m Model) runningStatusCell() string { + return m.spinner.View() + " running" +} +``` + +### Task State Indicators (for task list items) + +```go +type TaskState int + +const ( + TaskUnassigned TaskState = iota + TaskBlocked // dependency not met + TaskInProgress // worker spawned for this task + TaskDone // worker completed successfully + TaskFailed // worker failed +) + +func taskStatusBadge(state TaskState, blockingDep string) string { + switch state { + case TaskDone: + return lipgloss.NewStyle().Foreground(colorDone).Render("✓ done") + case TaskInProgress: + return lipgloss.NewStyle().Foreground(colorRunning).Render("⟳ in-progress") + case TaskBlocked: + return lipgloss.NewStyle().Foreground(colorOrange).Render( + fmt.Sprintf("⊘ blocked (%s)", blockingDep)) + case TaskFailed: + return lipgloss.NewStyle().Foreground(colorFailed).Render("✗ failed") + default: // TaskUnassigned + return lipgloss.NewStyle().Foreground(colorPending).Render("○ unassigned") + } +} +``` + +### Role Badges (for table cells and dialog labels) + +```go +func roleBadge(role string) string { + colors, ok := roleBadgeColors[role] + if !ok { + colors = struct{ bg, fg lipgloss.TerminalColor }{ + bg: colorDarkGray, fg: colorCream, + } + } + return lipgloss.NewStyle(). + Foreground(colors.fg). + Background(colors.bg). + Padding(0, 1). + Render(role) +} +``` + +### Output Viewport Content Styling + +```go +// Styled content lines in the output viewport +var ( + timestampStyle = lipgloss.NewStyle().Foreground(colorTimestamp) + filePathStyle = lipgloss.NewStyle().Foreground(colorLightBlue) + successStyle = lipgloss.NewStyle().Foreground(colorGreen) + failStyle = lipgloss.NewStyle().Foreground(colorOrange) + warningStyle = lipgloss.NewStyle().Foreground(colorYellow) + agentTagStyle = lipgloss.NewStyle().Foreground(colorPurple) +) + +// Format an output line for the viewport +// Input: "[14:32:01] [coder] Creating internal/auth/middleware.go" +// Output: styled version with colored timestamp, agent tag, file path +func formatOutputLine(line string) string { + // Implementation: regex or string matching to identify and style: + // - Timestamps [HH:MM:SS] → timestampStyle + // - Agent tags [coder] [reviewer] → agentTagStyle + // - File paths (anything with / that looks like a path) → filePathStyle + // - "PASS" → successStyle + // - "FAIL" → failStyle + // - "Suggestion:" → warningStyle + // - "✓" → successStyle + // - "✗" → failStyle + // Pass through everything else unstyled (terminal default foreground) + return line // TODO: implement +} +``` + +### Analysis View Styles + +```go +var ( + analysisHeaderStyle = lipgloss.NewStyle(). + Foreground(colorHotPink). + Bold(true) + + rootCauseLabelStyle = lipgloss.NewStyle(). + Foreground(colorOrange). + Bold(true) + + suggestedFixLabelStyle = lipgloss.NewStyle(). + Foreground(colorGreen). + Bold(true) + + analysisHintStyle = lipgloss.NewStyle(). + Foreground(colorMidGray). + Faint(true) +) +``` + +--- + +## Panel Title Rendering + +Panel titles are embedded in the top border line. This is not a built-in bubbles feature — it's a lipgloss rendering technique. + +```go +// Render a panel with a title in the top border +func renderTitledPanel(title string, content string, width int, height int, focused bool) string { + style := panelStyle(focused) + + // Build the top border manually with embedded title + borderStyle := lipgloss.RoundedBorder() + borderColor := colorFocusBorder + if !focused { + borderColor = colorUnfocusBorder + } + + bc := lipgloss.NewStyle().Foreground(borderColor) + + // "╭─ Title ─────────╮" + titleRendered := bc.Render(fmt.Sprintf("%s%s %s %s%s", + string(borderStyle.TopLeft), + string(borderStyle.Top), + title, + strings.Repeat(string(borderStyle.Top), max(0, width-lipgloss.Width(title)-5)), + string(borderStyle.TopRight), + )) + + // Content with side borders + // ... (pad content lines, add │ on each side) + + // Bottom border + bottomRendered := bc.Render(fmt.Sprintf("%s%s%s", + string(borderStyle.BottomLeft), + strings.Repeat(string(borderStyle.Bottom), width-2), + string(borderStyle.BottomRight), + )) + + return lipgloss.JoinVertical(lipgloss.Left, titleRendered, contentBody, bottomRendered) +} +``` + +**Alternative (simpler):** Just render the title as the first content line inside the border and use standard `style.Render()`: + +```go +// Simpler approach: title as first line inside the panel +panelContent := lipgloss.JoinVertical(lipgloss.Left, + bc.Render("Workers"), // styled title line + "", // blank separator + m.table.View(), // actual content +) +style.Width(w).Height(h).Render(panelContent) +``` + +--- + +## Overlay Backdrop + +All overlays use the same backdrop pattern: + +```go +func (m Model) renderWithBackdrop(dialog string) string { + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + dialog, + lipgloss.WithWhitespaceChars("░"), + lipgloss.WithWhitespaceForeground(colorDarkGray), + ) +} +``` + +The `░` character in darkGray creates the signature Charm textured backdrop that makes dialogs pop visually. + +--- + +## huh Form Theme + +Use huh's built-in Charm theme for all form dialogs: + +```go +form := huh.NewForm(groups...).WithTheme(huh.ThemeCharm()) +``` + +This automatically applies purple accents, rounded borders, and the bubblegum aesthetic to all form fields (Select, Text, Input, Confirm). No custom theme needed. + +--- + +## Color Behavior Rules + +1. **Body text uses terminal default foreground.** Never force `Foreground(colorCream)` on general content. Only color text that carries semantic meaning. +2. **Purple is the interactive spine.** Focus borders, selected rows, active buttons, cursor, key labels in help. When you see purple, you know "this is where the action is." +3. **Hot pink is for headers and emphasis.** App title gradient endpoint, dialog borders, section headers in help and analysis. Less frequent than purple but higher visual weight. +4. **Green = positive, Orange = negative, Yellow = attention.** No exceptions. Don't use green for non-success states or orange for non-error states. +5. **Gray tones for everything secondary.** Timestamps → lightGray. Disabled/inactive → midGray. Borders → darkGray. These fade into the background. +6. **LightBlue is for references.** File paths, session IDs, commit hashes, links. Information that the user might want to act on. +7. **One spinner style everywhere.** `spinner.Dot` in purple. Don't mix spinner styles. +8. **Bold sparingly.** Headers, key labels, status labels. Not body text, not descriptions. +9. **Faint for hints only.** `Faint(true)` on help text, placeholder text, and "press X to do Y" hints. Not on content the user needs to read. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/checklists/requirements.md b/kitty-specs/011-mcp-agent-swarm-orchestration/checklists/requirements.md deleted file mode 100644 index d7c1c76..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/checklists/requirements.md +++ /dev/null @@ -1,37 +0,0 @@ -# Specification Quality Checklist: MCP Agent Swarm Orchestration - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-02-13 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass validation. Spec is ready for `/spec-kitty.clarify` or `/spec-kitty.plan`. -- Spec deliberately uses generic terms ("terminal multiplexer", "agent runtime", "pane-tracking service") instead of technology names in requirements and success criteria. Technology mapping happens during planning/implementation. -- FR-024 (preserve TUI code) ensures this is an additive change, not a destructive rewrite. -- The spec covers 3 kasmos modes (launch, serve, setup) as described in discovery, 8 user stories across 2 priority levels, 33 functional requirements (FR-001 through FR-033), 10 success criteria, and 11 edge cases. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/contracts/kasmos-serve.json b/kitty-specs/011-mcp-agent-swarm-orchestration/contracts/kasmos-serve.json deleted file mode 100644 index 507383b..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/contracts/kasmos-serve.json +++ /dev/null @@ -1,332 +0,0 @@ -{ - "service": "kasmos-serve", - "version": "1.0.0-draft", - "transport": "stdio", - "description": "MCP tool contract for kasmos manager orchestration", - "tools": [ - { - "name": "spawn_worker", - "description": "Spawn a planner/coder/reviewer/release worker pane", - "inputSchema": { - "type": "object", - "required": ["wp_id", "role", "prompt", "feature_slug"], - "properties": { - "wp_id": { "type": "string" }, - "role": { - "type": "string", - "enum": ["planner", "coder", "reviewer", "release"] - }, - "prompt": { "type": "string" }, - "feature_slug": { "type": "string" }, - "worktree_path": { "type": "string" } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "worker"], - "properties": { - "ok": { "type": "boolean" }, - "worker": { "$ref": "#/sharedSchemas/WorkerEntry" } - }, - "additionalProperties": false - } - }, - { - "name": "despawn_worker", - "description": "Close a worker pane and remove it from registry", - "inputSchema": { - "type": "object", - "required": ["wp_id", "role"], - "properties": { - "wp_id": { "type": "string" }, - "role": { - "type": "string", - "enum": ["planner", "coder", "reviewer", "release"] - }, - "reason": { "type": "string" } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "removed"], - "properties": { - "ok": { "type": "boolean" }, - "removed": { "type": "boolean" } - }, - "additionalProperties": false - } - }, - { - "name": "list_workers", - "description": "List workers tracked by this manager instance", - "inputSchema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["active", "done", "errored", "aborted"] - } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "workers"], - "properties": { - "ok": { "type": "boolean" }, - "workers": { - "type": "array", - "items": { "$ref": "#/sharedSchemas/WorkerEntry" } - } - }, - "additionalProperties": false - } - }, - { - "name": "read_messages", - "description": "Read and parse message-log pane events", - "inputSchema": { - "type": "object", - "properties": { - "since_index": { "type": "integer", "minimum": 0 }, - "filter_wp": { "type": "string" }, - "filter_event": { - "type": "string", - "enum": [ - "STARTED", - "PROGRESS", - "DONE", - "ERROR", - "REVIEW_PASS", - "REVIEW_REJECT", - "NEEDS_INPUT" - ] - } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "messages", "next_index"], - "properties": { - "ok": { "type": "boolean" }, - "messages": { - "type": "array", - "items": { "$ref": "#/sharedSchemas/KasmosMessage" } - }, - "next_index": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": false - } - }, - { - "name": "wait_for_event", - "description": "Block until matching event appears or timeout is reached", - "inputSchema": { - "type": "object", - "required": ["timeout_seconds"], - "properties": { - "wp_id": { "type": "string" }, - "event": { - "type": "string", - "enum": [ - "STARTED", - "PROGRESS", - "DONE", - "ERROR", - "REVIEW_PASS", - "REVIEW_REJECT", - "NEEDS_INPUT" - ] - }, - "timeout_seconds": { "type": "integer", "minimum": 1 } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "status", "elapsed_seconds"], - "properties": { - "ok": { "type": "boolean" }, - "status": { "type": "string", "enum": ["matched", "timeout"] }, - "elapsed_seconds": { "type": "integer", "minimum": 0 }, - "message": { "$ref": "#/sharedSchemas/KasmosMessage" } - }, - "additionalProperties": false - } - }, - { - "name": "workflow_status", - "description": "Return feature phase, wave status, and active lock metadata", - "inputSchema": { - "type": "object", - "required": ["feature_slug"], - "properties": { - "feature_slug": { "type": "string" } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "snapshot"], - "properties": { - "ok": { "type": "boolean" }, - "snapshot": { "$ref": "#/sharedSchemas/WorkflowSnapshot" } - }, - "additionalProperties": false - } - }, - { - "name": "transition_wp", - "description": "Validate and apply WP lane transitions in task files", - "inputSchema": { - "type": "object", - "required": ["feature_slug", "wp_id", "to_state", "actor"], - "properties": { - "feature_slug": { "type": "string" }, - "wp_id": { "type": "string" }, - "to_state": { - "type": "string", - "enum": ["pending", "active", "for_review", "done", "rework"] - }, - "actor": { "type": "string" }, - "reason": { "type": "string" } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "wp_id", "from_state", "to_state"], - "properties": { - "ok": { "type": "boolean" }, - "wp_id": { "type": "string" }, - "from_state": { "type": "string" }, - "to_state": { "type": "string" } - }, - "additionalProperties": false - } - }, - { - "name": "list_features", - "description": "List known feature specs and artifact availability", - "inputSchema": { - "type": "object", - "properties": {}, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "features"], - "properties": { - "ok": { "type": "boolean" }, - "features": { - "type": "array", - "items": { - "type": "object", - "required": ["slug", "has_spec", "has_plan", "has_tasks"], - "properties": { - "slug": { "type": "string" }, - "has_spec": { "type": "boolean" }, - "has_plan": { "type": "boolean" }, - "has_tasks": { "type": "boolean" } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - { - "name": "infer_feature", - "description": "Infer feature slug from arg, branch, and cwd context", - "inputSchema": { - "type": "object", - "properties": { - "spec_prefix": { "type": "string" } - }, - "additionalProperties": false - }, - "outputSchema": { - "type": "object", - "required": ["ok", "source"], - "properties": { - "ok": { "type": "boolean" }, - "source": { "type": "string", "enum": ["arg", "branch", "directory", "none"] }, - "feature_slug": { "type": "string" } - }, - "additionalProperties": false - } - } - ], - "sharedSchemas": { - "WorkerEntry": { - "type": "object", - "required": ["wp_id", "role", "pane_name", "status", "spawned_at"], - "properties": { - "wp_id": { "type": "string" }, - "role": { "type": "string" }, - "pane_name": { "type": "string" }, - "status": { "type": "string" }, - "spawned_at": { "type": "string", "format": "date-time" } - }, - "additionalProperties": true - }, - "KasmosMessage": { - "type": "object", - "required": ["message_index", "sender", "event", "payload", "timestamp"], - "properties": { - "message_index": { "type": "integer", "minimum": 0 }, - "sender": { "type": "string" }, - "event": { "type": "string" }, - "payload": { "type": "object" }, - "timestamp": { "type": "string", "format": "date-time" }, - "raw_line": { "type": "string" } - }, - "additionalProperties": true - }, - "WorkflowSnapshot": { - "type": "object", - "required": ["feature_slug", "phase", "waves", "lock"], - "properties": { - "feature_slug": { "type": "string" }, - "phase": { "type": "string" }, - "waves": { - "type": "array", - "items": { - "type": "object", - "required": ["wave", "wp_ids", "complete"], - "properties": { - "wave": { "type": "integer", "minimum": 0 }, - "wp_ids": { "type": "array", "items": { "type": "string" } }, - "complete": { "type": "boolean" } - }, - "additionalProperties": false - } - }, - "lock": { - "type": "object", - "required": ["state"], - "properties": { - "state": { "type": "string", "enum": ["active", "stale", "none"] }, - "owner_id": { "type": "string" }, - "expires_at": { "type": "string", "format": "date-time" } - }, - "additionalProperties": true - } - }, - "additionalProperties": true - } - }, - "errorCodes": [ - "INVALID_INPUT", - "FEATURE_LOCK_CONFLICT", - "STALE_LOCK_CONFIRMATION_REQUIRED", - "WORKER_NOT_FOUND", - "TRANSITION_NOT_ALLOWED", - "DEPENDENCY_MISSING", - "INTERNAL_ERROR" - ] -} diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/data-model.md b/kitty-specs/011-mcp-agent-swarm-orchestration/data-model.md deleted file mode 100644 index 9dab4c0..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/data-model.md +++ /dev/null @@ -1,141 +0,0 @@ -# Data Model: MCP Agent Swarm Orchestration - -## Overview - -This model defines runtime entities required for launch orchestration, worker lifecycle, feature ownership locking, and audit persistence. - -## Entity: FeatureBindingLock - -- Purpose: Ensure a single active manager owns a feature in a repository at any time. -- Identity: - - Primary key: `lock_key` (`::`) -- Fields: - - `lock_key: String` - - `repo_root: String` - - `feature_slug: String` - - `owner_id: String` (stable process/session token) - - `owner_session: String` - - `owner_tab: String` - - `acquired_at: DateTime` - - `last_heartbeat_at: DateTime` - - `expires_at: DateTime` - - `status: LockStatus` (`active | stale | released`) -- Validation rules: - - Only one `active` lock per `lock_key` - - `expires_at = last_heartbeat_at + stale_timeout` - - Default stale timeout is 15 minutes -- State transitions: - - `released -> active` on successful acquisition - - `active -> stale` when heartbeat misses timeout - - `stale -> active` on confirmed takeover - - `active/stale -> released` on clean shutdown - -## Entity: WorkerEntry - -- Purpose: Track active and completed worker panes managed by one manager. -- Identity: - - Primary key: `wp_id + role` -- Fields: - - `wp_id: String` - - `role: AgentRole` (`planner | coder | reviewer | release`) - - `pane_name: String` - - `pane_id: Option` - - `worktree_path: Option` - - `status: WorkerStatus` (`active | done | errored | aborted`) - - `spawned_at: DateTime` - - `updated_at: DateTime` - - `last_event: Option` -- Validation rules: - - `worktree_path` required for coder role - - `pane_name` must be unique within owning orchestration tab - -## Entity: KasmosMessage - -- Purpose: Parsed structured event line from message-log pane. -- Identity: - - Primary key: `message_index` (monotonic per manager) -- Fields: - - `message_index: u64` - - `sender: String` - - `event: MessageEvent` - - `payload: JsonValue` - - `timestamp: DateTime` - - `raw_line: String` -- Validation rules: - - Must match format: `[KASMOS::] ` - - `event` must map to known enum values - -## Entity: AuditEntry - -- Purpose: Persisted orchestration trail for post-session diagnostics. -- Storage path: - - `kitty-specs//.kasmos/messages.jsonl` -- Fields: - - `timestamp: DateTime` - - `actor: String` (`manager`, `kasmos-serve`, or worker id) - - `action: String` (`spawn_worker`, `transition_wp`, etc.) - - `feature_slug: String` - - `wp_id: Option` - - `status: String` - - `summary: String` - - `details: JsonValue` (metadata by default) - - `debug_payload: Option` (present only in debug mode) -- Validation rules: - - `debug_payload` must be omitted unless debug logging is enabled - - Writes must be append-only within a file generation - -## Entity: AuditPolicy - -- Purpose: Retention and payload policy controls. -- Fields: - - `metadata_only_default: bool` (default `true`) - - `debug_full_payload_enabled: bool` (default `false`) - - `max_bytes: u64` (default `536870912`) - - `max_age_days: u32` (default `14`) - - `trigger_mode: TriggerMode` (`either_threshold`) -- Validation rules: - - Rotation/pruning triggers when either size or age threshold is reached - -## Entity: WorkflowSnapshot - -- Purpose: Report current feature phase and wave execution state. -- Fields: - - `feature_slug: String` - - `phase: String` (`spec_only | clarifying | planned | analyzing | tasked | implementing | reviewing | releasing | complete`) - - `waves: Vec` - - `active_workers: Vec` - - `last_event_at: Option>` -- Phase derivation notes: - - `clarifying` and `analyzing` are optional planning phases; smaller features may skip directly from `spec_only` to `planned` to `tasked` - - Phase is derived from artifact presence (e.g., spec.md exists without plan.md -> `spec_only` or `clarifying`), not from WP lane states - - Workflow phases and WP lane states are orthogonal concepts; the Lane Translation Protocol (below) applies only to WP states - -## Relationships - -- One `FeatureBindingLock` controls one active manager per feature key. -- One manager owns many `WorkerEntry` records. -- Many `KasmosMessage` records reference zero or one `WorkerEntry` by `wp_id` and sender. -- Many `AuditEntry` records belong to one feature slug and optional WP. -- One `AuditPolicy` is loaded from config and applied to all `AuditEntry` writes. - -## Lane Translation Protocol - -Kasmos uses its own orchestration state vocabulary internally and in the MCP contract. Spec-kitty task files use a different lane vocabulary. Translation occurs at the file I/O boundary (inside `transition_wp` writes and `workflow_status` reads). - -| Kasmos State | Spec-Kitty Lane | Direction | Notes | -|--------------|-----------------|-----------|-------| -| `pending` | `planned` | Bidirectional | WP ready but not started | -| `active` | `doing` | Bidirectional | Worker assigned and executing | -| `for_review` | `for_review` | Bidirectional | Shared term, no translation needed | -| `done` | `done` | Bidirectional | Shared term, no translation needed | -| `rework` | `doing` | Write-only | Written as `doing`; rework context is preserved in the audit log (`reason` field), not the lane name. On read-back, `doing` with prior `for_review` history implies rework. | - -- `transition_wp` translates kasmos state to spec-kitty lane before writing task file frontmatter. -- `workflow_status` translates spec-kitty lane to kasmos state when reading task file frontmatter. -- The audit log always records the kasmos vocabulary (e.g., `rework`, not `doing`) for precise history. - -## Scale Assumptions - -- Concurrent workers: 4+ expected, configurable upper bound validated in config. -- Message volume: bursty but line-oriented; parser must handle thousands of lines without duplication. -- Audit retention: bounded by 512MB and 14-day age using either-threshold policy. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/meta.json b/kitty-specs/011-mcp-agent-swarm-orchestration/meta.json deleted file mode 100644 index 8a371f7..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/meta.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "accept_commit": null, - "acceptance_history": [ - { - "accepted_at": "2026-02-16T03:30:09Z", - "accepted_by": "release", - "accepted_from_commit": "e7d0cdcd76513c4ca7bbf4a636c6f42fd8f84cad", - "branch": "main", - "mode": "pr" - } - ], - "acceptance_mode": "pr", - "accepted_at": "2026-02-16T03:30:09Z", - "accepted_by": "release", - "accepted_from_commit": "e7d0cdcd76513c4ca7bbf4a636c6f42fd8f84cad", - "created_at": "2026-02-13T18:00:00Z", - "feature_number": "011", - "friendly_name": "MCP Agent Swarm Orchestration", - "mission": "software-dev", - "slug": "011-mcp-agent-swarm-orchestration", - "source_description": "Pivot kasmos from TUI-based orchestrator to MCP-powered agent swarm model. kasmos becomes bootstrapper + launcher + local MCP server. Manager agent orchestrates workers via kasmos MCP tools. Workers communicate status via zellij MCP message-log pane. Dynamic Zellij swap layouts for worker grid management. Hub TUI preserved for future reintegration.", - "target_branch": "main", - "vcs": "git" -} diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/plan.md b/kitty-specs/011-mcp-agent-swarm-orchestration/plan.md deleted file mode 100644 index 60d09be..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/plan.md +++ /dev/null @@ -1,238 +0,0 @@ -# Implementation Plan: MCP Agent Swarm Orchestration - -**Branch**: `011-mcp-agent-swarm-orchestration` | **Date**: 2026-02-14 | **Spec**: `kitty-specs/011-mcp-agent-swarm-orchestration/spec.md` -**Input**: Feature specification from `/home/kas/dev/kasmos/kitty-specs/011-mcp-agent-swarm-orchestration/spec.md` - -## Summary - -Replace the legacy TUI orchestration flow with an MCP-driven swarm model where the manager agent controls lifecycle transitions, worker spawning, review loops, and release handoff. The `kasmos` command becomes a launcher for orchestration tabs, while `kasmos serve` runs as a manager-scoped MCP stdio subprocess. Feature execution ownership is enforced by a repository-wide lock, and orchestration audit logs are persisted per feature under `kitty-specs//.kasmos/messages.jsonl` with rotation by size or age. - -## Technical Context - -**Language/Version**: Rust 2024 edition (latest stable) -**Primary Dependencies**: `tokio`, `clap`, `serde`, `serde_json`, `serde_yaml`, `rmcp`, `schemars`, `regex`, `kdl`, `nix`, `thiserror`, `anyhow`, `tracing` -**Storage**: Filesystem only; spec-kitty task files are SSOT for WP state, per-feature audit log at `kitty-specs//.kasmos/messages.jsonl` -**Testing**: `cargo test` for unit and integration suites; scenario checks for launch, lock handling, and MCP tool behavior -**Target Platform**: Linux primary, macOS best-effort -**Project Type**: Single Rust binary (`crates/kasmos/`) -**Performance Goals**: Launch ready under 10s, event detection under 15s, error report under 30s, support 4+ concurrent workers -**Constraints**: -- Launch must preflight dependencies and fail fast with actionable guidance and non-zero exit on missing requirements -- `kasmos serve` runs as manager-spawned MCP stdio subprocess (no dedicated MCP tab process) -- Feature ownership lock is repository-wide across running processes -- Stale lock takeover requires explicit confirmation after 15 minutes timeout -- Audit log retention rotates when either threshold is hit: size > 512MB OR age > 14 days -- Metadata-only audit logging by default; full payloads only in opt-in debug mode -**Scale/Scope**: Platform-level workflow pivot touching launch, serve, prompting, workflow state, and operational safeguards - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| Rust 2024 edition | PASS | Plan keeps Rust 2024 across modules | -| tokio async runtime | PASS | MCP server and polling loops are async on tokio | -| ratatui for TUI | PASS | Existing TUI remains preserved and feature-gated per spec FR-024 | -| Zellij substrate | PASS | Launch/session flow and pane lifecycle remain Zellij-based | -| OpenCode primary agent | PASS | Manager, planner, coder, reviewer, release roles use OpenCode profile set | -| cargo test required | PASS | Unit and integration test scopes defined for changed subsystems | -| Linux primary, macOS best-effort | PASS | Platform support unchanged | -| Single binary distribution | PASS | `kasmos` remains one installable binary with subcommands | - -## Engineering Alignment - -Planning interrogation decisions accepted by stakeholder: - -1. `kasmos serve` runtime model: manager-spawned MCP stdio subprocess -2. Audit log path: `kitty-specs//.kasmos/messages.jsonl` -3. Missing dependency behavior: fail before launch with actionable guidance and non-zero exit -4. Duplicate binding prevention scope: repository-wide across running processes -5. Stale lock recovery: takeover only after timeout with explicit user confirmation -6. Default stale lock timeout: 15 minutes -7. Audit detail level: metadata default, debug mode enables full payload logging -8. Audit retention policy: rotate/prune when size exceeds 512MB or age exceeds 14 days -9. No-inference feature selection: CLI selection before any session or tab creation - -No planning clarifications remain unresolved. - -## Architecture Overview - -### Runtime Topology - -- `kasmos [spec-prefix]` performs dependency preflight, feature resolution, and lock acquisition attempt before creating tabs -- Orchestration tab hosts manager pane, message-log pane, dashboard pane, and dynamic worker area -- Manager process owns a dedicated `kasmos serve` MCP stdio subprocess for tool calls -- Workers never call kasmos MCP directly; they write status events through zellij pane tools to message-log - -### Session Layout - -``` -Tab: orchestration -- manager (60%) -- message-log (20%) -- dashboard (20%) -- worker rows (dynamic, max parallel from config) -``` - -No dedicated MCP hosting tab is required in this design. - -### Feature Ownership Locking - -- Lock key: `::` -- Scope: repository-wide across all running kasmos processes -- Record fields: owner id, session/tab metadata, acquired time, heartbeat time, expires time -- Stale policy: stale when heartbeat exceeds 15 minutes -- Takeover policy: present stale-owner details and require explicit user confirmation before stealing lock - -### Manager Orchestration Loop - -1. Load workflow state from spec artifacts and task lanes -2. Present next action and confirmation gate to user -3. Spawn worker(s) via `spawn_worker` -4. Wait using blocking `wait_for_event` (with bounded timeout) -5. Process event, transition WP status, emit manager status update -6. Repeat until stage completion, then pause for user confirmation - -### Communication Protocol - -Workers send structured messages in message-log pane: - -``` -[KASMOS::] -``` - -Supported events include `STARTED`, `PROGRESS`, `DONE`, `ERROR`, `REVIEW_PASS`, `REVIEW_REJECT`, `NEEDS_INPUT`. - -### Audit Logging and Retention - -- Storage path: `kitty-specs//.kasmos/messages.jsonl` -- Default payload: metadata only (`timestamp`, `actor`, `action`, `wp_id`, `status`, `summary`) -- Debug payload mode: gated by config flag, includes prompts/tool args for incident analysis -- Retention trigger: rotate/prune when either condition is true: - - active log > 512MB - - entry age > 14 days - -### Failure and Recovery - -- Missing dependencies at launch -> fail fast before tab/session creation -- Lock already held and fresh -> refuse bind and show active owner details -- Lock stale -> offer takeover (confirmation required) -- `kasmos serve` subprocess crash -> manager pauses automation and surfaces restart guidance -- Worker pane loss or crash -> mark aborted, notify user, and offer respawn/skip path -- Review rejection loops -> enforce configurable cap (default 3), then pause for user intervention - -## Project Structure - -### Documentation (this feature) - -``` -kitty-specs/011-mcp-agent-swarm-orchestration/ -|- spec.md -|- plan.md -|- research.md -|- data-model.md -|- quickstart.md -|- contracts/ -| \- kasmos-serve.json -\- tasks.md -``` - -### Source Code (repository root) - -``` -crates/kasmos/ -|- Cargo.toml -\- src/ - |- main.rs - |- config.rs - |- launch/ - | |- mod.rs - | |- detect.rs - | |- layout.rs - | \- session.rs - |- serve/ - | |- mod.rs - | |- registry.rs - | |- messages.rs - | |- audit.rs - | |- dashboard.rs - | |- lock.rs - | \- tools/ - | |- spawn_worker.rs - | |- despawn_worker.rs - | |- list_workers.rs - | |- read_messages.rs - | |- wait_for_event.rs - | |- workflow_status.rs - | |- transition_wp.rs - | |- list_features.rs - | \- infer_feature.rs - |- setup/ - | \- mod.rs - |- prompt.rs - |- zellij.rs - |- parser.rs - |- state_machine.rs - |- graph.rs - \- types.rs - -config/profiles/kasmos/ -|- opencode.jsonc -\- agent/ - |- manager.md - |- planner.md - |- coder.md - |- reviewer.md - \- release.md -``` - -**Structure Decision**: Keep a single-binary Rust workspace crate and add focused modules for `launch/`, `serve/`, and `setup/`; preserve existing reusable domain modules and keep legacy TUI code disconnected behind feature gates. - -## MCP Tool Contracts - -The MCP surface remains 9 tools, defined in `kitty-specs/011-mcp-agent-swarm-orchestration/contracts/kasmos-serve.json`: - -- `spawn_worker` -- `despawn_worker` -- `list_workers` -- `read_messages` -- `wait_for_event` -- `workflow_status` -- `transition_wp` -- `list_features` -- `infer_feature` - -Contract updates in this plan include explicit lock-conflict responses, audit metadata fields, and timeout semantics. - -## Data Model Snapshot - -Detailed model is documented in `kitty-specs/011-mcp-agent-swarm-orchestration/data-model.md`. Key entities: - -- `FeatureBindingLock` -- `WorkerEntry` -- `KasmosMessage` -- `AuditEntry` -- `AuditPolicy` -- `WorkflowSnapshot` - -## Phase Outputs - -### Phase 0: Research - -Produce `kitty-specs/011-mcp-agent-swarm-orchestration/research.md` with finalized decisions, rationale, and alternatives for runtime model, lock scope, retention, and operational behavior. - -### Phase 1: Design and Contracts - -- Produce `kitty-specs/011-mcp-agent-swarm-orchestration/data-model.md` -- Produce `kitty-specs/011-mcp-agent-swarm-orchestration/contracts/kasmos-serve.json` -- Produce `kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md` -- Update agent context via `spec-kitty agent context update-context --feature 011-mcp-agent-swarm-orchestration --agent-type opencode` - -## Constitution Check (Post-Design Recheck) - -Post-design status remains PASS for all constitution principles. No exceptions or complexity waivers are required. - -## Complexity Tracking - -No constitution violations or exception justifications were introduced. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md b/kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md deleted file mode 100644 index 1a1cf68..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md +++ /dev/null @@ -1,107 +0,0 @@ -# Quickstart: MCP Agent Swarm Orchestration - -## Prerequisites - -- Rust stable toolchain (2024 edition support) -- `zellij` in `PATH` -- `ocx` (OpenCode) in `PATH` -- `bun` in `PATH` (runs pane-tracker MCP server) -- `pane-tracker` (or `zellij-pane-tracker`) in `PATH` -- `zjstatus.wasm` in `~/.config/zellij/plugins/` -- `zellij-pane-tracker.wasm` in `~/.config/zellij/plugins/` - -## 0) Build Matrix Sanity - -Run: - -```bash -cargo build -cargo test -cargo build --features tui -cargo test --features tui -``` - -Expected behavior: - -- Default build and tests pass -- Legacy TUI build and tests pass behind `tui` feature gate - -## 1) Validate Environment - -Run: - -```bash -kasmos setup -``` - -Expected behavior: - -- Validates required binaries and integration prerequisites -- Creates missing baseline config/profile assets if needed - -## 2) Launch with Explicit Feature - -Run: - -```bash -kasmos 011 -``` - -Expected behavior: - -- Launch preflight runs before any tab/session creation -- If dependencies are missing, launch aborts with actionable guidance and non-zero exit -- Orchestration tab opens with manager, message-log, dashboard, and worker area -- Manager spawns `kasmos serve` as MCP stdio subprocess - -## 3) Launch Without Feature Prefix - -Run: - -```bash -kasmos -``` - -Expected behavior: - -- If feature can be inferred (branch/path), launch continues -- If not inferable, CLI selector appears before any tab/session creation -- If no feature specs exist, CLI reports this and exits cleanly - -## 4) Listing And Status - -Run: - -```bash -kasmos list -kasmos status 011 -``` - -Expected behavior: - -- `kasmos list` shows available feature slugs and artifact availability -- `kasmos status 011` reports workflow phase, waves, lock state, and active workers - -## 5) Lock Conflict and Stale Recovery - -If another process already owns the feature lock: - -- Fresh lock: bind is refused and current owner details are shown -- Stale lock (older than 15 minutes): takeover is offered but requires explicit confirmation - -## 6) Audit Logging Behavior - -- Log file path: `kitty-specs//.kasmos/messages.jsonl` -- Default mode: metadata-only entries -- Debug mode: optional full payload capture for deep troubleshooting -- Rotation/pruning triggers when either threshold is met: - - file size exceeds 512MB - - entry age exceeds 14 days - -## 7) Basic Verification Checklist - -- `cargo build` -- `cargo test` -- `cargo build --features tui` -- `cargo test --features tui` -- Manual check: launch + lock conflict + stale takeover prompt + message-log event flow diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/research.md b/kitty-specs/011-mcp-agent-swarm-orchestration/research.md deleted file mode 100644 index 80b2d65..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/research.md +++ /dev/null @@ -1,64 +0,0 @@ -# Research: MCP Agent Swarm Orchestration - -## Decision 1: Serve Runtime Model - -- Decision: Run `kasmos serve` as an MCP stdio subprocess owned by the manager agent. -- Rationale: Keeps lifecycle tied to manager ownership, removes extra Zellij process wiring, and aligns with OpenCode MCP subprocess support. -- Alternatives considered: - - Dedicated MCP tab process: easier visual inspection, but adds lifecycle split and startup ordering complexity. - - Hybrid tab plus subprocess: duplicates responsibility and increases failure modes. - -## Decision 2: Feature Ownership Lock Scope - -- Decision: Enforce one active owner per feature repository-wide (`::`). -- Rationale: Prevents conflicting managers from separate tabs/sessions/processes from mutating the same task state concurrently. -- Alternatives considered: - - Same-session-only lock: misses collisions across sessions and detached terminals. - - Machine-global lock without repo key: risks false conflicts across unrelated repositories. - -## Decision 3: Stale Lock Recovery - -- Decision: Mark locks stale after 15 minutes of missed heartbeat; allow takeover only after explicit user confirmation. -- Rationale: Balances safety and operability by preventing silent ownership transfer while still recovering from abandoned sessions. -- Alternatives considered: - - Manual unlock only: safest but creates operational dead-ends after crashes. - - Automatic takeover: faster but can hide race conditions and user intent mismatches. - -## Decision 4: Launch Failure Policy - -- Decision: `kasmos` launch path must fail before creating tabs/sessions if dependencies are missing. -- Rationale: Prevents partial orchestration state and gives deterministic setup feedback. -- Alternatives considered: - - Warning-only behavior: users reach broken sessions with delayed failures. - - Auto-run setup and continue: can mask root causes and produce inconsistent startup timing. - -## Decision 5: Audit Log Location and Retention - -- Decision: Persist audit logs at `kitty-specs//.kasmos/messages.jsonl`; rotate/prune when either file size exceeds 512MB or entry age exceeds 14 days. -- Rationale: Feature-local logs improve traceability and code review context; dual threshold controls disk growth and stale data. -- Alternatives considered: - - Repo-level shared log: harder to isolate incidents by feature. - - Append-only no rotation: unbounded growth and slower diagnostics. - -## Decision 6: Audit Payload Depth - -- Decision: Metadata-only logging by default; full payload logging enabled only in debug mode. -- Rationale: Reduces sensitive prompt/context retention while preserving enough telemetry for normal operations. -- Alternatives considered: - - Always full payload: highest forensic value, but larger privacy and storage risk. - - Strict metadata only with no debug option: insufficient for deep incident triage. - -## Decision 7: No-Inference Feature Selection UX - -- Decision: Run feature selection in CLI before any session or tab is created. -- Rationale: Avoids launching orchestration UI in an unknown context and keeps startup behavior deterministic. -- Alternatives considered: - - In-manager selector: delays resolution until after tab launch and complicates rollback. - - Dual prompt CLI plus manager: redundant confirmation with little extra safety. - -## Best-Practice Notes - -- Use bounded blocking waits (`wait_for_event` timeout) so manager loops remain resumable. -- Use atomic writes plus advisory locking for task lane transitions. -- Keep structured message format stable and machine-parseable (`[KASMOS::] `). -- Treat lock conflicts, dependency failures, and transition validation errors as first-class user-facing events. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/research/evidence-log.csv b/kitty-specs/011-mcp-agent-swarm-orchestration/research/evidence-log.csv deleted file mode 100644 index 4ded365..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/research/evidence-log.csv +++ /dev/null @@ -1,15 +0,0 @@ -evidence_id,date,source_id,finding,relevance,decision_reference -EVD-01,2026-02-13,SRC-01,"rmcp v0.15 provides #[tool] proc macros that auto-generate JSON Schema from Rust types via schemars. Dependency overlap with kasmos: serde, serde_json, thiserror, tokio, async-trait, tracing, chrono.",MCP SDK selection,Decision 1 -EVD-02,2026-02-13,SRC-02,"rmcp has 3k stars, 460 forks, at version 0.15 with frequent releases. Alternative rust-mcp-sdk at v0.8.3 has lower adoption.",SDK maturity comparison,Decision 1 -EVD-03,2026-02-13,SRC-03,"OpenCode kas profile configures zellij MCP as type:local with command array. Same pattern needed for kasmos serve.",Transport compatibility,Decision 2 -EVD-04,2026-02-13,SRC-04,"kasmos Cargo.toml already includes all rmcp core dependencies. Only rmcp itself and schemars need adding.",Dependency impact assessment,Decision 1 -EVD-05,2026-02-13,SRC-05,"Zellij swap_tiled_layout supports max_panes/min_panes/exact_panes constraints. Commands in swap layouts are positioning hints only (won't create/close panes).",Layout architecture,Decision 3 -EVD-06,2026-02-13,SRC-06,"layout.rs LayoutGenerator uses kdl v6.5 with grid_dimensions() and zjstatus theme generation. generate_wave_tab() pattern already creates per-wave tab layouts.",Code reuse for layout generation,Decision 3 -EVD-07,2026-02-13,SRC-07,"Zellij 0.43.1 supports: new-tab --layout --name, new-pane --name --cwd --, close-pane, rename-pane, dump-layout, remote --session control.",Zellij CLI capabilities,Decision 10 -EVD-08,2026-02-13,SRC-08,"zellij-pane-tracker run_in_pane uses focus-cycling + ASCII byte injection + 200ms delay. No completion detection. Focus-cycling is visible to user.",Communication mechanism analysis,Decision 4 & 5 -EVD-09,2026-02-13,SRC-09,"MCP server is ~800 lines TypeScript, well-structured with clear tool definitions. Missing: close_pane, rename_pane, list_tabs, concurrency lock.",MCP server extensibility,Decision 4 -EVD-10,2026-02-13,SRC-10,"Spec defines FR-026 (log to message-log) and FR-027 (persist audit log). WP entity uses task file lanes as SSOT. FR-028-031 define per-role context profiles.",Spec requirements alignment,Decisions 5-9 -EVD-11,2026-02-13,SRC-11,"51 source files analyzed. 12 KEEP (~3350 LOC), 4 ADAPT (~2400), 5 UNWIRE (~2000+), 11 REPLACE (~6600+). Engine.rs 1460 lines of reusable wave/dep/capacity algorithms.",Codebase reuse assessment,Decision 6 -EVD-12,2026-02-13,SRC-12,"Agent instruction files define 4 roles: controller (spec lifecycle), coder (implement+verify), reviewer (4-layer analysis, READ-ONLY), release (merge/finalize).",Agent architecture input,Decision 7 -EVD-13,2026-02-13,SRC-08,"/tmp/zj-pane-names.json is hardcoded global path (not per-session). Multiple sessions overwrite each other. Non-atomic writes (truncate + write).",Multi-session risk,Risk 2 -EVD-14,2026-02-13,SRC-08,"Concurrent MCP calls to dump_pane/run_in_pane will race on focus-cycling navigation. No locking mechanism in current implementation.",Concurrency risk,Risk 1 diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/research/source-register.csv b/kitty-specs/011-mcp-agent-swarm-orchestration/research/source-register.csv deleted file mode 100644 index 15ba174..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/research/source-register.csv +++ /dev/null @@ -1,16 +0,0 @@ -source_id,type,location,description,accessed_date,reliability -SRC-01,repository,https://github.com/modelcontextprotocol/rust-sdk,Official MCP Rust SDK (rmcp) - source code and documentation,2026-02-13,High -SRC-02,registry,https://crates.io/crates/rmcp,"rmcp crate page on crates.io - version history, downloads, metadata",2026-02-13,High -SRC-03,config,/home/kas/.config/opencode/profiles/kas/opencode.jsonc,OpenCode kas profile - agent definitions and MCP server configs,2026-02-13,High -SRC-04,source,/home/kas/dev/kasmos/crates/kasmos/Cargo.toml,kasmos dependency manifest,2026-02-13,High -SRC-05,documentation,Zellij official docs (via context7),Zellij swap layout and KDL layout syntax documentation,2026-02-13,High -SRC-06,source,/home/kas/dev/kasmos/crates/kasmos/src/layout.rs,Current kasmos layout generator source code,2026-02-13,High -SRC-07,cli,zellij --help + action --help,Zellij 0.43.1 CLI capabilities and subcommands,2026-02-13,High -SRC-08,source,/opt/zellij-pane-tracker/mcp-server/index.ts,zellij-pane-tracker MCP server TypeScript source (v0.8.0),2026-02-13,High -SRC-09,source,/opt/zellij-pane-tracker/src/main.rs,zellij-pane-tracker WASM plugin Rust source,2026-02-13,High -SRC-10,spec,/home/kas/dev/kasmos/kitty-specs/011-mcp-agent-swarm-orchestration/spec.md,Feature specification (237 lines; 31 FRs; 8 entities; 10 SCs),2026-02-13,High -SRC-11,analysis,kasmos codebase analysis (51 .rs files),Module-by-module categorization of current kasmos source,2026-02-13,High -SRC-12,config,/home/kas/.config/opencode/profiles/kas/agent/*.md,Agent instruction files (controller/coder/reviewer/release),2026-02-13,High -SRC-13,repository,https://github.com/theslyprofessor/zellij-pane-tracker,zellij-pane-tracker GitHub repository - README and documentation,2026-02-13,High -SRC-14,config,/home/kas/.config/zellij/layouts/opencode-kas.kdl,Current kas Zellij layout definition,2026-02-13,High -SRC-15,config,/home/kas/.config/zellij/config.kdl,Zellij global configuration (locked mode; rose-pine-moon),2026-02-13,High diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/spec.md b/kitty-specs/011-mcp-agent-swarm-orchestration/spec.md deleted file mode 100644 index 7c94101..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/spec.md +++ /dev/null @@ -1,246 +0,0 @@ -# Feature Specification: MCP Agent Swarm Orchestration - -**Feature Branch**: `011-mcp-agent-swarm-orchestration` -**Created**: 2026-02-13 -**Status**: Draft -**Input**: User description: "Pivot kasmos from TUI-based orchestrator to MCP-powered agent swarm using zellij-pane-tracker for inter-agent communication" - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Launch Kasmos Session (Priority: P1) - -A developer runs `kasmos` (optionally with a spec prefix like `kasmos 011`) from any terminal. Kasmos validates that required tools are available, generates a session layout, and opens a Zellij session named "kasmos" with an orchestration tab containing a manager agent in the top-left pane, a message-log pane to its right, a dashboard pane showing live worker status, and an empty worker row below. The manager agent is automatically primed with the project context and, if a spec prefix was provided, binds to that feature specification. `kasmos serve` runs as an MCP stdio subprocess spawned by the manager agent (not as a dedicated pane). Once the layout is ready, the manager greets the user and reports what feature (if any) it has bound to and what the current workflow state is. - -If kasmos is already running inside a Zellij session, it opens a new orchestration tab within the current session instead of creating a new session. - -**Why this priority**: Without session bootstrapping, nothing else works. This is the foundation that creates the agent environment and primes the manager. It replaces the current orchestrator TUI entry point. - -**Independent Test**: Run `kasmos 011` from a terminal outside Zellij. Verify a new Zellij session named "kasmos" opens with the correct layout. Verify the manager agent is active and reports binding to spec 011. Then, from within that session, run `kasmos 012` and verify a new tab opens (not a new session) with a separate manager bound to spec 012. - -**Acceptance Scenarios**: - -1. **Given** a terminal outside Zellij with all dependencies installed, **When** the user runs `kasmos`, **Then** a new Zellij session named "kasmos" launches with an orchestration tab (manager pane, message-log pane, empty worker area), the manager starts `kasmos serve` as its MCP stdio subprocess, and the manager reports readiness. -2. **Given** a terminal outside Zellij, **When** the user runs `kasmos 011`, **Then** the session launches and the manager automatically binds to the `011-*` feature spec and reports the current workflow phase. -3. **Given** a terminal inside an active Zellij session, **When** the user runs `kasmos 011`, **Then** a new named orchestration tab opens within the existing session (no new session created) with the same layout. -4. **Given** the user runs `kasmos` without a spec prefix on the `main` branch, **When** no feature can be inferred from branch or directory, **Then** the CLI presents a selection of available feature specs before any session or tab is created, and launch proceeds only after the user chooses one. -5. **Given** a feature branch like `011-mcp-agent-swarm-orchestration`, **When** the user runs `kasmos` without a spec prefix, **Then** the manager infers the spec prefix `011` from the branch name and binds to it automatically. - ---- - -### User Story 2 - Manager Orchestrates Planning Phase (Priority: P1) - -After binding to a feature spec, the manager agent assesses where the spec is in the planning lifecycle (specify → clarify → plan → analyze → tasks). It presents a summary of the current state and what needs to happen next. Before taking any action, the manager asks the user for explicit confirmation. Upon approval, the manager delegates planning work to worker agents by spawning them in the worker area with appropriate context and commands, monitoring their progress, and collecting results. - -**Why this priority**: The planning phase produces the work packages that drive everything else. Without automated planning orchestration, the system cannot progress to implementation. This story validates the core delegation and monitoring loop. - -**Independent Test**: Start a kasmos session bound to a spec that has `spec.md` but no plan. Verify the manager identifies "clarify" or "plan" as the next step, presents a summary, waits for confirmation, spawns a worker agent with the correct command, monitors completion, and reports the outcome. - -**Acceptance Scenarios**: - -1. **Given** a feature spec at the "draft" stage (spec.md exists, no plan), **When** the manager analyzes the workflow state, **Then** it correctly identifies the next phase (clarify or plan) and presents a summary to the user. -2. **Given** the manager has presented the next planning action, **When** the user confirms, **Then** the manager spawns a worker agent in the worker area with the correct spec-kitty command and context. -3. **Given** a worker agent is executing a planning task, **When** the worker completes successfully, **Then** the manager detects completion via scrollback monitoring and message-log messages, reports the outcome, and identifies the next step. -4. **Given** a worker agent encounters an error during planning, **When** the error is detected via scrollback or message-log, **Then** the manager reports the error to the user and suggests corrective action. -5. **Given** the planning phase is fully complete (all work packages defined), **When** the manager detects this, **Then** it pauses automation and notifies the user that planning is done, asking for confirmation before transitioning to the implementation phase. - ---- - -### User Story 3 - Manager Orchestrates Implementation and Review (Priority: P1) - -Once work packages are available, the manager orchestrates the implementation-and-review cycle. It spawns coder agents for work packages (respecting wave ordering), monitors their progress, detects when implementation is done, transitions work packages to "for_review" status, spawns reviewer agents, processes review outcomes (approved → done, rejected → rework cycle), and manages the overall wave progression. The manager proactively cleans up completed agent panes and spawns new ones as needed. - -**Why this priority**: This is the core value proposition — fully automated implementation and review cycles with human oversight at stage boundaries. It replaces the WaveEngine, CompletionDetector, and SessionManager. - -**Independent Test**: Start a kasmos session with a feature that has defined work packages. Confirm the manager spawns a coder for WP01, monitors it, detects completion, transitions to for_review, spawns a reviewer, and handles the review outcome. Verify wave ordering is respected (WP02 doesn't start until WP01's wave completes if they are in different waves). - -**Acceptance Scenarios**: - -1. **Given** a feature with work packages ready for implementation, **When** the user confirms starting implementation, **Then** the manager spawns coder agents for the first wave's work packages (up to the configured concurrency limit). -2. **Given** a coder agent has completed implementing a work package, **When** the manager detects completion through scrollback polling or message-log, **Then** the manager transitions that work package to "for_review" status and spawns a reviewer agent in a replacement pane. -3. **Given** a reviewer approves a work package, **When** the manager detects the approval, **Then** it transitions the work package to "done" status, cleans up the reviewer pane, and checks if the current wave is complete. -4. **Given** a reviewer rejects a work package with feedback, **When** the manager detects the rejection, **Then** it spawns a new coder agent with the review feedback as context for rework, and the cycle repeats. -5. **Given** all work packages in a wave are "done", **When** the manager detects wave completion, **Then** it starts the next wave or, if all waves are complete, pauses and notifies the user that implementation is finished. -6. **Given** a coder or reviewer agent errors out or is aborted by the user, **When** the manager detects this via scrollback polling, **Then** the manager reports the issue, cleans up the broken pane, and presents recovery options. - ---- - -### User Story 4 - Worker-to-Manager Communication via Message Log (Priority: P1) - -A message-log pane sits to the right of the manager pane (~25% width). Worker agents send structured messages to this pane (status updates, completion signals, error reports) using the zellij-pane-tracker's run-in-pane capability. The manager reads these messages to supplement its scrollback polling, providing faster and more reliable event detection than scrollback-only monitoring. - -**Why this priority**: Without a reliable communication channel, the manager must rely solely on periodic scrollback polling, which is slow and error-prone. The message-log provides an explicit, structured communication path that dramatically reduces missed events and latency. - -**Independent Test**: Spawn a worker agent and have it send a structured message to the message-log pane. Verify the manager can read and parse the message. Verify that the combination of message-log and scrollback provides reliable completion detection. - -**Acceptance Scenarios**: - -1. **Given** a worker agent is active in a pane, **When** it reaches a milestone (task complete, error, needs input), **Then** it sends a structured message to the message-log pane using the zellij MCP run-in-pane tool. -2. **Given** messages have been written to the message-log pane, **When** the manager checks for updates, **Then** it reads and parses all new messages since the last check. -3. **Given** a worker sends a "task_complete" message, **When** the manager reads it, **Then** it triggers the appropriate workflow transition without waiting for the next scrollback poll cycle. -4. **Given** the message-log pane accumulates many messages, **When** the manager reads messages, **Then** it processes them in order and does not miss or duplicate any messages. - ---- - -### User Story 5 - Dynamic Pane Management with Swap Layouts (Priority: P2) - -As workers are spawned and despawned during the workflow, the session layout automatically adjusts. Kasmos generates swap-layout-aware configurations so that adding or removing worker panes causes Zellij to reflow the layout cleanly. The manager area remains fixed while the worker area expands and contracts. Workers are arranged in rows with a configurable maximum per row (default 4), and the layout adapts as the worker count changes. - -**Why this priority**: Without dynamic layout management, spawning and removing workers would result in messy, unusable pane arrangements. Swap layouts make the experience feel polished and professional. This is important but secondary to core orchestration. - -**Independent Test**: Start a session, spawn 1 worker, observe layout. Spawn 3 more workers, observe reflow to a row of 4. Despawn 2, observe reflow. Verify the manager and message-log areas remain stable throughout. - -**Acceptance Scenarios**: - -1. **Given** a session with the manager and message-log only, **When** the first worker is spawned, **Then** a worker row appears below the manager area with a single pane. -2. **Given** a worker row with 4 panes, **When** a 5th worker is spawned, **Then** a second worker row appears below the first (or the layout reflows to accommodate the new count). -3. **Given** multiple workers active, **When** a worker pane is closed, **Then** the remaining panes reflow to fill the space without disrupting the manager area. -4. **Given** the manager spawns workers, **When** the pane count changes, **Then** Zellij automatically applies the appropriate swap layout for that pane count. - ---- - -### User Story 6 - Acceptance, Merge, and Release (Priority: P2) - -After all work packages pass implementation and review, the manager pauses automation and asks the user to confirm transition to the release phase. Upon confirmation, the manager spawns a release agent that performs acceptance testing, merges the feature branch, and handles cleanup. The manager monitors the release agent and reports the outcome. - -**Why this priority**: Release is the final stage of the workflow. It's critical for completing the full lifecycle but is sequentially dependent on implementation and review being complete. - -**Independent Test**: Start with a feature where all work packages are "done". Confirm the manager detects this, pauses, asks for release confirmation, spawns a release agent on approval, and monitors it through completion. - -**Acceptance Scenarios**: - -1. **Given** all work packages in a feature are "done", **When** the manager detects this, **Then** it pauses automation and presents a release-readiness summary to the user. -2. **Given** the user confirms release, **When** the manager proceeds, **Then** it spawns a release agent with the appropriate context (feature branch, target branch, work package summary). -3. **Given** the release agent completes successfully, **When** the manager detects this, **Then** it reports the merge result and any cleanup actions taken. -4. **Given** the release agent encounters a merge conflict or failure, **When** the manager detects this, **Then** it reports the issue and presents options (manual resolution, abort, retry). - ---- - -### User Story 7 - Environment Setup and Validation (Priority: P2) - -A first-time user runs `kasmos setup` to validate that all required tools are installed and properly configured. The setup process checks for the presence of required dependencies (terminal multiplexer, agent runtime, pane-tracking plugin) and the necessary configuration files. It reports any missing dependencies and, where possible, generates default configurations. - -**Why this priority**: Setup reduces friction for new users and ensures the environment is correct before any orchestration is attempted. It prevents cryptic runtime errors. - -**Independent Test**: Run `kasmos setup` in a clean environment with all dependencies. Verify it reports success. Remove one dependency and re-run. Verify it reports the specific missing tool and guidance. - -**Acceptance Scenarios**: - -1. **Given** all dependencies are installed, **When** the user runs `kasmos setup`, **Then** it validates each dependency and reports all checks passed. -2. **Given** a dependency is missing, **When** the user runs `kasmos setup`, **Then** it reports which dependency is missing and provides installation guidance. -3. **Given** configuration files need to be generated, **When** setup detects they don't exist, **Then** it generates sensible defaults and tells the user what was created. - ---- - -### User Story 8 - Status Updates and Transparency (Priority: P2) - -While agents are working, the manager provides periodic status updates to the user. It reports significant events: worker spawned, task completed, review started, review outcome, wave completion, errors encountered, panes cleaned up. The user is never left wondering what's happening — the manager is proactively communicative. - -**Why this priority**: Transparency builds trust in automation. Without status updates, the user has no visibility into what the swarm is doing and cannot make informed decisions about intervention. - -**Independent Test**: Start an implementation cycle. Verify the manager reports when each worker is spawned, when each finishes, when reviews start and complete, and when waves transition. Verify errors are reported promptly. - -**Acceptance Scenarios**: - -1. **Given** the manager spawns a worker agent, **When** the spawn completes, **Then** the manager reports which work package the worker is handling and in which pane. -2. **Given** a worker completes a task, **When** the manager detects this, **Then** it reports the completion and the next action it will take. -3. **Given** an error occurs in any worker, **When** the manager detects it, **Then** it reports the error immediately with context about which work package and what went wrong. -4. **Given** the user has been idle for a configurable period during active work, **When** the period elapses, **Then** the manager provides a summary of overall progress. -5. **Given** workers are active in the orchestration tab, **When** the event polling loop executes, **Then** the dashboard pane displays an updated status table showing each worker's role, assigned WP, current state, and elapsed time. - ---- - -### Edge Cases - -- What happens when the Zellij session is terminated unexpectedly while workers are active? The next `kasmos` invocation reconstructs workflow state from spec-kitty task file lanes (the single source of truth), detects any WPs left in "active" or "for_review" states, and presents the user with options to resume or reset those WPs. -- What happens when the pane-tracking service becomes unresponsive? The manager should detect connection failures, report them, and degrade gracefully (e.g., fall back to slower polling or pause automation). -- What happens when multiple `kasmos` instances (tabs, sessions, or processes) try to manage the same spec at once? The system should detect the conflict and refuse new bindings, directing the user to the already-active owner. -- What happens when a worker agent's pane is manually closed by the user? The manager should detect the missing pane on its next poll and handle it as an abort — reporting the loss and offering to respawn or skip. -- What happens when the message-log pane fills up with thousands of messages? The manager should handle message parsing efficiently and not slow down. Old messages beyond a threshold can be considered consumed and ignored. -- What happens when a work package has no clear completion signal in scrollback? The manager should use a timeout-based heuristic plus message-log as the primary detection mechanism, with configurable timeout before flagging the work package for user attention. -- What happens when the user runs `kasmos` but there are no feature specs in the repository? The CLI should report that no specs were found, provide guidance to create one, and exit before creating any session or tab. -- What happens when the layout generation fails? The system should report the error clearly and fall back to a minimal layout (manager + message-log only). -- What happens when required dependencies are missing at launch time? The system should fail before creating any session or tab, print actionable install guidance for each missing dependency, and exit with a non-zero code. -- What happens when the manager's `kasmos serve` MCP stdio subprocess crashes while workers are active? The manager should detect the MCP disconnect, pause all automation, and notify the user with steps to restart the orchestration flow. -- What happens when a review-rejection-rework cycle loops more than a configurable number of times? The manager should pause automation and escalate to the user after the configured maximum (default: 3 iterations). - -## Clarifications - -### Session 2026-02-13 - -- Q: Who starts kasmos serve and how is it managed? → A: kasmos serve runs as an MCP stdio subprocess spawned by the manager agent. No dedicated MCP tab is used for serve process hosting. -- Q: Where is work package state stored and how is it recovered after a crash? → A: State is derived from existing spec-kitty task file lanes (single source of truth). No separate state store. On restart, the manager reconstructs workflow state by reading task files, preventing drift between kasmos and spec-kitty. -- Q: Where does the manager's decision audit trail live? → A: Hybrid approach — manager logs significant decisions to the message-log pane (real-time visibility alongside worker messages) AND kasmos serve persists an orchestration audit log to the feature directory (survives session closure, committed to git with spec artifacts). -- Q: What context/memory should each agent role preload? → A: Gradient by role — Manager: broadest (full spec, plan, task board, architecture memory, project structure). Coder: narrowest (WP task file as contract, coding standards, scoped architecture memory only). Reviewer: medium (WP task file, coder's changes, acceptance criteria, standards, scoped architecture). Release: broad structural (all WP statuses, branch structure, merge target, other active features, commit conventions). - -### Session 2026-02-14 - -- Q: What is the canonical runtime model for `kasmos serve`? -> A: `kasmos serve` runs as an MCP stdio subprocess spawned by the manager agent; no dedicated MCP tab process is created. -- Q: Where should the persistent orchestration audit log live for each bound feature? -> A: In the bound feature directory at `kitty-specs//.kasmos/messages.jsonl`. -- Q: If a required dependency is missing when the user runs `kasmos` (launch command), what should the system do? -> A: Hard fail before launch with actionable error and non-zero exit. -- Q: For duplicate feature binding prevention (`FR-020`), what lock scope should apply? -> A: Repository-wide across all running processes. -- Q: When no feature can be inferred (`FR-005`), where should feature selection occur? -> A: In the CLI before any session or tab is created. -- Q: How do kasmos WP states map to spec-kitty task file lanes? -> A: Kasmos uses its own orchestration vocabulary (`pending`, `active`, `for_review`, `done`, `rework`) and translates at the spec-kitty file I/O boundary. Mapping: `pending` <-> `planned`, `active` <-> `doing`, `for_review` <-> `for_review` (shared), `done` <-> `done` (shared), `rework` -> `doing` (with rework context preserved in the audit log, not the lane name). - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The system MUST launch a named session with an orchestration tab when run from outside an existing session. The orchestration tab contains the manager pane, message-log pane, dashboard pane, and worker area. -- **FR-002**: The system MUST open a new orchestration tab within the current session when run from inside an existing session. -- **FR-003**: The system MUST accept an optional spec prefix argument to bind to a specific feature specification. -- **FR-004**: The system MUST infer the spec prefix from the current git branch name when no prefix is provided and the branch matches a known spec pattern. -- **FR-005**: The system MUST present a feature selector in the CLI when no spec prefix is provided and no spec can be inferred from the environment. Selection MUST complete before any session or tab is created. -- **FR-006**: The system MUST provide a local service (`kasmos serve`) that exposes orchestration capabilities to the manager agent, including spawning workers, despawning workers, listing workers, reading messages, querying workflow status, transitioning work packages, listing features, and inferring features from the environment. -- **FR-007**: The system MUST generate layout configurations that support automatic reflow when panes are added or removed. -- **FR-008**: The manager agent MUST assess the current workflow phase of the bound feature and present a summary to the user before taking action. -- **FR-009**: The manager agent MUST request explicit user confirmation before beginning any workflow stage (planning, implementation/review, release). -- **FR-010**: The manager agent MUST spawn worker agents with role-specific context (as defined in FR-028 through FR-033), role assignment, and commands for the current task. -- **FR-011**: The manager agent MUST monitor worker agents via message-log reading and scrollback polling to detect completion, errors, and other actionable events. -- **FR-012**: The manager agent MUST transition work packages through their lifecycle states (pending → active → for_review → done/rework) by updating the corresponding spec-kitty task file lanes — these artifacts are the single source of truth for WP state. -- **FR-013**: The manager agent MUST respect wave ordering — work packages in later waves do not start until all work packages in earlier waves are complete. -- **FR-014**: The manager agent MUST spawn reviewer agents when a work package transitions to "for_review" status. -- **FR-015**: The manager agent MUST handle review rejections by spawning new coder agents with the review feedback for rework. -- **FR-016**: The manager agent MUST pause automation when transitioning between the three major workflow stages (planning, implementation/review, release). -- **FR-017**: The manager agent MUST actively clean up worker panes that are no longer needed while preserving any panes still in use. -- **FR-018**: The manager agent MUST proactively report status updates for significant events (spawns, completions, errors, wave transitions). -- **FR-019**: Worker agents MUST send structured messages to the message-log pane to communicate status, completion, and errors back to the manager. -- **FR-020**: The system MUST detect and prevent duplicate bindings to the same feature spec repository-wide across all running kasmos processes (including different tabs and sessions), and refuse new bind attempts while an active owner exists. -- **FR-021**: The system MUST validate that all required dependencies are available before launching. If any dependency is missing, launch MUST fail before creating sessions or tabs, print actionable install guidance, and return a non-zero exit code. -- **FR-022**: The system MUST provide a setup command for first-time environment validation and configuration generation. -- **FR-023**: The system MUST cap the review-rejection-rework cycle at a configurable maximum (default: 3 iterations) before pausing for user intervention. -- **FR-024**: The system MUST preserve existing TUI code in a disconnected state (not deleted, just unwired from entry points) for potential future reintegration. -- **FR-025**: All agent interactions MUST use a single agent runtime (OpenCode), regardless of the underlying model, to maintain a consistent execution model. -- **FR-026**: The manager agent MUST log significant orchestration decisions (worker spawns, WP transitions, pane cleanups, error handling actions) to the message-log pane for real-time user visibility. -- **FR-027**: The kasmos serve service MUST persist an orchestration audit log in the bound feature directory at `kitty-specs//.kasmos/messages.jsonl`, recording timestamped manager actions that survive session closure and are committed to version control with the rest of the spec artifacts. -- **FR-028**: The manager agent MUST be provisioned with the broadest context: the full feature spec, plan, complete task board, project architecture memory, workflow intelligence, and project structure overview — sufficient to make informed routing and orchestration decisions. -- **FR-029**: Coder agents MUST be provisioned with narrow, task-focused context: the specific work package task file (as their contract), project coding standards and constitution, and architecture memory scoped to the relevant subsystems only. Coders MUST NOT receive the full spec, other work packages, or the plan. -- **FR-030**: Reviewer agents MUST be provisioned with medium-breadth context: the work package task file (to know what was requested), the changes produced by the coder, the spec's acceptance criteria for that work package, project coding standards and constitution, and architecture memory for the affected areas. -- **FR-031**: Release agents MUST be provisioned with broad structural context: all work package statuses, the feature branch structure, merge target, spec summary, awareness of other active features in the repository (for conflict detection), and project conventions for commits and changelogs. -- **FR-032**: The system MUST maintain a dashboard pane in the orchestration tab that displays a live worker status table (role, assigned WP, current state, elapsed time). The dashboard MUST be updated as a side effect of the event polling loop, not on a separate timer. -- **FR-033**: Planner agents MUST be provisioned with medium-broad context: the full feature spec, plan (if it exists), workflow state, project architecture memory, and workflow intelligence. Planners MUST NOT receive individual work package task files or implementation-level coding standards, as their role is strategic (running spec-kitty lifecycle commands) rather than tactical. - -### Key Entities - -- **Session**: A kasmos workspace within the terminal multiplexer with one or more orchestration tabs (each containing one manager, one message-log, one dashboard, and zero or more workers). For each manager, `kasmos serve` runs as an MCP stdio subprocess. Attributes: session name, tab names, bound feature spec, active workflow phase, worker inventory. -- **Manager Agent**: The controller agent occupying the primary pane. Holds workflow state, makes orchestration decisions, communicates with workers. Provisioned with the broadest context (full spec, plan, task board, architecture memory, project structure). Attributes: bound feature, current phase, active workers, pending actions. -- **Worker Agent**: A planner, coder, reviewer, or release agent in the worker area. Has a specific task assignment and communicates via message-log. Each role receives a different context profile: planners get medium-broad strategic context, coders get narrow task-focused context, reviewers get medium-breadth context including changes and acceptance criteria, release agents get broad structural context across the feature. Attributes: pane ID, role (planner/coder/reviewer/release), assigned work package, status (active/complete/errored/aborted), context profile. -- **Message Log**: A dedicated pane serving as the shared communication channel. Contains structured messages from both workers (status updates, completion signals) and the manager (orchestration decisions, transitions, cleanup actions), each with timestamps, sender IDs, and event types. -- **Orchestration Audit Log**: A persistent file at `kitty-specs//.kasmos/messages.jsonl` recording timestamped manager actions (spawns, transitions, cleanups, errors). Committed to version control alongside spec artifacts for post-mortem analysis and project history. -- **Work Package (WP)**: A unit of work from the feature plan. Progresses through states: pending -> active -> for_review -> done (or rework loop). Belongs to a wave for ordering. State is stored in spec-kitty task file lanes (single source of truth); the system does not maintain a separate state store. Kasmos uses its own state vocabulary (pending/active/for_review/done/rework) and translates to spec-kitty lane names (planned/doing/for_review/done) at the file I/O boundary. -- **Wave**: An ordered group of work packages that can execute concurrently. All WPs in wave N must complete before wave N+1 begins. -- **Feature Spec**: A specification in the specs directory containing spec document, plan, tasks, and metadata. The unit of work that kasmos orchestrates. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: A user can go from running the launch command to having a fully operational manager agent session within 10 seconds. -- **SC-002**: The manager detects worker completion events within 15 seconds of the event occurring (via message-log or scrollback polling). -- **SC-003**: The full planning phase (specify through tasks) can be completed with no more than 3 user confirmations (one per sub-phase transition). -- **SC-004**: The implementation-and-review cycle for a single work package (code → review → approve) completes without manual intervention when the review passes on first attempt. -- **SC-005**: The system correctly handles at least 4 concurrent worker agents without layout degradation or missed events. -- **SC-006**: 100% of stage transitions (planning → implementation, implementation → release) pause for user confirmation — no silent phase jumps. -- **SC-007**: When a worker errors out, the manager detects and reports it to the user within 30 seconds. -- **SC-008**: The existing TUI code compiles and passes tests after being disconnected, confirming no destructive changes were made. -- **SC-009**: A clean setup command run on a properly configured machine completes within 5 seconds and reports all-green. -- **SC-010**: The system supports the full workflow lifecycle (specify → clarify → plan → analyze → tasks → implement → review → release) end-to-end through the agent swarm without falling back to manual file editing or pipe-based commands. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks.md deleted file mode 100644 index 8666f0d..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks.md +++ /dev/null @@ -1,505 +0,0 @@ -# Work Packages: MCP Agent Swarm Orchestration - -**Inputs**: Design documents from `/kitty-specs/011-mcp-agent-swarm-orchestration/` -**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `quickstart.md`, `contracts/kasmos-serve.json` -**Tests**: Include unit and integration coverage for launch preflight, lock behavior, audit retention, MCP tool contracts, and feature selection gating before tab/session creation. - -**Organization**: 75 fine-grained subtasks (`T001`-`T075`) roll up into 12 focused work packages (`WP01`-`WP12`). Target size is 3-7 subtasks per work package. - -**Prompt Files**: Each work package references a matching prompt file in `/tasks/` generated by `/spec-kitty.tasks`. - -## Subtask Format: `[Txxx] [P?] Description` -- **[P]** indicates the subtask can proceed in parallel (different files/components). -- Paths are repository-relative. - ---- - -## Phase 0: CLI Pivot and Core Foundation - -## Work Package WP01: CLI Pivot and Legacy TUI Gating (Priority: P0) - -**Goal**: Replace legacy orchestration entry points with the new command surface (`kasmos [spec-prefix]`, `kasmos serve`, `kasmos setup`, `kasmos list`, `kasmos status`) while preserving old TUI code behind a feature gate (FR-024). -**Independent Test**: `cargo build` succeeds with default features, `cargo build --features tui` succeeds, and `kasmos --help` shows the new command topology. -**Prompt**: `/tasks/WP01-cli-pivot-and-legacy-tui-gating.md` -**Estimated prompt size**: ~320 lines - -### Included Subtasks -- [x] T001 Add required dependencies for MCP and schema tooling in `crates/kasmos/Cargo.toml` (`rmcp`, `schemars`, `regex`) and define `tui` feature gating -- [x] T002 Mark TUI-only dependencies (`ratatui`, `crossterm`, `futures-util`) as optional and gate legacy modules in `crates/kasmos/src/main.rs`/`crates/kasmos/src/lib.rs` -- [x] T003 Replace clap command model in `crates/kasmos/src/main.rs` with new launcher-first surface (`None => launch`, `Serve`, `Setup`, `List`, `Status`) -- [x] T004 [P] Create module stubs `crates/kasmos/src/launch/mod.rs`, `crates/kasmos/src/serve/mod.rs`, and `crates/kasmos/src/setup/mod.rs` -- [x] T005 Preserve list/status behavior while removing old default hub entrypoint and unwiring `start/cmd/attach/stop` -- [x] T006 Validate compile matrix and update any broken imports caused by feature gating - -### Implementation Notes -- Keep existing legacy TUI files in the repository; only disconnect from default command wiring. -- Ensure default invocation no longer launches hub TUI. -- Avoid deleting useful non-TUI utilities while removing command wiring. - -### Parallel Opportunities -- T004 can run in parallel once command routing shape from T003 is agreed. - -### Dependencies -- None (root package). - -### Risks and Mitigations -- Risk: accidental removal of legacy TUI code instead of feature-gating it. Mitigation: gate and preserve, do not delete. -- Risk: clap regressions breaking `kasmos list`/`kasmos status`. Mitigation: smoke-test both commands immediately after refactor. - ---- - -## Work Package WP02: Config, Feature Resolution, and Launch Preflight (Priority: P0) - -**Goal**: Implement new config model, deterministic feature resolution, CLI feature selector fallback, and launch preflight hard-fail checks before any tab/session creation. -**Independent Test**: On `main` with no inferable feature, `kasmos` prompts selector before launching Zellij; with missing dependency, launch exits non-zero and no tab/session is created. -**Prompt**: `/tasks/WP02-config-feature-resolution-and-preflight.md` -**Estimated prompt size**: ~360 lines - -### Included Subtasks -- [x] T007 Define sectioned config structs in `crates/kasmos/src/config.rs` (`AgentConfig`, `CommunicationConfig`, `PathsConfig`, `SessionConfig`, `AuditConfig`, `LockConfig`) -- [x] T008 Implement config loading precedence (defaults -> `kasmos.toml` at repo root -> env overrides) and validation -- [x] T009 Implement reusable feature detection pipeline (`arg -> branch -> directory -> none`) in `crates/kasmos/src/launch/detect.rs` and integrate `crates/kasmos/src/feature_arg.rs` -- [x] T010 Implement CLI feature selector path when feature cannot be inferred, guaranteed before any Zellij actions -- [x] T011 Implement launch dependency preflight checks with actionable guidance and non-zero exit (`zellij`, `opencode`, pane-tracker tooling, `spec-kitty`) -- [x] T012 Implement "no specs found" early-exit path before launch -- [x] T013 Add unit tests for config loading/validation, feature detection ambiguity, selector gate, and preflight hard-fail behavior - -### Implementation Notes -- Locked requirement: selector must happen before session/tab creation. -- Preflight is shared by `kasmos` launch and `kasmos setup` output messaging. -- Include default stale timeout in config (15 minutes). - -### Parallel Opportunities -- T009 and T011 can proceed in parallel once config surface from T007/T008 is stable. - -### Dependencies -- Depends on WP01. - -### Risks and Mitigations -- Risk: selector path accidentally launching Zellij before user picks a feature. Mitigation: enforce gate in launch orchestration entry function. -- Risk: brittle dependency checks across Linux/macOS. Mitigation: centralize check logic and use consistent actionable hints. - ---- - -## Phase 1: Launch Topology and MCP Runtime Skeleton - -## Work Package WP03: Launch Layout and Session Bootstrap (Priority: P1) - -**Goal**: Implement launch flow that creates the orchestration tab layout, supports inside/outside Zellij behavior, and primes manager startup instructions aligned with the manager-spawned `kasmos serve` subprocess model. -**Independent Test**: `kasmos 011` from outside Zellij creates session `kasmos`; running inside Zellij creates a new orchestration tab; no dedicated MCP hosting tab/process is created. -**Prompt**: `/tasks/WP03-launch-layout-and-session-bootstrap.md` -**Estimated prompt size**: ~340 lines - -### Included Subtasks -- [x] T014 Implement `crates/kasmos/src/launch/layout.rs` for manager pane, message-log pane, dashboard pane, and dynamic worker area layout generation with swap-layout KDL blocks for pane counts 2 through max_workers+2, ensuring automatic reflow on pane add/remove per FR-007 and US5 -- [x] T015 Implement `crates/kasmos/src/launch/session.rs` for outside-Zellij session creation vs inside-Zellij tab creation behavior -- [x] T016 Ensure runtime model is manager-spawned MCP stdio subprocess; remove any dedicated MCP tab assumptions from launch logic -- [x] T017 Implement manager initial prompt seed including bound feature, phase assessment instruction, and confirmation-first behavior -- [x] T018 Add minimal-layout fallback path (manager + message-log) when advanced layout generation fails -- [x] T019 Wire `crates/kasmos/src/launch/mod.rs` end-to-end (config -> detect/select feature -> preflight -> layout -> launch) -- [x] T020 Add launch integration tests covering explicit feature arg, branch inference, and selector path behavior - -### Implementation Notes -- Keep session name deterministic (`kasmos`) unless existing naming policy demands suffixing. -- Ensure launch does not mutate WP state; only establishes orchestration runtime. - -### Parallel Opportunities -- T017 can proceed in parallel with T014/T015 once required prompt inputs are known. - -### Dependencies -- Depends on WP02. - -### Risks and Mitigations -- Risk: Zellij command differences across environments. Mitigation: encapsulate shell calls in `zellij.rs` wrappers with robust error mapping. -- Risk: launch fallback masking real layout bugs. Mitigation: log fallback reason and keep test coverage for normal layout path. - ---- - -## Work Package WP04: MCP Serve Bootstrap and Contract Wiring (Priority: P1) - -**Goal**: Stand up `kasmos serve` as an RMCP stdio server with all 9 contract-defined tools registered and schema-validated. -**Independent Test**: `kasmos serve` responds to `tools/list` with 9 tools that match `contracts/kasmos-serve.json` schema shape. -**Prompt**: `/tasks/WP04-mcp-serve-bootstrap-and-contracts.md` -**Estimated prompt size**: ~350 lines - -### Included Subtasks -- [x] T021 Implement `crates/kasmos/src/serve/mod.rs` server bootstrap with stdio transport -- [x] T022 Define shared server state (`WorkerRegistry`, message cursor, lock handler, audit policy, config snapshot) -- [x] T023 Implement typed request/response structs for all tool handlers to align with `contracts/kasmos-serve.json` -- [x] T024 [P] Fully implement `list_features` tool against `kitty-specs/` -- [x] T025 [P] Fully implement `infer_feature` tool using shared feature detection logic -- [x] T026 Add contract-level tests for tool registration and standard error code responses - -### Implementation Notes -- Preserve exact error codes from contract (`FEATURE_LOCK_CONFLICT`, `STALE_LOCK_CONFIRMATION_REQUIRED`, etc.). -- Keep tool modules in `crates/kasmos/src/serve/tools/` with clear ownership boundaries. - -### Parallel Opportunities -- T024 and T025 are parallel-safe once shared detection utilities are available. - -### Dependencies -- Depends on WP02. - -### Risks and Mitigations -- Risk: contract drift between code and JSON spec. Mitigation: enforce schema snapshots in tests. -- Risk: incomplete input validation in early handlers. Mitigation: centralize validation helpers for all tools. - ---- - -## Phase 2: Safety, State, and Audit Guarantees - -## Work Package WP05: Repository-Wide Feature Locking (Priority: P1) - -**Goal**: Enforce single active owner per feature across the repository, with stale lock handling that requires explicit takeover confirmation after timeout. -**Independent Test**: Second process binding same feature receives conflict; stale lock older than 15 minutes requires explicit confirmation before takeover. -**Prompt**: `/tasks/WP05-repository-feature-locking.md` -**Estimated prompt size**: ~330 lines - -### Included Subtasks -- [x] T027 Implement lock key derivation `::` and canonical repo-root resolution -- [x] T028 Implement persistent lock record with owner/session metadata, acquired/heartbeat/expires timestamps -- [x] T029 Enforce conflict response path for active locks with actionable owner details -- [x] T030 Implement stale detection using configurable timeout (default 15 minutes) -- [x] T031 Implement confirmation-gated stale takeover flow and explicit rejection path when confirmation absent -- [x] T032 Add tests for lock acquisition, heartbeat refresh, stale transition, takeover confirmation, and conflict errors - -### Implementation Notes -- Lock scope is repository-wide across processes, tabs, and sessions. -- Integrate lock checks in launch binding path and serve tool operations that mutate feature state. - -### Parallel Opportunities -- T029 and T030 can run in parallel once record schema from T028 exists. - -### Dependencies -- Depends on WP04. - -### Risks and Mitigations -- Risk: race conditions during concurrent lock acquisition. Mitigation: atomic file ops plus advisory locking. -- Risk: accidental silent takeover. Mitigation: fail closed unless confirmation token is explicit. - ---- - -## Work Package WP06: Audit Log Persistence and Retention Policy (Priority: P1) - -**Goal**: Persist orchestration audit records at `kitty-specs//.kasmos/messages.jsonl` with metadata-default payloads, debug-mode full payload option, and either-threshold retention. -**Independent Test**: Audit file is written per feature; default entries are metadata-only; debug mode includes payload; rotation/pruning triggers when size or age threshold is reached. -**Prompt**: `/tasks/WP06-audit-log-persistence-and-retention.md` -**Estimated prompt size**: ~320 lines - -### Included Subtasks -- [x] T033 Implement audit directory and file bootstrap under feature path (`.kasmos/messages.jsonl`) -- [x] T034 Implement append-only JSONL writer for manager/tool audit entries -- [x] T035 Implement metadata-only default record shaping and debug-mode full payload opt-in -- [x] T036 Implement retention evaluator with either-threshold trigger (size > 512MB OR age > 14 days) -- [x] T037 Integrate audit writes across lock, spawn/despawn, transition, and error paths -- [x] T038 Add tests for payload redaction defaults, debug inclusion, and retention trigger correctness - -### Implementation Notes -- Do not emit full prompts/tool args unless debug mode is enabled. -- Keep audit record schema aligned with `data-model.md` and contract expectations. - -### Parallel Opportunities -- T036 can proceed in parallel with T037 once writer API from T034 is stable. - -### Dependencies -- Depends on WP04. - -### Risks and Mitigations -- Risk: audit writes impacting runtime latency. Mitigation: buffered append and bounded JSON serialization. -- Risk: retention deleting recent diagnostics unexpectedly. Mitigation: deterministic rotation naming and threshold tests. - ---- - -## Work Package WP07: Worker Lifecycle MCP Tools (Priority: P1) - -**Goal**: Implement worker pane lifecycle (`spawn_worker`, `despawn_worker`, `list_workers`) with registry consistency and role-aware runtime behavior. -**Independent Test**: Manager can spawn worker panes, list active workers, and despawn workers while registry and pane state stay consistent. -**Prompt**: `/tasks/WP07-worker-lifecycle-tools.md` -**Estimated prompt size**: ~350 lines - -### Included Subtasks -- [x] T039 Implement `spawn_worker` tool with role, prompt, and pane-name validation -- [x] T040 Implement coder-only worktree provisioning and cleanup handling -- [x] T041 Implement `despawn_worker` with pane close, registry update, and audit event -- [x] T042 Implement `list_workers` with live-pane reconciliation and aborted detection -- [x] T043 Implement max parallel worker enforcement and actionable backpressure response -- [x] T044 Add unit/integration tests for spawn/despawn/list behavior and registry edge cases - -### Implementation Notes -- Keep pane naming deterministic (`-`). -- Use `zellij.rs` wrappers to avoid shell command duplication in tools. - -### Parallel Opportunities -- T040 can run in parallel with T041 once spawn contract is stable. - -### Dependencies -- Depends on WP03, WP05, WP06. - -### Risks and Mitigations -- Risk: pane lifecycle race with manual user pane closure. Mitigation: reconcile on each `list_workers` call. -- Risk: worktree leaks for aborted coders. Mitigation: cleanup hooks plus documented recovery command. - ---- - -## Work Package WP08: Message Log Parsing and Event Waiting (Priority: P1) - -**Goal**: Implement `read_messages` and `wait_for_event` with structured parsing, incremental cursors, timeout behavior, and degraded-mode fallback. -**Independent Test**: Structured messages are parsed in order without duplication; wait blocks until event match or timeout and reports elapsed time correctly. -**Prompt**: `/tasks/WP08-message-log-and-event-waiting.md` -**Estimated prompt size**: ~370 lines - -### Included Subtasks -- [x] T045 Implement structured message parser in `crates/kasmos/src/serve/messages.rs` with ANSI stripping -- [x] T046 Implement `read_messages` cursor semantics (`since_index`, filters, ordered response) -- [x] T047 Implement `wait_for_event` bounded blocking loop with timeout and elapsed reporting -- [x] T048 Implement fallback path when pane-tracker is unavailable (degraded polling + explicit warning) -- [x] T049 Ensure manager decision events are also written to message-log pane for real-time transparency -- [x] T050 Add tests for parser edge cases, duplicate protection, timeout semantics, and degraded mode -- [x] T075 Implement dashboard pane update as a side effect of `wait_for_event` polling loop: format current worker status table (role, WP, state, elapsed time) and write to dashboard pane via zellij (FR-032) - -### Implementation Notes -- Message format is fixed: `[KASMOS::] `. -- Keep parser strict enough to avoid false positives from unrelated pane output. - -### Parallel Opportunities -- T045 and T048 are parallel-safe once shared event enums are in place. - -### Dependencies -- Depends on WP04 and WP06. - -### Risks and Mitigations -- Risk: scrollback noise causing parser misreads. Mitigation: strict prefix matching and JSON parse validation. -- Risk: waiting loop blocking manager progress. Mitigation: hard timeout with explicit status codes. - ---- - -## Work Package WP09: Workflow Status and Transition Controls (Priority: P1) - -**Goal**: Implement `workflow_status` and `transition_wp` against spec-kitty task lanes as SSOT, including transition validation, wave awareness, and review loop caps. -**Independent Test**: Tool reports correct phase/wave snapshots; valid transitions persist; invalid transitions are rejected with explicit errors. -**Prompt**: `/tasks/WP09-workflow-status-and-transitions.md` -**Estimated prompt size**: ~360 lines - -### Included Subtasks -- [x] T051 Implement `workflow_status` artifact scan (`spec.md`, `plan.md`, `tasks.md`, task lanes) with fine-grained phase derivation (including optional `clarifying`/`analyzing` planning phases and distinct `releasing` phase) per expanded WorkflowSnapshot model in `data-model.md` -- [x] T052 Integrate dependency graph wave computation from parsed WP metadata -- [x] T053 Implement `transition_wp` with state-machine validation, actor/reason audit metadata, and lane translation layer (kasmos states `pending/active/for_review/done/rework` -> spec-kitty lanes `planned/doing/for_review/done`; see `data-model.md` Lane Translation Protocol) -- [x] T054 Implement advisory lock protection for task-file writes to prevent concurrent corruption -- [x] T055 Enforce review-rejection loop cap (default 3) and pause-required outcome -- [x] T056 Add tests for phase derivation, transition guards, wave ordering constraints, and concurrent writers - -### Implementation Notes -- Use task lanes as single source of truth (no parallel shadow state). -- Implement bidirectional lane translation per `data-model.md` Lane Translation Protocol: kasmos states (`pending`, `active`, `rework`) map to spec-kitty lanes (`planned`, `doing`, `doing`). `rework` writes as `doing` but preserves rework semantics in the audit log `reason` field. -- `workflow_status` reads spec-kitty lanes and translates back to kasmos vocabulary, using transition history to distinguish `active` from `rework` when both map to `doing`. -- Keep transition errors mapped to contract codes (`TRANSITION_NOT_ALLOWED`). - -### Parallel Opportunities -- T052 and T054 can run in parallel once parser contract for task metadata is finalized. - -### Dependencies -- Depends on WP04. - -### Risks and Mitigations -- Risk: malformed task files breaking status API. Mitigation: robust parse errors with file-level context. -- Risk: race conditions under multi-process orchestration attempts. Mitigation: combine lock manager checks with per-file advisory locks. - ---- - -## Phase 3: Setup UX, Role Context, and End-to-End Hardening - -## Work Package WP10: Setup Command and Launch Hardening (Priority: P1) - -**Goal**: Deliver `kasmos setup` and ensure launch-time preflight behavior remains strict and actionable in all dependency failure scenarios. -**Independent Test**: `kasmos setup` reports all checks on healthy environment, identifies missing dependencies with guidance, and launch exits before creating tabs when checks fail. -**Prompt**: `/tasks/WP10-setup-command-and-launch-hardening.md` -**Estimated prompt size**: ~300 lines - -### Included Subtasks -- [x] T057 Implement `crates/kasmos/src/setup/mod.rs` dependency validation flow with structured output -- [x] T058 Implement idempotent generation of missing baseline config/profile assets -- [x] T059 Ensure launch uses the same preflight engine and exits before any session/tab creation on failures -- [x] T060 Add per-dependency remediation guidance (install hints and expected minimum behavior) -- [x] T061 Ensure non-zero exit code mapping for setup and launch preflight failures -- [x] T062 Add tests for setup pass/fail and launch hard-fail guarantees - -### Implementation Notes -- Keep setup non-destructive unless creating missing defaults. -- Keep dependency checks fast to satisfy startup latency goals. - -### Parallel Opportunities -- T058 can proceed in parallel with T060 after core setup command skeleton (T057) exists. - -### Dependencies -- Depends on WP03. - -### Risks and Mitigations -- Risk: setup and launch checks drift over time. Mitigation: shared preflight library used by both commands. -- Risk: false positives for pane-tracker availability. Mitigation: include functional probe in addition to binary lookup. - ---- - -## Work Package WP11: Agent Profiles and Prompt Context Boundaries (Priority: P1) - -**Goal**: Implement role-specific prompt/context assembly that enforces manager/coder/reviewer/release scope boundaries and OpenCode runtime consistency. -**Independent Test**: Generated prompts for each role include required context and exclude forbidden context per FR-028 to FR-031. -**Prompt**: `/tasks/WP11-agent-profiles-and-prompt-context.md` -**Estimated prompt size**: ~330 lines - -### Included Subtasks -- [x] T063 Define/update OpenCode profile assets under `config/profiles/kasmos/` for manager/planner/coder/reviewer/release -- [x] T064 Rewrite `crates/kasmos/src/prompt.rs` for role-aware context assembly and command generation -- [x] T065 Implement manager bootstrap prompt instructions for assessment, confirmation gates, and `kasmos serve` subprocess ownership -- [x] T066 Implement worker prompt contract for structured message-log communication and escalation events -- [x] T067 Enforce context minimization rules (coder narrow, reviewer medium, release broad structural) -- [x] T068 Add prompt snapshot tests and validation for role-context boundary rules - -### Implementation Notes -- Single agent runtime requirement: OpenCode only for all roles. -- Include references to constitution and feature artifacts in generated prompts. - -### Parallel Opportunities -- T063 and T064 can run in parallel after role contract definitions are finalized. - -### Dependencies -- Depends on WP02. - -### Risks and Mitigations -- Risk: prompts leak unnecessary context to coder role. Mitigation: explicit allowlists and snapshot assertions. -- Risk: manager prompt becomes too vague for deterministic orchestration. Mitigation: codify explicit stage gates and tool usage loops. - ---- - -## Work Package WP12: Integration, Legacy Preservation, and Acceptance Hardening (Priority: P2) - -**Goal**: Validate end-to-end behavior against locked decisions and success criteria, finalize docs alignment, and confirm both default and `tui` feature builds remain healthy. -**Independent Test**: End-to-end scenario verifies selector-before-launch, lock conflicts/stale takeover flow, metadata-default audit logging with retention triggers, and manager-spawned serve subprocess lifecycle. -**Prompt**: `/tasks/WP12-integration-and-acceptance-hardening.md` -**Estimated prompt size**: ~360 lines - -### Included Subtasks -- [x] T069 Verify and preserve legacy TUI compile path (`cargo build --features tui`) while default build uses new launcher flow -- [x] T070 Add integration scenario for duplicate lock conflict and stale takeover confirmation behavior -- [x] T071 Add integration scenario for audit logging modes and retention thresholds (size/age either-threshold) -- [x] T072 Add integration scenario for feature selector pre-launch gate and no-specs early exit -- [x] T073 Align `README.md`, `quickstart.md`, and any launch/setup docs with final command behavior -- [x] T074 Run final verification matrix (`cargo test`, launch/serve manual smoke checks) and capture FR/SC traceability checklist - -### Implementation Notes -- Keep scenario tests deterministic; use temp dirs and mocked clocks where possible. -- Ensure acceptance artifacts explicitly map to locked decisions 1-9. - -### Parallel Opportunities -- T070, T071, and T072 can run in parallel once core runtime work packages complete. - -### Dependencies -- Depends on WP07, WP08, WP09, WP10, WP11. - -### Risks and Mitigations -- Risk: flaky integration tests around terminal tooling. Mitigation: split deterministic unit-level checks from optional environment-dependent smoke tests. -- Risk: docs drifting from implementation. Mitigation: update docs only after final command/help output is stable. - ---- - -## Dependency and Execution Summary - -### Wave Structure - -- **Wave 0**: WP01 (CLI pivot and feature gating foundation) -- **Wave 1**: WP02 (config, detection, selector, preflight) -- **Wave 2**: WP03, WP04, WP11 (launch topology, serve skeleton, role-context system) -- **Wave 3**: WP05, WP06, WP09, WP10 (locking, audit policy, workflow transitions, setup hardening) -- **Wave 4**: WP07, WP08 (worker lifecycle and message/event loop) -- **Wave 5**: WP12 (integration and acceptance hardening) - -### Parallelization Highlights -- WP03 and WP04 can proceed in parallel after WP02. -- WP05 and WP06 can proceed in parallel after WP04. -- WP07 and WP08 can proceed in parallel once locking/audit foundations are complete. -- Within WP12, lock/audit/selector integration scenarios are parallelizable. - -### MVP Scope Recommendation -- **MVP**: WP01 -> WP02 -> WP03 -> WP04 -> WP05 -> WP06. -- This sequence delivers the new launcher, manager-owned serve runtime, repository-wide lock safety, and required audit guarantees. - ---- - -## Subtask Index (Reference) - -| Subtask | Summary | WP | Priority | Parallel? | -|---------|---------|----|----------|-----------| -| T001 | Add MCP/schema deps and features section | WP01 | P0 | No | -| T002 | Gate TUI deps/modules behind `tui` feature | WP01 | P0 | No | -| T003 | Replace clap command surface | WP01 | P0 | No | -| T004 | Create launch/serve/setup module stubs | WP01 | P0 | Yes | -| T005 | Rewire default entrypoint and keep list/status | WP01 | P0 | No | -| T006 | Validate build matrix (default + tui) | WP01 | P0 | No | -| T007 | Define new sectioned config structs | WP02 | P0 | No | -| T008 | Implement config load precedence + validation | WP02 | P0 | No | -| T009 | Implement arg/branch/directory feature detection | WP02 | P0 | No | -| T010 | Implement pre-launch CLI feature selector | WP02 | P0 | No | -| T011 | Implement dependency preflight hard-fail checks | WP02 | P0 | No | -| T012 | Implement no-specs early-exit path | WP02 | P0 | No | -| T013 | Add config/detection/preflight tests | WP02 | P0 | No | -| T014 | Implement layout generator with swap-layout + dashboard | WP03 | P1 | No | -| T015 | Implement session/tab bootstrap behavior | WP03 | P1 | No | -| T016 | Enforce manager-owned serve subprocess model | WP03 | P1 | No | -| T017 | Implement manager initial prompt seed | WP03 | P1 | Yes | -| T018 | Add minimal layout fallback path | WP03 | P1 | No | -| T019 | Wire launch flow end-to-end | WP03 | P1 | No | -| T020 | Add launch integration tests | WP03 | P1 | No | -| T021 | Implement RMCP serve bootstrap | WP04 | P1 | No | -| T022 | Define shared serve state containers | WP04 | P1 | No | -| T023 | Implement typed tool input/output structs | WP04 | P1 | No | -| T024 | Implement `list_features` tool | WP04 | P1 | Yes | -| T025 | Implement `infer_feature` tool | WP04 | P1 | Yes | -| T026 | Add contract-level serve tests | WP04 | P1 | No | -| T027 | Implement repository lock key + root resolution | WP05 | P1 | No | -| T028 | Implement lock record persistence + heartbeat | WP05 | P1 | No | -| T029 | Enforce active lock conflict handling | WP05 | P1 | No | -| T030 | Implement stale timeout detection (15m default) | WP05 | P1 | No | -| T031 | Implement confirmation-gated stale takeover | WP05 | P1 | No | -| T032 | Add lock behavior tests | WP05 | P1 | No | -| T033 | Implement per-feature audit file bootstrap | WP06 | P1 | No | -| T034 | Implement append-only JSONL audit writer | WP06 | P1 | No | -| T035 | Implement metadata default + debug payload mode | WP06 | P1 | No | -| T036 | Implement retention on size/age either-threshold | WP06 | P1 | No | -| T037 | Integrate audit events across core actions | WP06 | P1 | No | -| T038 | Add audit policy tests | WP06 | P1 | No | -| T039 | Implement `spawn_worker` tool | WP07 | P1 | No | -| T040 | Implement coder worktree provisioning | WP07 | P1 | No | -| T041 | Implement `despawn_worker` tool | WP07 | P1 | No | -| T042 | Implement reconciled `list_workers` | WP07 | P1 | No | -| T043 | Enforce worker concurrency limits | WP07 | P1 | No | -| T044 | Add worker lifecycle tests | WP07 | P1 | No | -| T045 | Implement structured message parser | WP08 | P1 | No | -| T046 | Implement `read_messages` cursor semantics | WP08 | P1 | No | -| T047 | Implement `wait_for_event` loop + timeout | WP08 | P1 | No | -| T048 | Implement degraded fallback on pane-tracker failure | WP08 | P1 | No | -| T049 | Log manager decisions to message-log pane | WP08 | P1 | No | -| T050 | Add message/event tool tests | WP08 | P1 | No | -| T051 | Implement `workflow_status` with fine-grained phases | WP09 | P1 | No | -| T052 | Compute waves from dependency graph | WP09 | P1 | Yes | -| T053 | Implement `transition_wp` with validation + lane translation | WP09 | P1 | No | -| T054 | Add advisory lock around task writes | WP09 | P1 | Yes | -| T055 | Enforce rejection loop cap and escalation | WP09 | P1 | No | -| T056 | Add workflow/transition tests | WP09 | P1 | No | -| T057 | Implement `kasmos setup` command | WP10 | P1 | No | -| T058 | Generate baseline config/profile assets idempotently | WP10 | P1 | Yes | -| T059 | Share preflight engine with launch hard-fail path | WP10 | P1 | No | -| T060 | Add per-dependency remediation guidance | WP10 | P1 | Yes | -| T061 | Enforce non-zero exit mappings for failures | WP10 | P1 | No | -| T062 | Add setup and launch hard-fail tests | WP10 | P1 | No | -| T063 | Create/update OpenCode role profiles | WP11 | P1 | No | -| T064 | Rewrite role-based prompt/context assembly | WP11 | P1 | No | -| T065 | Implement manager startup prompt contract | WP11 | P1 | No | -| T066 | Implement worker message protocol prompt rules | WP11 | P1 | No | -| T067 | Enforce context boundary allowlists by role | WP11 | P1 | No | -| T068 | Add prompt snapshot and policy tests | WP11 | P1 | No | -| T069 | Verify legacy TUI preservation and compile path | WP12 | P2 | No | -| T070 | Add lock conflict and stale takeover integration scenario | WP12 | P2 | Yes | -| T071 | Add audit mode and retention integration scenario | WP12 | P2 | Yes | -| T072 | Add selector/no-specs pre-launch integration scenario | WP12 | P2 | Yes | -| T073 | Align README/quickstart/docs with final behavior | WP12 | P2 | No | -| T074 | Run final validation matrix and FR/SC traceability | WP12 | P2 | No | -| T075 | Implement dashboard pane update in polling loop | WP08 | P1 | No | diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/.gitkeep b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP01-cli-pivot-and-legacy-tui-gating.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP01-cli-pivot-and-legacy-tui-gating.md deleted file mode 100644 index 5aeae77..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP01-cli-pivot-and-legacy-tui-gating.md +++ /dev/null @@ -1,312 +0,0 @@ ---- -work_package_id: WP01 -title: CLI Pivot and Legacy TUI Gating -lane: "done" -dependencies: [] -base_branch: main -base_commit: e2efe83d5f7238ed6104250098ac15f90cc6038e -created_at: '2026-02-14T19:22:02.295961+00:00' -subtasks: -- T001 -- T002 -- T003 -- T004 -- T005 -- T006 -phase: Phase 0 - CLI Pivot and Core Foundation -assignee: 'opencode' -agent: "reviewer" -shell_pid: "3419985" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP01 - CLI Pivot and Legacy TUI Gating - -## Important: Review Feedback Status - -**Read this first if you are implementing this task!** - -- **Has review feedback?**: Check the `review_status` field above. If it says `has_feedback`, scroll to the **Review Feedback** section immediately. -- **You must address all feedback** before your work is complete. -- **Mark as acknowledged**: When you understand the feedback and begin addressing it, update `review_status: acknowledged` in the frontmatter. - ---- - -## Review Feedback - -> **Populated by `/spec-kitty.review`** - Reviewers add detailed feedback here when work needs changes. - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP01 -``` - ---- - -## Objectives & Success Criteria - -Replace the legacy orchestration entry points with the new command surface while preserving old TUI code behind a feature gate (FR-024). After this WP: - -1. `cargo build` succeeds with default features (no TUI) - produces a lean MCP-focused binary -2. `cargo build --features tui` succeeds - all legacy TUI code still compiles -3. `kasmos --help` shows the new command topology: `kasmos [spec-prefix]`, `kasmos serve`, `kasmos setup`, `kasmos list`, `kasmos status` -4. Default invocation (`kasmos` with no args) no longer launches the hub TUI -5. Legacy commands (`start`, `stop`, `attach`, `cmd`, `tui-ctrl`, `tui-preview`) are removed from the CLI surface -6. `kasmos list` and `kasmos status` continue to work - -## Context & Constraints - -- **Constitution**: Rust 2024 edition, tokio async, single binary distribution. See `.kittify/memory/constitution.md`. -- **Plan reference**: `kitty-specs/011-mcp-agent-swarm-orchestration/plan.md` - Section "Project Structure" defines the target module layout. -- **FR-024**: Preserve existing TUI code in a disconnected state (not deleted, just unwired from entry points). -- **Spec FR-006**: `kasmos serve` exposes MCP orchestration capabilities to the manager agent. -- **Key constraint**: Do NOT delete any TUI source files. Feature-gate them behind `#[cfg(feature = "tui")]`. - -**Current codebase state** (read these files for accurate context): -- `crates/kasmos/Cargo.toml` (36 lines) - 33 dependencies, no `[features]` section, edition inherited from workspace -- `crates/kasmos/src/main.rs` (208 lines) - 11 binary-only mod declarations, `Cli` struct with `Commands` enum (List, Start, Status, Cmd, Attach, Stop, TuiCtrl, TuiPreview), `bootstrap_start_in_zellij()` helper -- `crates/kasmos/src/lib.rs` (60 lines) - 28 `pub mod` declarations + re-exports - -## Subtasks & Detailed Guidance - -### Subtask T001 - Add MCP and schema dependencies to Cargo.toml - -**Purpose**: Bring in `rmcp` (Rust MCP SDK), `schemars` (JSON Schema generation for tool inputs), and `regex` (message parsing) as new dependencies. - -**Steps**: -1. Open `crates/kasmos/Cargo.toml` -2. Add to `[dependencies]`: - ```toml - rmcp = { version = "0.1", features = ["server", "transport-io"] } - schemars = "0.8" - regex = "1" - ``` -3. **Important**: Check crates.io for the latest `rmcp` version. The plan specifies it but the exact version may differ. The required features are `server` (for `#[tool]` proc macros and `ServerHandler` trait) and `transport-io` (for stdio transport). -4. `schemars` is needed by rmcp's `#[tool]` macro for `#[derive(JsonSchema)]` on tool input structs. -5. `regex` is for parsing structured messages matching `[KASMOS::] `. - -**Files**: `crates/kasmos/Cargo.toml` -**Validation**: `cargo check` resolves the new dependencies without errors. - -### Subtask T002 - Feature-gate TUI dependencies and modules - -**Purpose**: Make TUI-specific dependencies optional and gate TUI module declarations behind `#[cfg(feature = "tui")]` so the default build produces a lean binary. - -**Steps**: -1. Add a `[features]` section to `crates/kasmos/Cargo.toml`: - ```toml - [features] - default = [] - tui = ["dep:ratatui", "dep:crossterm", "dep:futures-util", "dep:tui-popup", "dep:tui-nodes", "dep:tui-logger", "dep:ratatui-macros"] - ``` -2. Mark TUI dependencies as optional in `[dependencies]`: - ```toml - ratatui = { version = "0.30", features = ["crossterm"], optional = true } - ratatui-macros = { version = "0.7", optional = true } - crossterm = { version = "0.29", features = ["event-stream"], optional = true } - tui-popup = { version = "0.7", optional = true } - tui-nodes = { version = "0.10.0", optional = true } - futures-util = { version = "0.3.31", optional = true } - tui-logger = { version = "0.18", features = ["tracing-support"], optional = true } - ``` -3. In `crates/kasmos/src/lib.rs`, gate TUI modules: - ```rust - #[cfg(feature = "tui")] - pub mod tui; - ``` - Also gate any re-exports that reference TUI types. -4. In `crates/kasmos/src/main.rs`, gate TUI-related mod declarations: - ```rust - #[cfg(feature = "tui")] - mod hub; - #[cfg(feature = "tui")] - mod report; - #[cfg(feature = "tui")] - mod tui_cmd; - #[cfg(feature = "tui")] - mod tui_preview; - ``` -5. Gate any `use` statements in other files that import from gated modules. -6. Modules that are used by BOTH TUI and non-TUI paths (like `config`, `list_specs`, `feature_arg`, `types`) remain ungated. - -**Edge cases**: -- The `log` crate is used by `tui-logger` but may also be needed by non-TUI code. Keep it ungated if so. -- If `report.rs` is imported by non-TUI code, those imports need cfg gates too. -- Legacy engine modules (`engine`, `detector`, `session`, `command_handlers`, `commands`, etc.) stay ungated for now - they'll be cleaned up separately. - -**Files**: `crates/kasmos/Cargo.toml`, `crates/kasmos/src/lib.rs`, `crates/kasmos/src/main.rs` -**Validation**: `cargo build` succeeds without TUI deps. `cargo build --features tui` also succeeds. - -### Subtask T003 - Replace clap command model with new launcher-first surface - -**Purpose**: Redesign the CLI from `kasmos start ` to `kasmos [spec-prefix]` as the primary entry point, adding `serve`, `setup`, `list`, and `status` subcommands. - -**Steps**: -1. Rewrite the `Commands` enum in `crates/kasmos/src/main.rs`: - ```rust - #[derive(Subcommand)] - enum Commands { - /// Run MCP server (stdio transport, spawned by manager agent) - Serve, - /// Validate environment and generate default configs - Setup, - /// List available feature specs - List, - /// Show orchestration status for a feature - Status { - /// Feature directory (optional, auto-detects) - feature: Option, - }, - } - ``` -2. Add `spec_prefix` as an optional positional argument to `Cli`: - ```rust - #[derive(Parser)] - #[command(name = "kasmos", version, about = "MCP agent swarm orchestrator")] - struct Cli { - /// Feature spec prefix (e.g., "011") - launches orchestration session - spec_prefix: Option, - #[command(subcommand)] - command: Option, - } - ``` -3. Update `main()` dispatch: - - If `command` is `Some(...)`: dispatch to `Serve`, `Setup`, `List`, `Status` - - If `command` is `None` and `spec_prefix` is `Some(prefix)`: dispatch to launch flow (stub for now - `todo!("Launch with spec prefix")`) - - If both are `None`: dispatch to launch flow with no prefix (stub for now) -4. Remove old commands: `Start`, `Cmd`, `Attach`, `Stop`, `TuiCtrl`, `TuiPreview` -5. Remove `bootstrap_start_in_zellij()` function (legacy) -6. Update `after_help` text to reflect new commands - -**Files**: `crates/kasmos/src/main.rs` -**Validation**: `kasmos --help` shows new topology. `kasmos list` works. `kasmos status` works. - -### Subtask T004 - Create module stubs for launch, serve, and setup - -**Purpose**: Create the directory structure and minimal module stubs that will be populated in subsequent WPs. - -**Steps**: -1. Create `crates/kasmos/src/launch/mod.rs`: - ```rust - //! Launch flow: feature resolution, preflight, layout generation, session bootstrap. - pub mod detect; - pub mod layout; - pub mod session; - - pub async fn run(_spec_prefix: Option<&str>) -> anyhow::Result<()> { - todo!("Launch flow implementation in WP02/WP03") - } - ``` -2. Create `crates/kasmos/src/launch/detect.rs`: - ```rust - //! Feature detection pipeline: arg -> branch -> directory -> none. - ``` -3. Create `crates/kasmos/src/launch/layout.rs`: - ```rust - //! KDL layout generation for orchestration tabs. - ``` -4. Create `crates/kasmos/src/launch/session.rs`: - ```rust - //! Zellij session/tab creation and lifecycle. - ``` -5. Create `crates/kasmos/src/serve/mod.rs`: - ```rust - //! MCP server: kasmos serve (stdio transport). - pub mod registry; - pub mod messages; - pub mod audit; - pub mod dashboard; - pub mod lock; - pub mod tools; - - pub async fn run() -> anyhow::Result<()> { - todo!("MCP serve implementation in WP04") - } - ``` -6. Create `crates/kasmos/src/serve/tools/mod.rs` and stub files for each tool. -7. Create `crates/kasmos/src/setup/mod.rs`: - ```rust - //! Environment validation and default config generation. - pub async fn run() -> anyhow::Result<()> { - todo!("Setup implementation in WP10") - } - ``` -8. Add `pub mod launch;`, `pub mod serve;`, `pub mod setup;` to `crates/kasmos/src/lib.rs`. - -**Files**: New files under `crates/kasmos/src/launch/`, `crates/kasmos/src/serve/`, `crates/kasmos/src/setup/` -**Parallel?**: Yes - can proceed once T003 command routing shape is agreed. -**Validation**: `cargo build` succeeds with new module stubs. - -### Subtask T005 - Preserve list/status and remove old command wiring - -**Purpose**: Keep `kasmos list` and `kasmos status` working while removing old `start`, `stop`, `attach`, `cmd` command wiring. - -**Steps**: -1. The existing `list_specs.rs` (146 lines) and `status.rs` (123 lines) are binary-only modules. Keep them and wire them to the new `Commands::List` and `Commands::Status` variants. -2. Remove (or cfg-gate) the following binary-only modules that are no longer wired: - - `start.rs` (647 lines) - the old orchestration launcher (heavy TUI + engine wiring) - - `stop.rs` (90 lines) - FIFO/SIGTERM stop command - - `attach.rs` (83 lines) - session reattachment - - `cmd.rs` (257 lines) - FIFO command subcommand - - `sendmsg.rs` (69 lines) - FIFO message sender -3. For safety per FR-024: use `#[cfg(feature = "tui")]` on these modules rather than deleting them. This way `cargo build --features tui` still compiles them. -4. Update any imports in `main.rs` that reference removed/gated modules. -5. Verify `kasmos list` still scans `kitty-specs/` and shows unfinished features. -6. Verify `kasmos status` still reads `.kasmos/state.json` and shows WP progress. - -**Files**: `crates/kasmos/src/main.rs`, potentially `crates/kasmos/src/start.rs`, `stop.rs`, `attach.rs`, `cmd.rs`, `sendmsg.rs` -**Validation**: `kasmos list` and `kasmos status` produce correct output. - -### Subtask T006 - Validate compile matrix and fix broken imports - -**Purpose**: Final verification that both build configurations work after all changes. - -**Steps**: -1. Run `cargo build` - must succeed with zero errors -2. Run `cargo build --features tui` - must succeed with zero errors -3. Run `cargo test` - must pass for default features -4. Run `cargo test --features tui` - must pass with TUI features -5. Fix any unused import warnings introduced by cfg gating -6. Fix any dead_code warnings that are new (existing ones are acceptable) -7. Verify `kasmos --help` shows new command topology - -**Files**: Any files with warnings or errors -**Validation**: Zero errors on both build configs, minimal new warnings. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Accidental deletion of TUI code instead of feature-gating | Gate and preserve - never delete. Grep for all `mod hub`, `mod tui`, `use tui::` to ensure complete gating. | -| clap regressions breaking list/status | Smoke-test both commands immediately after CLI refactor. | -| Conditional compilation breaking cross-module imports | Grep for all `use` statements referencing gated modules and add corresponding cfg gates. | -| rmcp version incompatibility | Check crates.io for latest compatible version. Pin to specific minor version. | - -## Review Guidance - -- Verify all TUI deps are `optional = true` and `default = []` (not `default = ["tui"]`) -- Verify every `mod hub`, `mod tui`, `mod tui_cmd`, `mod report`, `mod tui_preview` has `#[cfg(feature = "tui")]` -- Verify old commands (Start, Cmd, Attach, Stop, TuiCtrl, TuiPreview) are removed from CLI surface -- Verify new commands (Serve, Setup, List, Status) are present -- Verify `spec_prefix` positional argument exists on `Cli` struct -- Test both `cargo build` and `cargo build --features tui` -- Verify `kasmos list` and `kasmos status` still work - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-14T20:40:54Z – coder – shell_pid=3202027 – lane=for_review – Submitted for review via swarm -- 2026-02-14T20:40:54Z – reviewer – shell_pid=3419985 – lane=doing – Started review via workflow command -- 2026-02-14T20:44:35Z – reviewer – shell_pid=3419985 – lane=done – Review passed via swarm diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP02-config-feature-resolution-and-preflight.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP02-config-feature-resolution-and-preflight.md deleted file mode 100644 index 64e0768..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP02-config-feature-resolution-and-preflight.md +++ /dev/null @@ -1,311 +0,0 @@ ---- -work_package_id: WP02 -title: Config, Feature Resolution, and Launch Preflight -lane: "done" -dependencies: [WP01] -base_branch: 011-mcp-agent-swarm-orchestration-WP01 -base_commit: e8f430fb6a020367f628f9d80fbcec56c22b7d6a -created_at: '2026-02-14T20:48:46.329430+00:00' -subtasks: -- T007 -- T008 -- T009 -- T010 -- T011 -- T012 -- T013 -phase: Phase 0 - CLI Pivot and Core Foundation -assignee: 'opencode' -agent: "reviewer" -shell_pid: "3639715" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP02 - Config, Feature Resolution, and Launch Preflight - -## Important: Review Feedback Status - -**Read this first if you are implementing this task!** - -- **Has review feedback?**: Check the `review_status` field above. If it says `has_feedback`, scroll to the **Review Feedback** section immediately. -- **You must address all feedback** before your work is complete. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP02 --base WP01 -``` - ---- - -## Objectives & Success Criteria - -Implement the new sectioned config model, deterministic feature resolution pipeline, CLI feature selector fallback, and launch preflight hard-fail checks. After this WP: - -1. Config loads with precedence: defaults -> `kasmos.toml` -> env overrides -2. Feature detection pipeline resolves: arg -> branch -> directory -> none -3. When no feature can be inferred, a CLI selector appears BEFORE any Zellij session/tab creation (FR-005) -4. When a required dependency is missing, launch exits non-zero with actionable guidance BEFORE creating any session/tab (FR-021) -5. When no feature specs exist in the repository, CLI reports this and exits cleanly - -## Context & Constraints - -- **Depends on WP01**: New CLI surface with `spec_prefix` positional arg and module stubs -- **Plan reference**: `kitty-specs/011-mcp-agent-swarm-orchestration/plan.md` - Engineering Alignment decisions 3, 7, 9 -- **Research decisions**: Missing deps = hard fail before launch. Feature selection = CLI before session/tab creation. -- **Existing code**: `crates/kasmos/src/config.rs` (386 lines) has the current flat `Config` struct with env/file/validation. `crates/kasmos/src/feature_arg.rs` (88 lines) has `resolve_feature_dir()` for prefix matching. - -## Subtasks & Detailed Guidance - -### Subtask T007 - Define sectioned config structs - -**Purpose**: Replace the flat `Config` struct with a sectioned model that supports the new MCP-focused configuration needs (agent settings, communication, paths, session, audit, lock policies). - -**Steps**: -1. Restructure `crates/kasmos/src/config.rs` with nested sections: - ```rust - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct Config { - pub agent: AgentConfig, - pub communication: CommunicationConfig, - pub paths: PathsConfig, - pub session: SessionConfig, - pub audit: AuditConfig, - pub lock: LockConfig, - } - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct AgentConfig { - pub max_parallel_workers: usize, // default: 4 - pub opencode_binary: String, // default: "ocx" - pub opencode_profile: Option, // default: Some("kas") - pub review_rejection_cap: u32, // default: 3 (FR-023) - } - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct CommunicationConfig { - pub poll_interval_secs: u64, // default: 5 - pub event_timeout_secs: u64, // default: 300 - } - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct PathsConfig { - pub zellij_binary: String, // default: "zellij" - pub spec_kitty_binary: String, // default: "spec-kitty" - pub specs_root: String, // default: "kitty-specs" - } - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct SessionConfig { - pub session_name: String, // default: "kasmos" - pub manager_width_pct: u32, // default: 60 - pub message_log_width_pct: u32, // default: 20 - pub max_workers_per_row: usize, // default: 4 - } - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct AuditConfig { - pub metadata_only: bool, // default: true - pub debug_full_payload: bool, // default: false - pub max_bytes: u64, // default: 536_870_912 (512MB) - pub max_age_days: u32, // default: 14 - } - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct LockConfig { - pub stale_timeout_minutes: u64, // default: 15 - } - ``` -2. Provide sensible defaults via `Default` implementations for each section. -3. Keep the old `Config` struct available behind `#[cfg(feature = "tui")]` if needed for TUI compatibility, or migrate the existing fields into the new structure. - -**Files**: `crates/kasmos/src/config.rs` -**Validation**: `cargo build` succeeds. Default config validates successfully. - -### Subtask T008 - Implement config loading precedence and validation - -**Purpose**: Support layered config loading: defaults -> `kasmos.toml` at repo root -> env overrides. - -**Steps**: -1. Add a `Config::load()` method that: - - Starts from `Config::default()` - - Attempts to load `kasmos.toml` from repo root (or current directory). Use the existing `load_from_file` pattern but adapted for TOML sections. - - Applies env overrides using `KASMOS_` prefix (e.g., `KASMOS_AGENT_MAX_PARALLEL_WORKERS`, `KASMOS_LOCK_STALE_TIMEOUT_MINUTES`) - - Calls `validate()` at the end -2. Update validation to check new fields: - - `max_parallel_workers` in range 1..=16 - - `manager_width_pct` in range 10..=80 - - `stale_timeout_minutes` >= 1 - - `max_age_days` >= 1 - - `review_rejection_cap` >= 1 -3. Handle the TOML file being absent gracefully (not an error, just use defaults + env). - -**Files**: `crates/kasmos/src/config.rs` -**Validation**: Config loads with partial TOML + env overrides. Invalid values produce clear errors. - -### Subtask T009 - Implement feature detection pipeline - -**Purpose**: Create a reusable feature detection pipeline that resolves feature slug from multiple sources in priority order: CLI arg -> git branch -> directory name -> none. - -**Steps**: -1. Create `crates/kasmos/src/launch/detect.rs` with: - ```rust - pub enum FeatureSource { - Arg(String), - Branch(String), - Directory(String), - None, - } - - pub struct FeatureDetection { - pub source: FeatureSource, - pub feature_slug: Option, - pub feature_dir: Option, - } - - pub fn detect_feature( - spec_prefix: Option<&str>, - specs_root: &Path, - ) -> Result { ... } - ``` -2. Detection logic: - - **Arg**: If `spec_prefix` is provided, use existing `feature_arg::resolve_feature_dir()` logic - - **Branch**: Run `git branch --show-current`, parse prefix pattern (e.g., `011-mcp-agent-swarm-orchestration` -> `011`), match against `kitty-specs/011-*` directories - - **Directory**: Check if current working directory is inside a feature spec directory - - **None**: No feature could be inferred -3. Integrate the existing `feature_arg.rs` logic rather than duplicating it. The new detect module should call into the existing resolver for the arg path. - -**Files**: `crates/kasmos/src/launch/detect.rs`, referencing `crates/kasmos/src/feature_arg.rs` -**Validation**: Detection returns correct source for each scenario. - -### Subtask T010 - Implement CLI feature selector for no-inference case - -**Purpose**: When no feature can be inferred (FR-005), present an interactive selector in the CLI BEFORE any Zellij actions. - -**Steps**: -1. In the launch flow (within `crates/kasmos/src/launch/mod.rs`), after detection returns `FeatureSource::None`: - - Scan `kitty-specs/` for available feature directories - - Display a numbered list to stdout - - Read user selection from stdin - - Resolve the selected feature -2. Keep this simple - no TUI library needed. Use basic terminal I/O: - ``` - No feature specified and none could be inferred from the environment. - Available feature specs: - 1) 010-hub-tui-navigator - 2) 011-mcp-agent-swarm-orchestration - Select a feature [1-2]: - ``` -3. **Critical**: This MUST happen before any `zellij` commands are executed. The selection gate is in the launch entry function, before layout generation or session creation. - -**Files**: `crates/kasmos/src/launch/mod.rs` -**Validation**: Running `kasmos` on `main` with no inferable feature shows the selector. - -### Subtask T011 - Implement launch dependency preflight checks - -**Purpose**: Validate that all required runtime dependencies are available before launching (FR-021). Fail hard with actionable guidance. - -**Steps**: -1. Create a preflight check function in `crates/kasmos/src/launch/mod.rs` (or a dedicated submodule): - ```rust - pub fn preflight_checks(config: &Config) -> Result<(), Vec> { - let mut failures = Vec::new(); - // Check each dependency - check_binary(&config.paths.zellij_binary, "zellij", &mut failures); - check_binary(&config.agent.opencode_binary, "opencode", &mut failures); - check_binary(&config.paths.spec_kitty_binary, "spec-kitty", &mut failures); - // Check pane-tracker plugin availability - check_pane_tracker(&mut failures); - if failures.is_empty() { Ok(()) } else { Err(failures) } - } - ``` -2. Each check uses `which::which()` (already a dependency) to verify binary existence. -3. Each failure includes: - - Which dependency is missing - - What it's needed for - - Installation guidance (e.g., "Install zellij: cargo install zellij") -4. Print all failures (not just the first) so the user can fix everything at once. -5. Exit with non-zero code. No session/tab must be created. - -**Files**: `crates/kasmos/src/launch/mod.rs` -**Validation**: Remove a binary from PATH, run `kasmos 011`, verify non-zero exit and guidance message. - -### Subtask T012 - Implement "no specs found" early-exit path - -**Purpose**: When no feature specs exist in the repository, exit cleanly before creating any session or tab. - -**Steps**: -1. In the launch flow, after config loading but before feature detection: - - Check if `kitty-specs/` exists and contains at least one feature directory - - If empty or missing: print a message like "No feature specs found in kitty-specs/. Create one with: spec-kitty init" and exit with code 0 (not an error, just nothing to do) -2. This check runs before the feature detection pipeline. - -**Files**: `crates/kasmos/src/launch/mod.rs` -**Validation**: Remove all spec directories, run `kasmos`, verify clean exit message. - -### Subtask T013 - Add tests for config, detection, and preflight - -**Purpose**: Unit test coverage for config loading/validation, feature detection scenarios, selector gate timing, and preflight hard-fail behavior. - -**Steps**: -1. Config tests in `crates/kasmos/src/config.rs`: - - Default config validates - - Partial TOML loads correctly (missing sections use defaults) - - Invalid values produce clear errors - - Env override precedence works -2. Detection tests in `crates/kasmos/src/launch/detect.rs`: - - Arg detection resolves correctly - - Branch detection parses prefix from branch name - - None detection when no sources available - - Ambiguous prefix handling (multiple matches) -3. Preflight tests: - - All dependencies present -> success - - Missing binary -> failure with guidance - - Multiple missing -> all reported - -**Files**: Test modules within the respective source files -**Validation**: `cargo test` passes with new tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Selector accidentally launching Zellij before user picks | Enforce gate in launch entry function - preflight and selection MUST complete before any zellij commands | -| Brittle dependency checks across Linux/macOS | Centralize check logic using `which` crate. Test with PATH manipulation. | -| Config migration breaking existing users | Old `kasmos.toml` format (flat) should either be auto-migrated or clearly reported as incompatible | - -## Review Guidance - -- Verify selector runs BEFORE any Zellij session/tab creation -- Verify preflight exits non-zero on missing deps with ALL failures reported -- Verify config loads with partial TOML + env overrides correctly -- Verify feature detection handles all four sources (arg, branch, dir, none) -- Verify "no specs" path exits before any Zellij actions -- Verify all new public functions have doc comments - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-14T21:23:48Z – coder – shell_pid=3406135 – lane=for_review – Submitted for review via swarm -- 2026-02-14T21:23:48Z – reviewer – shell_pid=3530436 – lane=doing – Started review via workflow command -- 2026-02-14T21:44:15Z – reviewer – shell_pid=3530436 – lane=for_review – Moved to for_review -- 2026-02-14T21:44:24Z – reviewer – shell_pid=3580441 – lane=doing – Started review via workflow command -- 2026-02-14T21:54:47Z – reviewer – shell_pid=3580441 – lane=for_review – Moved to for_review -- 2026-02-14T22:08:06Z – reviewer – shell_pid=3580441 – lane=for_review – Ready for review -- 2026-02-14T22:10:00Z – reviewer – shell_pid=3639715 – lane=doing – Started review via workflow command -- 2026-02-14T22:11:57Z – reviewer – shell_pid=3639715 – lane=done – Review passed: all subtasks implemented correctly, build+tests pass, backward compatibility maintained diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP03-launch-layout-and-session-bootstrap.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP03-launch-layout-and-session-bootstrap.md deleted file mode 100644 index 54abf8d..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP03-launch-layout-and-session-bootstrap.md +++ /dev/null @@ -1,283 +0,0 @@ ---- -work_package_id: WP03 -title: Launch Layout and Session Bootstrap -lane: "done" -dependencies: [WP02] -base_branch: 011-mcp-agent-swarm-orchestration-WP02 -base_commit: 839ff563e7dfa7894ce4b53b37f439478bf887a6 -created_at: '2026-02-14T22:26:27.010494+00:00' -subtasks: -- T014 -- T015 -- T016 -- T017 -- T018 -- T019 -- T020 -phase: Phase 1 - Launch Topology and MCP Runtime Skeleton -assignee: 'opencode' -agent: "opencode" -shell_pid: "3957849" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP03 - Launch Layout and Session Bootstrap - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP03 --base WP02 -``` - ---- - -## Objectives & Success Criteria - -Implement the launch flow that creates the orchestration tab layout, supports inside/outside Zellij behavior, and primes manager startup. After this WP: - -1. `kasmos 011` from outside Zellij creates a new session named "kasmos" with an orchestration tab -2. Running `kasmos 012` from inside an existing Zellij session creates a new orchestration tab (not a new session) -3. The orchestration tab contains: manager pane (60%), message-log pane (20%), dashboard pane (20%), and empty worker area below -4. No dedicated MCP tab/process is created - `kasmos serve` runs as a manager-spawned MCP stdio subprocess -5. The manager pane receives initial prompt instructions including bound feature and phase assessment -6. Layout fallback works (manager + message-log only) when advanced layout generation fails - -## Context & Constraints - -- **Depends on WP02**: Config, feature detection, and preflight are available -- **Plan reference**: Session Layout section shows manager(60%) + message-log(20%) + dashboard(20%) + worker rows -- **Existing code**: `crates/kasmos/src/layout.rs` (980 lines) has `LayoutGenerator` for KDL layout generation. `crates/kasmos/src/zellij.rs` (550 lines) has `ZellijCli` trait. `crates/kasmos/src/session.rs` (718 lines) has `SessionManager`. -- **Research**: Inside-session Zellij commands use `zellij action ` directly (no `--session` flag). Outside-session commands use `zellij --session action `. -- **Key constraint**: Launch does NOT mutate WP state. It only establishes the orchestration runtime environment. - -## Subtasks & Detailed Guidance - -### Subtask T014 - Implement orchestration layout generator with swap-layout and dashboard - -**Purpose**: Generate KDL layout for the orchestration tab with manager pane, message-log pane, dashboard pane, and dynamic worker area. Include swap-layout KDL blocks for automatic reflow when workers are added/removed (FR-007, US5, FR-032). - -**Steps**: -1. Create/populate `crates/kasmos/src/launch/layout.rs`: - ```rust - pub struct OrchestrationLayout { - pub manager_width_pct: u32, - pub message_log_width_pct: u32, - pub feature_slug: String, - } - - impl OrchestrationLayout { - pub fn to_kdl(&self) -> String { - // Generate KDL layout string - } - } - ``` -2. Layout structure (KDL format for Zellij): - - Top row: manager pane (60% width, named "manager") + message-log pane (20%, named "msg-log") + dashboard pane (20%, named "dashboard") - - Bottom area: empty initially (workers spawned dynamically later) - - Use Zellij's `swap_tiled_layout` for dynamic reflow when workers are added/removed (FR-007) -3. Reference the existing `LayoutGenerator` in `crates/kasmos/src/layout.rs` for KDL generation patterns (see `generate_layout()` method and `tab_template_kdl_string()` static method). -4. The existing layout.rs generates layouts for the old orchestration model (controller + agents tab). The new layout is different: single tab with manager + msg-log + worker area. -5. Use config values for width percentages (`session.manager_width_pct`, `session.message_log_width_pct`). -6. Generate multiple `swap_tiled_layout` KDL blocks for pane counts 2 (manager + msg-log only) through `max_workers + 3` (manager + msg-log + dashboard + N workers). Each block defines how panes are arranged for that count, ensuring the header row (manager/msg-log/dashboard) stays fixed while worker rows expand below. This is the mechanism behind FR-007 and US5's automatic reflow requirement. - -**Files**: `crates/kasmos/src/launch/layout.rs` -**Validation**: Generated KDL parses correctly. Layout produces expected pane arrangement. Swap layouts exist for multiple pane counts. - -### Subtask T015 - Implement session/tab bootstrap behavior - -**Purpose**: Handle the two launch scenarios: outside Zellij (create session) vs inside Zellij (create tab). - -**Steps**: -1. Create/populate `crates/kasmos/src/launch/session.rs`: - ```rust - pub async fn bootstrap( - config: &Config, - feature_slug: &str, - layout: &OrchestrationLayout, - ) -> Result<()> { - if is_inside_zellij() { - create_orchestration_tab(config, feature_slug, layout).await - } else { - create_orchestration_session(config, feature_slug, layout).await - } - } - - fn is_inside_zellij() -> bool { - std::env::var("ZELLIJ_SESSION_NAME").is_ok() - } - ``` -2. **Outside Zellij** (`create_orchestration_session`): - - Write the KDL layout to a temp file - - Launch `zellij --layout attach kasmos --create` - - Session name: `config.session.session_name` (default: "kasmos") - - If session already exists, kill and recreate (same pattern as existing `bootstrap_start_in_zellij` in main.rs:154-207) -3. **Inside Zellij** (`create_orchestration_tab`): - - Use `zellij action new-tab --layout --name ` - - No `--session` flag needed inside a session -4. Use `ZellijCli` trait methods where possible, or add new methods if needed. - -**Files**: `crates/kasmos/src/launch/session.rs` -**Validation**: Launch from outside creates session. Launch from inside creates tab. - -### Subtask T016 - Enforce manager-spawned MCP stdio subprocess model - -**Purpose**: Ensure the launch flow does NOT create a dedicated MCP tab/process. `kasmos serve` runs as an MCP stdio subprocess spawned by the manager agent through its OpenCode MCP configuration. - -**Steps**: -1. In the launch layout and session bootstrap code, verify there is NO pane running `kasmos serve`. -2. The manager pane launches OpenCode with an MCP configuration that includes `kasmos serve` as a stdio subprocess. This is configured in the OpenCode profile, not in the launch code. -3. Add a comment in the launch code explicitly noting this design decision: - ```rust - // kasmos serve runs as an MCP stdio subprocess owned by the manager agent. - // It is NOT a dedicated pane or process. The manager's OpenCode profile - // configures kasmos serve as an MCP server in its mcp config. - // See: config/profiles/kasmos/opencode.jsonc - ``` -4. Ensure the manager pane command launches OpenCode (not kasmos serve directly). - -**Files**: `crates/kasmos/src/launch/session.rs`, `crates/kasmos/src/launch/layout.rs` -**Validation**: No pane in the layout runs `kasmos serve`. Manager pane runs OpenCode. - -### Subtask T017 - Implement manager initial prompt seed - -**Purpose**: Generate the initial prompt for the manager agent that includes bound feature context, phase assessment instruction, and confirmation-first behavior. - -**Steps**: -1. Create/update `crates/kasmos/src/prompt.rs` (or a new function within it) for manager prompt generation: - ```rust - pub fn generate_manager_prompt( - feature_slug: &str, - feature_dir: &Path, - phase_hint: &str, - ) -> String { - format!( - "You are the kasmos manager agent for feature '{feature_slug}'. - Feature directory: {feature_dir} - ... - Your first task: Assess the current workflow phase and present a summary. - Do NOT take any action without explicit user confirmation. - ..." - ) - } - ``` -2. The prompt should instruct the manager to: - - Assess workflow phase by checking which artifacts exist (spec.md, plan.md, tasks.md, task lanes) - - Present a summary of current state and next recommended action - - Wait for explicit user confirmation before proceeding (FR-009) - - Use `kasmos serve` MCP tools for orchestration operations -3. Include context references: spec.md path, plan.md path, tasks.md path, constitution path, architecture memory path. -4. This prompt is passed to the OpenCode command that runs in the manager pane. - -**Parallel?**: Yes - can proceed once required prompt inputs (feature_slug, feature_dir) are known. -**Files**: `crates/kasmos/src/prompt.rs` -**Validation**: Generated prompt contains feature slug, phase assessment instructions, and confirmation gate. - -### Subtask T018 - Add minimal-layout fallback path - -**Purpose**: When advanced layout generation fails, fall back to a minimal layout with just manager + message-log panes. - -**Steps**: -1. In the layout generation code, wrap the full layout builder in a fallback: - ```rust - pub fn generate_layout(config: &Config, feature_slug: &str) -> Result { - match generate_full_layout(config, feature_slug) { - Ok(layout) => Ok(layout), - Err(e) => { - tracing::warn!("Full layout generation failed: {e}. Using minimal fallback."); - generate_minimal_layout(config, feature_slug) - } - } - } - ``` -2. Minimal layout: manager pane (70% width) + message-log pane (30% width), no worker area, no dashboard. -3. Log the fallback reason clearly so it's visible in diagnostics. - -**Files**: `crates/kasmos/src/launch/layout.rs` -**Validation**: Deliberately trigger a layout failure and verify fallback produces a usable layout. - -### Subtask T019 - Wire launch flow end-to-end - -**Purpose**: Connect all launch components into the single `launch::run()` entry point. - -**Steps**: -1. Implement the full flow in `crates/kasmos/src/launch/mod.rs`: - ```rust - pub async fn run(spec_prefix: Option<&str>) -> Result<()> { - // 1. Load config - let config = Config::load()?; - // 2. Check for specs - check_specs_exist(&config)?; - // 3. Preflight dependency checks - preflight_checks(&config)?; - // 4. Detect or select feature - let feature = detect_or_select_feature(spec_prefix, &config)?; - // 5. Generate layout - let layout = generate_layout(&config, &feature.slug)?; - // 6. Bootstrap session/tab - session::bootstrap(&config, &feature.slug, &layout).await?; - Ok(()) - } - ``` -2. Wire this into `main.rs` dispatch for both `spec_prefix` present and absent cases. -3. Ensure the order is strict: config -> specs check -> preflight -> detection/selection -> layout -> session. No Zellij commands before step 6. - -**Files**: `crates/kasmos/src/launch/mod.rs`, `crates/kasmos/src/main.rs` -**Validation**: Full launch flow works end-to-end for explicit feature arg case. - -### Subtask T020 - Add launch integration tests - -**Purpose**: Test the launch flow for explicit feature arg, branch inference, and selector path behavior. - -**Steps**: -1. Unit tests for layout generation (KDL output correctness) -2. Unit tests for session bootstrap logic (inside/outside detection) -3. Integration-style tests (may need to mock Zellij calls): - - Explicit feature arg resolves correctly and reaches layout generation - - Branch inference extracts prefix and resolves feature - - Missing dependency stops before any Zellij commands - - No specs path exits cleanly - -**Files**: Test modules in launch submodules -**Validation**: `cargo test` passes with new tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Zellij command differences across environments | Encapsulate shell calls in zellij.rs wrappers with robust error mapping | -| Launch fallback masking real layout bugs | Log fallback reason. Keep test coverage for normal layout path. | -| Session name collision | Use deterministic session name from config. Kill-and-recreate pattern for reruns. | - -## Review Guidance - -- Verify no pane in the layout runs `kasmos serve` directly -- Verify inside-Zellij creates tab, outside-Zellij creates session -- Verify launch order: config -> specs -> preflight -> detect -> layout -> session -- Verify fallback layout is functional -- Verify manager prompt includes feature binding, phase assessment, and confirmation gate - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-14T22:40:25Z – opencode – shell_pid=3957849 – lane=doing – Assigned agent via workflow command -- 2026-02-14T22:54:27Z – opencode – shell_pid=3957849 – lane=for_review – Ready for review -- 2026-02-14T22:55:17Z – opencode – shell_pid=3957849 – lane=done – Review passed: layout generation, session bootstrap, manager prompt all correct, 248 tests pass diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP04-mcp-serve-bootstrap-and-contracts.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP04-mcp-serve-bootstrap-and-contracts.md deleted file mode 100644 index 6d12520..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP04-mcp-serve-bootstrap-and-contracts.md +++ /dev/null @@ -1,268 +0,0 @@ ---- -work_package_id: WP04 -title: MCP Serve Bootstrap and Contract Wiring -lane: "done" -dependencies: [WP02] -base_branch: 011-mcp-agent-swarm-orchestration-WP02 -base_commit: 839ff563e7dfa7894ce4b53b37f439478bf887a6 -created_at: '2026-02-14T22:27:41.958224+00:00' -subtasks: -- T021 -- T022 -- T023 -- T024 -- T025 -- T026 -phase: Phase 1 - Launch Topology and MCP Runtime Skeleton -assignee: 'opencode' -agent: "opencode" -shell_pid: "3957847" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP04 - MCP Serve Bootstrap and Contract Wiring - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP04 --base WP02 -``` - ---- - -## Objectives & Success Criteria - -Stand up `kasmos serve` as an RMCP stdio server with all 9 contract-defined tools registered and schema-validated. After this WP: - -1. `kasmos serve` starts, reads JSON-RPC from stdin, writes to stdout -2. `tools/list` returns 9 tools matching the contract schema in `contracts/kasmos-serve.json` -3. Tool input/output types are fully typed with `schemars` for JSON Schema generation -4. `list_features` and `infer_feature` are fully functional -5. Remaining 7 tools have validated stubs that accept correct input and return correct error codes -6. Shared server state (worker registry, message cursor, config) is initialized - -## Context & Constraints - -- **Depends on WP02**: Config system available for loading into serve state -- **Contract**: `kitty-specs/011-mcp-agent-swarm-orchestration/contracts/kasmos-serve.json` defines all 9 tools with exact input/output schemas and error codes -- **Data model**: `kitty-specs/011-mcp-agent-swarm-orchestration/data-model.md` defines entity types -- **rmcp crate**: Provides `#[tool]` proc macro, `ServerHandler` trait, and stdio transport. Uses `schemars` for schema generation. -- **Runtime model**: `kasmos serve` runs as MCP stdio subprocess spawned by manager agent. It does NOT run in its own pane. - -## Subtasks & Detailed Guidance - -### Subtask T021 - Implement RMCP serve bootstrap with stdio transport - -**Purpose**: Create the `kasmos serve` entry point that initializes an RMCP server on stdio transport. - -**Steps**: -1. Populate `crates/kasmos/src/serve/mod.rs`: - ```rust - use rmcp::transport::io::stdio; - use rmcp::ServiceExt; - - pub mod registry; - pub mod messages; - pub mod audit; - pub mod lock; - pub mod tools; - - pub async fn run() -> anyhow::Result<()> { - // Load config - let config = crate::config::Config::load()?; - // Initialize shared state - let state = KasmosServer::new(config)?; - // Start stdio transport - let transport = stdio(); - let server = state.serve(transport).await?; - // Wait for server to complete - server.waiting().await?; - Ok(()) - } - ``` -2. The `KasmosServer` struct implements `ServerHandler` (or uses rmcp's tool registration pattern). -3. Wire `Commands::Serve` in `main.rs` to call `serve::run()`. -4. The server must handle graceful shutdown when stdin closes (manager agent exits). - -**Files**: `crates/kasmos/src/serve/mod.rs`, `crates/kasmos/src/main.rs` -**Validation**: `echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | kasmos serve` returns tool list. - -### Subtask T022 - Define shared server state - -**Purpose**: Create the shared state containers that all tool handlers access. - -**Steps**: -1. Create `crates/kasmos/src/serve/registry.rs`: - ```rust - use std::collections::HashMap; - use std::sync::Arc; - use tokio::sync::RwLock; - - /// Active worker registry - pub struct WorkerRegistry { - workers: HashMap, - } - - /// Entry for a tracked worker - pub struct WorkerEntry { - pub wp_id: String, - pub role: AgentRole, - pub pane_name: String, - pub pane_id: Option, - pub worktree_path: Option, - pub status: WorkerStatus, - pub spawned_at: chrono::DateTime, - pub updated_at: chrono::DateTime, - pub last_event: Option, - } - - pub enum AgentRole { Planner, Coder, Reviewer, Release } - pub enum WorkerStatus { Active, Done, Errored, Aborted } - ``` -2. The `KasmosServer` struct holds: - ```rust - pub struct KasmosServer { - pub config: Config, - pub registry: Arc>, - pub message_cursor: Arc>, - pub feature_slug: Option, - // Lock handler added in WP05 - // Audit writer added in WP06 - } - ``` -3. Derive `schemars::JsonSchema` on all types that appear in tool inputs/outputs. - -**Files**: `crates/kasmos/src/serve/registry.rs`, `crates/kasmos/src/serve/mod.rs` -**Validation**: Server state initializes correctly with default values. - -### Subtask T023 - Implement typed tool request/response structs - -**Purpose**: Create strongly-typed structs for all 9 tool handlers matching the contract schema. - -**Steps**: -1. Create `crates/kasmos/src/serve/tools/mod.rs` with submodule declarations for each tool. -2. For each tool, create a file in `crates/kasmos/src/serve/tools/`: - - `spawn_worker.rs`: `SpawnWorkerInput`, `SpawnWorkerOutput` - - `despawn_worker.rs`: `DespawnWorkerInput`, `DespawnWorkerOutput` - - `list_workers.rs`: `ListWorkersInput`, `ListWorkersOutput` - - `read_messages.rs`: `ReadMessagesInput`, `ReadMessagesOutput` - - `wait_for_event.rs`: `WaitForEventInput`, `WaitForEventOutput` - - `workflow_status.rs`: `WorkflowStatusInput`, `WorkflowStatusOutput` - - `transition_wp.rs`: `TransitionWpInput`, `TransitionWpOutput` - - `list_features.rs`: `ListFeaturesInput`, `ListFeaturesOutput` - - `infer_feature.rs`: `InferFeatureInput`, `InferFeatureOutput` -3. Each struct derives `Serialize, Deserialize, JsonSchema` and matches the contract exactly. -4. Use shared enums for `AgentRole`, `WorkerStatus`, `MessageEvent`, etc. -5. Register all tools with the RMCP server using `#[tool]` attributes or manual registration. -6. For tools not yet implemented, return a stub response with `ok: true` and placeholder data, OR return an error with code `INTERNAL_ERROR` and message "Not yet implemented". - -**Files**: `crates/kasmos/src/serve/tools/*.rs` -**Validation**: `tools/list` returns all 9 tools. Schema matches contract. - -### Subtask T024 - Fully implement list_features tool - -**Purpose**: Scan `kitty-specs/` and return feature availability information. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/list_features.rs`: - ```rust - pub async fn list_features(config: &Config) -> Result { - let specs_root = PathBuf::from(&config.paths.specs_root); - let mut features = Vec::new(); - for entry in std::fs::read_dir(&specs_root)? { - let entry = entry?; - if entry.path().is_dir() { - let slug = entry.file_name().to_string_lossy().to_string(); - let has_spec = entry.path().join("spec.md").exists(); - let has_plan = entry.path().join("plan.md").exists(); - let has_tasks = entry.path().join("tasks").is_dir() - && has_wp_files(&entry.path().join("tasks")); - features.push(FeatureInfo { slug, has_spec, has_plan, has_tasks }); - } - } - features.sort_by(|a, b| a.slug.cmp(&b.slug)); - Ok(ListFeaturesOutput { ok: true, features }) - } - ``` -2. Reference existing `list_specs.rs` (146 lines) for the scanning pattern already used in the CLI. -3. Match contract output: `{ ok: bool, features: [{ slug, has_spec, has_plan, has_tasks }] }` - -**Parallel?**: Yes - independent of T025 once shared utilities exist. -**Files**: `crates/kasmos/src/serve/tools/list_features.rs` -**Validation**: Returns correct feature list matching `kitty-specs/` contents. - -### Subtask T025 - Fully implement infer_feature tool - -**Purpose**: Infer feature slug from spec_prefix argument, git branch, or working directory. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/infer_feature.rs`: - - Reuse the detection logic from `launch/detect.rs` (T009) - - Accept optional `spec_prefix` input - - Return `{ ok, source: "arg"|"branch"|"directory"|"none", feature_slug? }` -2. Map `FeatureSource` enum to the contract's `source` string enum. -3. When `source` is `"none"`, `feature_slug` is absent and `ok` is still `true` (not an error). - -**Parallel?**: Yes - independent of T024 once shared detection logic exists. -**Files**: `crates/kasmos/src/serve/tools/infer_feature.rs` -**Validation**: Returns correct source and slug for each detection scenario. - -### Subtask T026 - Add contract-level tests for tool registration and error codes - -**Purpose**: Verify that all 9 tools are registered, schemas match the contract, and standard error codes are returned correctly. - -**Steps**: -1. Test that `tools/list` returns exactly 9 tools with correct names -2. Test that each tool accepts valid input without panicking -3. Test that invalid input returns `INVALID_INPUT` error code -4. Test that `list_features` returns expected structure -5. Test that `infer_feature` returns expected structure -6. Reference the error codes from the contract: `INVALID_INPUT`, `FEATURE_LOCK_CONFLICT`, `STALE_LOCK_CONFIRMATION_REQUIRED`, `WORKER_NOT_FOUND`, `TRANSITION_NOT_ALLOWED`, `DEPENDENCY_MISSING`, `INTERNAL_ERROR` - -**Files**: Test modules in serve submodules -**Validation**: `cargo test` passes with contract tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Contract drift between code and JSON spec | Enforce schema snapshots in tests. Compare generated schemas against contract file. | -| rmcp API changes between versions | Pin to specific version. Add integration test that exercises stdio transport. | -| Incomplete input validation | Centralize validation helpers. Use serde's strict deserialization. | - -## Review Guidance - -- Verify all 9 tools from the contract are registered -- Verify `list_features` and `infer_feature` are fully functional (not stubs) -- Verify typed structs match contract schemas exactly -- Verify server starts and responds on stdio transport -- Verify error codes match contract specification -- Verify graceful shutdown on stdin close - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-14T22:25:37Z – unknown – shell_pid=3674163 – lane=planned – Moved to planned -- 2026-02-14T22:27:10Z – unknown – shell_pid=3674163 – lane=planned – Moved to planned -- 2026-02-14T22:39:40Z – opencode – shell_pid=3957847 – lane=doing – Assigned agent via workflow command -- 2026-02-14T22:55:34Z – opencode – shell_pid=3957847 – lane=for_review – Ready for review -- 2026-02-14T22:56:12Z – opencode – shell_pid=3957847 – lane=done – Review passed: 9 MCP tools wired, registry CRUD, contract tests pass, 247 tests total diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP05-repository-feature-locking.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP05-repository-feature-locking.md deleted file mode 100644 index c2b2af5..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP05-repository-feature-locking.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -work_package_id: WP05 -title: Repository-Wide Feature Locking -lane: "done" -dependencies: [WP04] -base_branch: 011-mcp-agent-swarm-orchestration-WP04 -base_commit: 839ff563e7dfa7894ce4b53b37f439478bf887a6 -created_at: '2026-02-14T22:40:00.621692+00:00' -subtasks: -- T027 -- T028 -- T029 -- T030 -- T031 -- T032 -phase: Phase 2 - Safety, State, and Audit Guarantees -assignee: 'opencode' -agent: "opencode" -shell_pid: "3957848" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP05 - Repository-Wide Feature Locking - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP05 --base WP04 -``` - ---- - -## Objectives & Success Criteria - -Enforce single active owner per feature across the repository (FR-020), with stale lock handling that requires explicit takeover confirmation after timeout. After this WP: - -1. Lock key format: `::` -2. Second process binding same feature receives conflict with active owner details -3. Stale lock (no heartbeat for 15+ minutes) requires explicit confirmation before takeover -4. Clean shutdown releases the lock -5. Lock integrates with launch binding path and serve tool operations - -## Context & Constraints - -- **Depends on WP04**: Serve state containers and tool framework available -- **Data model**: `FeatureBindingLock` entity in `data-model.md` defines fields and state transitions -- **Research decision 2**: Lock scope is repository-wide across all running kasmos processes -- **Research decision 3**: Stale after 15 minutes, takeover requires explicit confirmation -- **Engineering decision 5**: Stale lock timeout = 15 minutes (configurable via `LockConfig`) -- **Key constraint**: Use atomic file operations plus advisory file locking to prevent race conditions - -## Subtasks & Detailed Guidance - -### Subtask T027 - Implement lock key derivation and repo-root resolution - -**Purpose**: Derive the canonical lock key from repo root and feature slug, ensuring consistency across invocations. - -**Steps**: -1. Create `crates/kasmos/src/serve/lock.rs`: - ```rust - pub struct LockKey { - pub repo_root: PathBuf, - pub feature_slug: String, - } - - impl LockKey { - pub fn new(feature_slug: &str) -> Result { - let repo_root = resolve_repo_root()?; - Ok(Self { repo_root, feature_slug: feature_slug.to_string() }) - } - - pub fn to_string(&self) -> String { - format!("{}::{}", self.repo_root.display(), self.feature_slug) - } - - pub fn lock_file_path(&self) -> PathBuf { - // Store lock in .kasmos/ at repo root - self.repo_root.join(".kasmos").join("locks") - .join(format!("{}.lock", self.feature_slug)) - } - } - ``` -2. `resolve_repo_root()`: Use `git rev-parse --show-toplevel` to get canonical repo root. Cache the result. -3. Lock files live in `/.kasmos/locks/.lock`. -4. Ensure the locks directory is created on first use. - -**Files**: `crates/kasmos/src/serve/lock.rs` -**Validation**: Lock key is deterministic for same repo + feature. - -### Subtask T028 - Implement persistent lock record with heartbeat - -**Purpose**: Create the lock file with owner metadata, timestamps, and heartbeat refresh capability. - -**Steps**: -1. Lock record structure (written as JSON to lock file): - ```rust - #[derive(Serialize, Deserialize)] - pub struct LockRecord { - pub lock_key: String, - pub repo_root: String, - pub feature_slug: String, - pub owner_id: String, // process PID + hostname - pub owner_session: String, // Zellij session name - pub owner_tab: String, // Zellij tab name - pub acquired_at: DateTime, - pub last_heartbeat_at: DateTime, - pub expires_at: DateTime, - pub status: LockStatus, - } - - #[derive(Serialize, Deserialize)] - pub enum LockStatus { Active, Stale, Released } - ``` -2. `acquire()`: Write lock file atomically (write to temp, rename). -3. `heartbeat()`: Update `last_heartbeat_at` and `expires_at`. -4. `release()`: Set status to `Released` and remove lock file. -5. Use file-level advisory locking (`nix::fcntl::flock` with `LOCK_EX | LOCK_NB`) around read-modify-write operations to prevent race conditions between processes. -6. Owner ID: use `format!("{}@{}", std::process::id(), hostname)` for a stable process identity. - -**Files**: `crates/kasmos/src/serve/lock.rs` -**Validation**: Lock file is created, heartbeat updates timestamps, release cleans up. - -### Subtask T029 - Enforce conflict response for active locks - -**Purpose**: When another process holds an active lock, refuse binding and provide clear owner details. - -**Steps**: -1. Before acquiring a lock, check if a lock file exists and is active: - ```rust - pub fn check_conflict(key: &LockKey) -> Result { - let lock_path = key.lock_file_path(); - if !lock_path.exists() { return Ok(LockConflict::None); } - let record = read_lock_record(&lock_path)?; - match record.status { - LockStatus::Active if !is_stale(&record) => { - Ok(LockConflict::ActiveOwner(record)) - } - LockStatus::Active => Ok(LockConflict::Stale(record)), - LockStatus::Released | LockStatus::Stale => Ok(LockConflict::None), - } - } - ``` -2. On `ActiveOwner`: Return `FEATURE_LOCK_CONFLICT` error code (matches contract) with owner details: - ``` - Feature '011-mcp-agent-swarm-orchestration' is already owned by: - PID: 12345@hostname - Session: kasmos - Acquired: 2026-02-14T10:00:00Z - Last heartbeat: 2026-02-14T10:05:00Z - ``` -3. On conflict, the launch flow exits immediately without creating session/tab. - -**Files**: `crates/kasmos/src/serve/lock.rs` -**Validation**: Second process gets conflict error with owner details. - -### Subtask T030 - Implement stale detection with configurable timeout - -**Purpose**: Detect when a lock has not received a heartbeat within the configured timeout (default 15 minutes). - -**Steps**: -1. Stale detection logic: - ```rust - fn is_stale(record: &LockRecord, config: &LockConfig) -> bool { - let now = Utc::now(); - let stale_threshold = chrono::Duration::minutes(config.stale_timeout_minutes as i64); - now - record.last_heartbeat_at > stale_threshold - } - ``` -2. The timeout is configurable via `LockConfig.stale_timeout_minutes` (default: 15). -3. When stale is detected, the lock's status transitions to `Stale` conceptually (may or may not be written to file at detection time). - -**Parallel?**: Can run alongside T029 once lock record schema exists. -**Files**: `crates/kasmos/src/serve/lock.rs` -**Validation**: Lock with old heartbeat is correctly identified as stale. - -### Subtask T031 - Implement confirmation-gated stale takeover - -**Purpose**: When a stale lock is detected, require explicit user confirmation before taking ownership. - -**Steps**: -1. In the launch flow, when `check_conflict` returns `LockConflict::Stale(record)`: - ``` - Feature '011-mcp-agent-swarm-orchestration' has a stale lock: - PID: 12345@hostname (may be dead) - Session: kasmos - Last heartbeat: 2026-02-14T08:00:00Z (2h 30m ago) - - Take over ownership? [y/N]: - ``` -2. If user confirms: Overwrite the lock file with new owner. -3. If user declines: Exit without launching. -4. For the MCP tool path (non-interactive), the error code is `STALE_LOCK_CONFIRMATION_REQUIRED` and the caller must provide an explicit confirmation flag. -5. Never silently take over a stale lock. Fail closed without confirmation. - -**Files**: `crates/kasmos/src/serve/lock.rs`, `crates/kasmos/src/launch/mod.rs` -**Validation**: Stale lock prompts for confirmation. Declining exits. Confirming acquires. - -### Subtask T032 - Add tests for lock behavior - -**Purpose**: Comprehensive tests for acquisition, heartbeat, stale detection, takeover, and conflicts. - -**Steps**: -1. Test lock acquisition creates valid lock file -2. Test heartbeat refresh updates timestamps -3. Test conflict detection for active lock -4. Test stale detection after timeout -5. Test takeover with confirmation -6. Test release removes lock -7. Test concurrent acquisition race (use temp dirs for isolation) -8. Use `tempfile` crate (already a dev-dependency) for test isolation - -**Files**: Test module in `crates/kasmos/src/serve/lock.rs` -**Validation**: `cargo test` passes with lock tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Race conditions during concurrent lock acquisition | Atomic file ops + advisory locking via nix flock | -| Accidental silent takeover | Fail closed unless confirmation token is explicit | -| Lock file corruption | Atomic write (temp + rename). Validate JSON on read. | -| Stale process detection false positives | Use heartbeat timeout, not process existence check (cross-platform reliable) | - -## Review Guidance - -- Verify lock key is deterministic: same repo + feature always produces same key -- Verify atomic file operations (write-temp-rename pattern) -- Verify advisory locking around read-modify-write -- Verify stale detection uses configurable timeout -- Verify takeover ALWAYS requires explicit confirmation -- Verify error codes match contract: `FEATURE_LOCK_CONFLICT`, `STALE_LOCK_CONFIRMATION_REQUIRED` - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-14T22:40:00Z – opencode – shell_pid=3957848 – lane=doing – Assigned agent via workflow command -- 2026-02-15T00:52:49Z – opencode – shell_pid=3957848 – lane=for_review – Ready for review -- 2026-02-15T00:55:14Z – opencode – shell_pid=3957848 – lane=done – Review passed: feature locking with flock, atomic writes, stale detection, 251 tests pass diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP06-audit-log-persistence-and-retention.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP06-audit-log-persistence-and-retention.md deleted file mode 100644 index 29fd8f4..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP06-audit-log-persistence-and-retention.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -work_package_id: WP06 -title: Audit Log Persistence and Retention Policy -lane: "done" -dependencies: [WP04] -base_branch: 011-mcp-agent-swarm-orchestration-WP04 -base_commit: a02df49238a89b34cf57dc156237af2bad587046 -created_at: '2026-02-15T00:59:41.708899+00:00' -subtasks: -- T033 -- T034 -- T035 -- T036 -- T037 -- T038 -phase: Phase 2 - Safety, State, and Audit Guarantees -assignee: 'opencode' -agent: "reviewer" -shell_pid: "417761" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP06 - Audit Log Persistence and Retention Policy - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP06 --base WP04 -``` - ---- - -## Objectives & Success Criteria - -Persist orchestration audit records at the feature-local path with configurable payload depth and retention. After this WP: - -1. Audit file at `kitty-specs//.kasmos/messages.jsonl` is created and written to -2. Default entries contain metadata only (timestamp, actor, action, wp_id, status, summary) -3. Debug mode includes full payloads (prompts, tool args) when enabled in config -4. Retention: rotation/pruning triggers when EITHER file size > 512MB OR entry age > 14 days -5. Audit writes are integrated across core action paths (lock, spawn, transition, error) - -## Context & Constraints - -- **Depends on WP04**: Serve state and config available -- **Data model**: `AuditEntry` and `AuditPolicy` entities in `data-model.md` -- **Research decision 5**: Audit at `kitty-specs//.kasmos/messages.jsonl`, dual threshold rotation -- **Research decision 6**: Metadata-only default, debug-mode full payload -- **Key constraint**: Append-only writes within a file generation. Buffered for performance. - -## Subtasks & Detailed Guidance - -### Subtask T033 - Implement audit directory and file bootstrap - -**Purpose**: Create the `.kasmos/` directory and `messages.jsonl` file under the feature path on first use. - -**Steps**: -1. Create `crates/kasmos/src/serve/audit.rs`: - ```rust - pub struct AuditWriter { - path: PathBuf, - config: AuditConfig, - file: Option, - } - - impl AuditWriter { - pub fn new(feature_dir: &Path, config: &AuditConfig) -> Result { - let audit_dir = feature_dir.join(".kasmos"); - std::fs::create_dir_all(&audit_dir)?; - let path = audit_dir.join("messages.jsonl"); - Ok(Self { path, config: config.clone(), file: None }) - } - } - ``` -2. The `.kasmos/` directory should be created idempotently (no error if it exists). -3. Add `.kasmos/` to `.gitignore` for the feature directory? No - per the spec, audit logs should be "committed to version control with the rest of the spec artifacts" (FR-027). So do NOT gitignore them. -4. Ensure the audit directory path is derived from the feature directory, not hard-coded. - -**Files**: `crates/kasmos/src/serve/audit.rs` -**Validation**: Directory and file are created correctly on first write. - -### Subtask T034 - Implement append-only JSONL writer - -**Purpose**: Write audit entries as newline-delimited JSON, append-only within a file generation. - -**Steps**: -1. Implement the write method: - ```rust - impl AuditWriter { - pub fn write_entry(&mut self, entry: &AuditEntry) -> Result<()> { - let file = self.get_or_open_file()?; - let json = serde_json::to_string(entry)?; - use std::io::Write; - writeln!(file, "{}", json)?; - file.flush()?; // Ensure durability - Ok(()) - } - - fn get_or_open_file(&mut self) -> Result<&mut std::fs::File> { - if self.file.is_none() { - let file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&self.path)?; - self.file = Some(file); - } - Ok(self.file.as_mut().unwrap()) - } - } - ``` -2. The `AuditEntry` struct (matching data-model.md): - ```rust - #[derive(Serialize, Deserialize)] - pub struct AuditEntry { - pub timestamp: DateTime, - pub actor: String, // "manager", "kasmos-serve", or worker id - pub action: String, // "spawn_worker", "transition_wp", etc. - pub feature_slug: String, - pub wp_id: Option, - pub status: String, - pub summary: String, - pub details: serde_json::Value, // metadata by default - #[serde(skip_serializing_if = "Option::is_none")] - pub debug_payload: Option, - } - ``` -3. Each write is flushed immediately for durability (crash safety). -4. Keep writes fast - avoid holding locks during serialization. - -**Files**: `crates/kasmos/src/serve/audit.rs` -**Validation**: Entries are written as valid JSONL. File can be read line-by-line. - -### Subtask T035 - Implement metadata-only default and debug payload opt-in - -**Purpose**: Control what goes into audit entries based on config. - -**Steps**: -1. Add a builder pattern for audit entries: - ```rust - impl AuditEntry { - pub fn new(actor: &str, action: &str, feature_slug: &str) -> Self { - Self { - timestamp: Utc::now(), - actor: actor.to_string(), - action: action.to_string(), - feature_slug: feature_slug.to_string(), - wp_id: None, - status: String::new(), - summary: String::new(), - details: serde_json::Value::Null, - debug_payload: None, - } - } - - pub fn with_debug_payload(mut self, payload: serde_json::Value, enabled: bool) -> Self { - if enabled { - self.debug_payload = Some(payload); - } - self - } - } - ``` -2. The `AuditWriter` checks `config.debug_full_payload` before including debug payloads. -3. Default mode: `details` contains minimal metadata (e.g., `{"pane_name": "WP01-coder"}`). -4. Debug mode: `debug_payload` additionally contains full prompts, tool arguments, etc. -5. Sensitive data (if any) should be redacted even in debug mode. - -**Files**: `crates/kasmos/src/serve/audit.rs` -**Validation**: Default entries have no `debug_payload`. Debug-enabled entries include it. - -### Subtask T036 - Implement retention evaluator with either-threshold trigger - -**Purpose**: Rotate/prune audit logs when EITHER size exceeds 512MB OR entries older than 14 days. - -**Steps**: -1. Add retention check method: - ```rust - impl AuditWriter { - pub fn check_retention(&self) -> Result { - let metadata = std::fs::metadata(&self.path)?; - // Size threshold - if metadata.len() > self.config.max_bytes { - return Ok(true); - } - // Age threshold - check first line timestamp - if let Some(oldest) = self.read_oldest_entry_timestamp()? { - let age = Utc::now() - oldest; - if age.num_days() > self.config.max_age_days as i64 { - return Ok(true); - } - } - Ok(false) - } - - pub fn rotate(&mut self) -> Result<()> { - // Close current file - self.file = None; - // Rename current to timestamped archive - let archive_name = format!("messages.{}.jsonl", - Utc::now().format("%Y%m%d-%H%M%S")); - let archive_path = self.path.with_file_name(archive_name); - std::fs::rename(&self.path, &archive_path)?; - // Optionally prune old archives - Ok(()) - } - } - ``` -2. Either-threshold: rotation triggers if size > `max_bytes` (512MB) OR age > `max_age_days` (14). -3. Rotation: rename current file to timestamped archive, start fresh. -4. Call `check_retention()` periodically (e.g., every N writes, not every write for performance). - -**Parallel?**: Can proceed alongside T037 once writer API is stable. -**Files**: `crates/kasmos/src/serve/audit.rs` -**Validation**: Rotation triggers on size threshold. Rotation triggers on age threshold. - -### Subtask T037 - Integrate audit writes across core actions - -**Purpose**: Wire audit event emission into lock, spawn/despawn, transition, and error paths. - -**Steps**: -1. Add `AuditWriter` to the `KasmosServer` shared state. -2. Emit audit entries at key points: - - Lock acquired/released/conflict/stale-takeover - - Worker spawned/despawned/errored/aborted - - WP state transitions (pending->active, active->for_review, etc.) - - Error conditions (lock conflicts, transition failures, dependency missing) -3. Each audit call should be non-blocking. If audit write fails, log a warning but do NOT fail the main operation. -4. Use the builder pattern from T035 for consistent entry construction. - -**Files**: Integration across `crates/kasmos/src/serve/lock.rs`, `crates/kasmos/src/serve/tools/*.rs` -**Validation**: Core actions produce audit entries in the JSONL file. - -### Subtask T038 - Add tests for audit policy - -**Purpose**: Test payload redaction defaults, debug inclusion, and retention trigger correctness. - -**Steps**: -1. Test default entry has no debug_payload -2. Test debug-enabled entry includes debug_payload -3. Test retention triggers on size threshold -4. Test retention triggers on age threshold -5. Test rotation creates archive file -6. Test append-only behavior (no overwriting) -7. Use tempfile for test isolation - -**Files**: Test module in `crates/kasmos/src/serve/audit.rs` -**Validation**: `cargo test` passes with audit tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Audit writes impacting runtime latency | Buffered append, flush on significant events only. Non-blocking writes. | -| Retention deleting recent diagnostics | Rotate (archive) rather than delete. Deterministic naming. | -| Disk space exhaustion | Either-threshold prevents unbounded growth. Archive pruning optional. | - -## Review Guidance - -- Verify audit path matches spec: `kitty-specs//.kasmos/messages.jsonl` -- Verify default entries are metadata-only (no prompts/payloads) -- Verify debug_payload only present when debug mode enabled -- Verify either-threshold retention (size OR age) -- Verify audit writes don't fail the main operation -- Verify append-only within a file generation - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-15T01:08:30Z – unknown – shell_pid=212269 – lane=for_review – Ready for review -- 2026-02-15T01:09:12Z – reviewer – shell_pid=417761 – lane=doing – Started review via workflow command -- 2026-02-15T01:15:09Z – reviewer – shell_pid=417761 – lane=done – Review passed: All 5 WP objectives verified — correct audit path, metadata-only default, debug opt-in with redaction, either-threshold retention, non-fatal integration across core action paths. 253 tests pass. 3 Low-severity findings noted as simplification suggestions. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP07-worker-lifecycle-tools.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP07-worker-lifecycle-tools.md deleted file mode 100644 index e93b9e6..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP07-worker-lifecycle-tools.md +++ /dev/null @@ -1,332 +0,0 @@ ---- -work_package_id: WP07 -title: Worker Lifecycle MCP Tools -lane: "done" -dependencies: [WP03, WP05, WP06] -base_branch: 011-mcp-agent-swarm-orchestration-WP05 -base_commit: bc2cea80968e2391d68d55763e35809dc44e582a -created_at: '2026-02-15T01:41:35.119359+00:00' -subtasks: -- T039 -- T040 -- T041 -- T042 -- T043 -- T044 -phase: Phase 2 - Safety, State, and Audit Guarantees -assignee: 'opencode' -agent: "reviewer" -shell_pid: "1148797" -review_status: "has_feedback" -reviewed_by: "reviewer" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP07 - Worker Lifecycle MCP Tools - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -**Reviewed by**: reviewer -**Status**: ❌ Changes Requested -**Date**: 2026-02-15 - -# Review Feedback: WP07 - Worker Lifecycle MCP Tools - -## Decision: NEEDS_CHANGES - -## Findings - -### [High] pane_exists uses nonexistent Zellij flag (mod.rs:167-183) - -`RealWorkerRuntime::pane_exists()` passes `--pane-name` to `zellij action dump-screen`, but this flag does not exist in Zellij 0.43.1. The command always fails, causing `pane_exists` to always return `Ok(false)`. This makes `list_workers` reconciliation mark ALL active workers as Aborted on every call. - -**Fix**: Either: -1. Return `Ok(true)` as a conservative default (skip reconciliation until external pane tracking is available) -2. Use the existing `SessionManager` focus-then-dump pattern -3. Integrate with zellij-pane-tracker MCP - -### [Medium] close_worker_pane uses nonexistent Zellij flag (mod.rs:147-165) - -`RealWorkerRuntime::close_worker_pane()` passes `--pane-name` to `zellij action close-pane`, but this flag does not exist. Despawn swallows the error (`let _ =`), so despawn "succeeds" but the pane stays open. - -**Fix**: Use focus-by-name navigation followed by `close-pane`, or document the limitation and log a warning. - -### [Medium] Despawn worktree cleanup failure skips registry removal (despawn_worker.rs:37-42) - -If `cleanup_coder_worktree` fails at line 41 (`?`), registry removal and audit logging are skipped, leaving an inconsistent state. - -**Fix**: Always remove from registry and log audit, regardless of cleanup outcome: - -```rust -// Best-effort cleanup -if worker.role == WorkerRole::Coder && worker.worktree_path.is_some() { - if let Err(e) = state.runtime.cleanup_coder_worktree(...).await { - tracing::warn!("worktree cleanup failed for {}: {e}", worker.wp_id); - } -} -// Always remove and audit -registry.remove(...); -audit_log.push(...); -``` - -### [Low] Regex compiled on every call (spawn_worker.rs:144-149) - -`validate_wp_id` compiles `Regex::new(...)` on every invocation. Use `std::sync::LazyLock` for a static compiled regex. - -### [Low] Blocking sync in async context (mod.rs:186-205) - -`ensure_coder_worktree` and `cleanup_coder_worktree` call blocking `WorktreeManager` (uses `std::process::Command`) inside async without `spawn_blocking`. - -### [Low] Test coverage gaps - -Missing tests for: -- Despawn of coder verifies worktree cleanup was called -- Audit events are emitted for spawn/despawn -- Despawn handles already-gone pane gracefully - - -## Implementation Command - -```bash -spec-kitty implement WP07 --base WP03 -``` - ---- - -## Objectives & Success Criteria - -Implement the worker pane lifecycle tools (`spawn_worker`, `despawn_worker`, `list_workers`) with registry consistency and role-aware behavior. After this WP: - -1. Manager can spawn worker panes via `spawn_worker` MCP tool -2. Coder workers automatically get worktree provisioning -3. Manager can despawn workers via `despawn_worker` with registry + pane cleanup -4. `list_workers` returns accurate state with live-pane reconciliation -5. Max parallel worker limit is enforced with clear backpressure response -6. Registry stays consistent even when panes are manually closed by users - -## Context & Constraints - -- **Depends on WP03**: Launch layout and Zellij session wrappers available -- **Depends on WP05**: Feature locking ensures single owner -- **Depends on WP06**: Audit writer available for event logging -- **Contract**: `spawn_worker`, `despawn_worker`, `list_workers` in `contracts/kasmos-serve.json` -- **Data model**: `WorkerEntry` entity with role, status, pane tracking -- **Existing code**: `crates/kasmos/src/session.rs` (718 lines) has `SessionManager` with pane tracking via HashMap. `crates/kasmos/src/git.rs` (467 lines) has `WorktreeManager` for worktree creation/cleanup. -- **Zellij**: Worker panes are created with `zellij run -n -- `. No `list-panes` command exists - tracking is internal. - -## Subtasks & Detailed Guidance - -### Subtask T039 - Implement spawn_worker tool - -**Purpose**: Create a new worker pane with role-specific setup and registry tracking. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/spawn_worker.rs`: - ```rust - pub async fn handle( - input: SpawnWorkerInput, - state: &KasmosServer, - ) -> Result { - // 1. Validate input - validate_wp_id(&input.wp_id)?; - validate_role(&input.role)?; - // 2. Check worker limit - check_capacity(state).await?; - // 3. Generate pane name - let pane_name = format!("{}-{}", input.wp_id, input.role); - // 4. Provision worktree (coders only) - let worktree_path = if input.role == "coder" { - Some(provision_worktree(&input).await?) - } else { None }; - // 5. Build agent command - let cmd = build_agent_command(&input, &worktree_path)?; - // 6. Create Zellij pane - create_worker_pane(&pane_name, &cmd).await?; - // 7. Register in worker registry - let entry = register_worker(state, &input, &pane_name, &worktree_path).await; - // 8. Audit log - audit_spawn(state, &entry).await; - Ok(SpawnWorkerOutput { ok: true, worker: entry.into() }) - } - ``` -2. Pane naming: `-` (e.g., `WP01-coder`, `WP03-reviewer`) -3. The Zellij command to create a worker pane: - ```bash - zellij run -n -- ocx oc -p kas -- --agent --prompt "" - ``` - Reference the existing pattern in `crates/kasmos/src/session.rs` `spawn_agent()` method. -4. The `prompt` field from input is passed to the agent command. -5. Worker starts in `Active` status. - -**Files**: `crates/kasmos/src/serve/tools/spawn_worker.rs` -**Validation**: Calling spawn_worker creates a Zellij pane with the correct command. - -### Subtask T040 - Implement coder-only worktree provisioning - -**Purpose**: Coders work in isolated git worktrees. Other roles work in the main repo. - -**Steps**: -1. When `role == "coder"`: - - Create worktree at `.worktrees/-/` - - Use the existing `WorktreeManager` in `crates/kasmos/src/git.rs`: - ```rust - let wt_manager = WorktreeManager::new(repo_root); - let worktree_path = wt_manager.create_worktree( - &feature_slug, &wp_id, &base_branch - ).await?; - ``` - - The worktree_path is passed to the agent command as `--cwd` -2. When `role != "coder"`: - - No worktree. Agent runs in the main repo directory. - - `worktree_path` in the registry entry is `None`. -3. Reference the existing `WorktreeManager` for create/cleanup patterns (including `.kittify/memory/` symlink handling). -4. Handle the case where a worktree already exists (idempotent creation or error). - -**Parallel?**: Can run alongside T041 once spawn contract is stable. -**Files**: `crates/kasmos/src/serve/tools/spawn_worker.rs`, referencing `crates/kasmos/src/git.rs` -**Validation**: Coder spawn creates worktree. Reviewer spawn does not. - -### Subtask T041 - Implement despawn_worker tool - -**Purpose**: Close a worker pane, update registry, and emit audit event. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/despawn_worker.rs`: - ```rust - pub async fn handle( - input: DespawnWorkerInput, - state: &KasmosServer, - ) -> Result { - // 1. Find worker in registry - let worker = find_worker(state, &input.wp_id, &input.role).await?; - // 2. Close Zellij pane - close_pane(&worker.pane_name).await; - // 3. Cleanup worktree (if coder) - if let Some(ref wt_path) = worker.worktree_path { - cleanup_worktree(wt_path).await?; - } - // 4. Remove from registry - remove_worker(state, &input.wp_id, &input.role).await; - // 5. Audit log - audit_despawn(state, &worker, input.reason.as_deref()).await; - Ok(DespawnWorkerOutput { ok: true, removed: true }) - } - ``` -2. Closing a Zellij pane: Use `zellij action close-pane` after focusing the target pane, or close by name if Zellij supports it. -3. If the pane is already gone (user manually closed it), the despawn should still succeed and clean up the registry. -4. Worktree cleanup for coders: The `WorktreeManager` in `git.rs` has cleanup methods. Only clean up if the worktree exists. - -**Files**: `crates/kasmos/src/serve/tools/despawn_worker.rs` -**Validation**: Despawn closes pane, removes from registry, cleans up worktree. - -### Subtask T042 - Implement list_workers with live-pane reconciliation - -**Purpose**: Return the current worker list with detection of panes that disappeared (user closed, crashed). - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/list_workers.rs`: - ```rust - pub async fn handle( - input: ListWorkersInput, - state: &KasmosServer, - ) -> Result { - let mut workers = get_workers(state).await; - // Reconcile: check which panes still exist - reconcile_panes(&mut workers).await; - // Filter by status if requested - if let Some(status_filter) = input.status { - workers.retain(|w| w.status == status_filter); - } - Ok(ListWorkersOutput { ok: true, workers }) - } - ``` -2. Reconciliation: For each worker with status `Active`, check if its Zellij pane still exists. Since Zellij has no `list-panes` command, use the zellij-pane-tracker MCP tool (`get_panes`) to check pane existence, or use `zellij action dump-screen` with the pane name as a proxy. -3. If a pane no longer exists, mark the worker as `Aborted` in the registry. -4. This reconciliation happens on every `list_workers` call to keep state fresh. - -**Files**: `crates/kasmos/src/serve/tools/list_workers.rs` -**Validation**: Workers with dead panes are marked as Aborted. Status filter works. - -### Subtask T043 - Implement max parallel worker enforcement - -**Purpose**: Prevent spawning more workers than the configured maximum. - -**Steps**: -1. In the `spawn_worker` handler, before creating the pane: - ```rust - async fn check_capacity(state: &KasmosServer) -> Result<()> { - let registry = state.registry.read().await; - let active_count = registry.workers.values() - .filter(|w| w.status == WorkerStatus::Active) - .count(); - if active_count >= state.config.agent.max_parallel_workers { - return Err(KasmosError::CapacityExceeded { - current: active_count, - max: state.config.agent.max_parallel_workers, - }); - } - Ok(()) - } - ``` -2. Return a clear error with current count and max, so the manager can make informed decisions (e.g., wait for a worker to finish before spawning a new one). -3. The limit is from `config.agent.max_parallel_workers` (default: 4). - -**Files**: `crates/kasmos/src/serve/tools/spawn_worker.rs` -**Validation**: Spawning beyond limit returns capacity error with counts. - -### Subtask T044 - Add worker lifecycle tests - -**Purpose**: Test spawn/despawn/list behavior and registry edge cases. - -**Steps**: -1. Test spawn creates registry entry with correct fields -2. Test despawn removes entry and returns removed=true -3. Test despawn of non-existent worker returns WORKER_NOT_FOUND -4. Test list returns all workers with correct statuses -5. Test capacity enforcement at limit -6. Test reconciliation marks missing panes as Aborted -7. Test coder spawn includes worktree_path, reviewer spawn does not -8. Use mocked Zellij commands for test isolation - -**Files**: Test modules in serve/tools files -**Validation**: `cargo test` passes with worker lifecycle tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Pane lifecycle race with manual user pane closure | Reconcile on each list_workers call. Despawn handles already-gone panes. | -| Worktree leaks for aborted coders | Cleanup hooks in despawn. Document manual recovery: `git worktree prune`. | -| Zellij pane naming collisions | Deterministic naming: `-`. Validate uniqueness before spawn. | - -## Review Guidance - -- Verify pane naming follows `-` convention -- Verify coder gets worktree, other roles do not -- Verify reconciliation detects dead panes -- Verify capacity enforcement returns clear error -- Verify despawn handles already-closed panes gracefully -- Verify audit events emitted for spawn and despawn - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-15T01:41:43Z – coder – shell_pid=566744 – lane=doing – Assigned agent via workflow command -- 2026-02-15T05:47:42Z – coder – shell_pid=566744 – lane=for_review – Ready for review: spawn/despawn/list_workers with registry, audit, reconciliation, capacity enforcement, worktree cleanup -- 2026-02-15T05:50:07Z – reviewer – shell_pid=1148797 – lane=doing – Started review via workflow command -- 2026-02-15T05:54:38Z – reviewer – shell_pid=1148797 – lane=planned – Moved to planned -- 2026-02-15T06:23:04Z – reviewer – shell_pid=1148797 – lane=for_review – Ready for re-review: addressed runtime fallbacks and registry consistency -- 2026-02-15T06:23:07Z – reviewer – shell_pid=1148797 – lane=doing – Started review via workflow command -- 2026-02-15T06:24:16Z – reviewer – shell_pid=1148797 – lane=done – Review passed: lifecycle runtime fallbacks and registry consistency verified -- 2026-02-15T06:24:49Z – reviewer – shell_pid=1148797 – lane=done – Review metadata synced diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP08-message-log-and-event-waiting.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP08-message-log-and-event-waiting.md deleted file mode 100644 index a3170d2..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP08-message-log-and-event-waiting.md +++ /dev/null @@ -1,385 +0,0 @@ ---- -work_package_id: WP08 -title: Message Log Parsing and Event Waiting -lane: "done" -dependencies: -- WP04 -base_branch: 011-mcp-agent-swarm-orchestration-WP04 -base_commit: a02df49238a89b34cf57dc156237af2bad587046 -created_at: '2026-02-15T01:41:35.969048+00:00' -subtasks: -- T045 -- T046 -- T047 -- T048 -- T049 -- T050 -- T075 -phase: Phase 2 - Safety, State, and Audit Guarantees -assignee: 'opencode' -agent: "reviewer" -shell_pid: "1148797" -review_status: "has_feedback" -reviewed_by: "reviewer" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP08 - Message Log Parsing and Event Waiting - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -**Reviewed by**: reviewer -**Status**: ❌ Changes Requested -**Date**: 2026-02-15 - -# Review Feedback: WP08 - Message Log Parsing and Event Waiting - -## Decision: NEEDS_CHANGES - -## Findings - -### [Medium] direct_scrollback_read always fails (messages.rs:220-221) - -`direct_scrollback_read()` calls `zellij action dump-screen` without the required `` argument. Zellij 0.43+ requires `dump-screen `. The degraded fallback always fails, so when pane-tracker is unavailable, `read_messages` and `wait_for_event` return hard errors instead of degraded data. - -**Fix**: Write to a temp file and read it back: - -```rust -async fn direct_scrollback_read() -> Result { - let probe = std::env::temp_dir().join(format!( - "kasmos-scrollback-{}.txt", - std::process::id() - )); - let probe_str = probe.display().to_string(); - run_command_capture("zellij", &["action", "dump-screen", &probe_str]).await?; - let content = tokio::fs::read_to_string(&probe).await - .context("read scrollback dump")?; - let _ = tokio::fs::remove_file(&probe).await; - Ok(content) -} -``` - -### [Low] pane-tracker arg brute-force not cached (messages.rs:168-183) - -`try_pane_tracker_dump` tries 4 CLI patterns on every call. Consider caching the working pattern in a `OnceLock` to skip failed patterns on subsequent calls. - -### [Low] WP filter checks payload instead of sender (read_messages.rs:55-61, wait_for_event.rs:84-92) - -Both filters match on `payload["wp_id"]` rather than the `sender` field. If a worker omits `wp_id` from the JSON payload, the filter silently excludes the message even though the sender captures the WP ID. - -### [Low] infer_feature_from_specs_root returns directory name (mod.rs:241-252) - -Returns the specs root directory name (e.g. "kitty-specs") as a feature slug. Currently unused by tool handlers, so no runtime impact. - - -## Implementation Command - -```bash -spec-kitty implement WP08 --base WP04 -``` - ---- - -## Objectives & Success Criteria - -Implement `read_messages` and `wait_for_event` MCP tools with structured parsing, incremental cursors, timeout behavior, and degraded-mode fallback. After this WP: - -1. Structured messages matching `[KASMOS::] ` are parsed correctly -2. `read_messages` returns messages since a cursor, with optional filtering, no duplicates -3. `wait_for_event` blocks until matching event or timeout, reports elapsed time -4. When pane-tracker is unavailable, system falls back to degraded polling with warning -5. Manager decision events are written to message-log pane for real-time transparency - -## Context & Constraints - -- **Depends on WP04**: Serve framework and typed tool structs available -- **Depends on WP06**: Audit writer available for logging -- **Contract**: `read_messages` and `wait_for_event` in `contracts/kasmos-serve.json` -- **Communication protocol**: Workers write `[KASMOS::] ` to the msg-log pane using zellij-pane-tracker's `run-in-pane` capability -- **Events**: STARTED, PROGRESS, DONE, ERROR, REVIEW_PASS, REVIEW_REJECT, NEEDS_INPUT -- **Data model**: `KasmosMessage` entity with message_index, sender, event, payload, timestamp - -## Subtasks & Detailed Guidance - -### Subtask T045 - Implement structured message parser - -**Purpose**: Parse `[KASMOS::] ` lines from the message-log pane scrollback, handling ANSI escape codes and malformed lines. - -**Steps**: -1. Create `crates/kasmos/src/serve/messages.rs`: - ```rust - use regex::Regex; - use std::sync::LazyLock; - - static MSG_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r"\[KASMOS:([^:]+):([^\]]+)\]\s*(.*)").unwrap() - }); - - pub fn parse_message(line: &str, index: u64) -> Option { - // Strip ANSI escape codes first - let clean = strip_ansi(line); - let caps = MSG_PATTERN.captures(&clean)?; - let sender = caps.get(1)?.as_str().to_string(); - let event = caps.get(2)?.as_str().to_string(); - let payload_str = caps.get(3)?.as_str(); - let payload = serde_json::from_str(payload_str).unwrap_or(serde_json::Value::Null); - Some(KasmosMessage { - message_index: index, - sender, - event, - payload, - timestamp: chrono::Utc::now(), - raw_line: line.to_string(), - }) - } - ``` -2. ANSI stripping: Terminal scrollback contains escape sequences. Strip them before parsing. - ```rust - fn strip_ansi(s: &str) -> String { - // Use regex or a dedicated crate like `strip-ansi-escapes` - let ansi_re = Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap(); - ansi_re.replace_all(s, "").to_string() - } - ``` -3. Be strict: only lines matching the exact `[KASMOS:...]` pattern are parsed. All other lines are ignored (they're normal terminal output from workers). -4. Validate that `event` maps to a known enum value. Unknown events should be preserved but flagged. - -**Files**: `crates/kasmos/src/serve/messages.rs` -**Validation**: Known message format parses correctly. Malformed lines return None. ANSI codes stripped. - -### Subtask T046 - Implement read_messages cursor semantics - -**Purpose**: Read messages from the msg-log pane with incremental cursor tracking and optional filtering. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/read_messages.rs`: - ```rust - pub async fn handle( - input: ReadMessagesInput, - state: &KasmosServer, - ) -> Result { - // 1. Read scrollback from msg-log pane - let scrollback = read_pane_scrollback("msg-log").await?; - // 2. Parse all message lines - let all_messages = parse_scrollback(&scrollback); - // 3. Filter by cursor (since_index) - let since = input.since_index.unwrap_or(0); - let mut messages: Vec<_> = all_messages.into_iter() - .filter(|m| m.message_index >= since) - .collect(); - // 4. Apply optional filters - if let Some(ref wp_filter) = input.filter_wp { - messages.retain(|m| m.payload.get("wp_id") - .and_then(|v| v.as_str()) == Some(wp_filter.as_str())); - } - if let Some(ref event_filter) = input.filter_event { - messages.retain(|m| m.event == *event_filter); - } - // 5. Compute next_index - let next_index = messages.last() - .map(|m| m.message_index + 1) - .unwrap_or(since); - Ok(ReadMessagesOutput { ok: true, messages, next_index }) - } - ``` -2. Scrollback reading: Use zellij-pane-tracker's `dump-pane` capability to read the msg-log pane contents. -3. Message indexing: Each message gets a monotonically increasing index based on its position in the scrollback. The cursor (`since_index`) allows the manager to read only new messages. -4. No duplicates: The index-based cursor ensures messages aren't processed twice. - -**Files**: `crates/kasmos/src/serve/tools/read_messages.rs` -**Validation**: Messages are returned in order. Cursor filters work. No duplicates. - -### Subtask T047 - Implement wait_for_event bounded blocking loop - -**Purpose**: Block until a matching event appears in the message log or timeout is reached. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/wait_for_event.rs`: - ```rust - pub async fn handle( - input: WaitForEventInput, - state: &KasmosServer, - ) -> Result { - let start = std::time::Instant::now(); - let timeout = std::time::Duration::from_secs(input.timeout_seconds as u64); - let poll_interval = std::time::Duration::from_secs( - state.config.communication.poll_interval_secs - ); - let mut cursor = state.message_cursor.read().await.clone(); - - loop { - // Check timeout - let elapsed = start.elapsed(); - if elapsed >= timeout { - return Ok(WaitForEventOutput { - ok: true, - status: "timeout".to_string(), - elapsed_seconds: elapsed.as_secs() as u32, - message: None, - }); - } - - // Read new messages - let messages = read_messages_since(cursor, state).await?; - for msg in &messages { - let matches = matches_filter(msg, &input); - if matches { - // Update cursor - *state.message_cursor.write().await = msg.message_index + 1; - return Ok(WaitForEventOutput { - ok: true, - status: "matched".to_string(), - elapsed_seconds: elapsed.as_secs() as u32, - message: Some(msg.clone()), - }); - } - } - if let Some(last) = messages.last() { - cursor = last.message_index + 1; - } - - // Sleep before next poll - tokio::time::sleep(poll_interval).await; - } - } - ``` -2. The loop polls the message log at `config.communication.poll_interval_secs` intervals. -3. Filter matching: optional `wp_id` and `event` filters from input. -4. Hard timeout prevents indefinite blocking. Returns `"timeout"` status with elapsed time. -5. On match, returns the matching message with `"matched"` status. - -**Files**: `crates/kasmos/src/serve/tools/wait_for_event.rs` -**Validation**: Matching event returns immediately. Timeout returns after specified seconds. - -### Subtask T048 - Implement degraded fallback when pane-tracker unavailable - -**Purpose**: If the zellij-pane-tracker service is unavailable, fall back to slower direct scrollback reading with explicit warning. - -**Steps**: -1. In the scrollback reading function, try the pane-tracker first: - ```rust - async fn read_pane_scrollback(pane_name: &str) -> Result { - match try_pane_tracker_dump(pane_name).await { - Ok(content) => Ok(content), - Err(e) => { - tracing::warn!( - "Pane-tracker unavailable: {e}. Falling back to direct scrollback." - ); - // Fallback: use zellij action dump-screen or similar - direct_scrollback_read(pane_name).await - } - } - } - ``` -2. In degraded mode, poll intervals should be longer (e.g., 2x normal) to reduce overhead. -3. Log a warning once (not every poll cycle) when operating in degraded mode. -4. The degraded mode still produces correct results, just with higher latency. - -**Files**: `crates/kasmos/src/serve/messages.rs` -**Validation**: System works (slower) when pane-tracker is unavailable. Warning is logged. - -### Subtask T049 - Write manager decisions to message-log pane - -**Purpose**: The manager should log its own orchestration decisions to the msg-log pane for real-time user visibility (FR-026). - -**Steps**: -1. Add a helper function for writing manager events to the msg-log pane: - ```rust - pub async fn log_manager_event( - event: &str, - payload: &serde_json::Value, - ) -> Result<()> { - let msg = format!( - "[KASMOS:manager:{}] {}", - event, - serde_json::to_string(payload)? - ); - // Write to msg-log pane using zellij-pane-tracker run-in-pane - write_to_pane("msg-log", &format!("echo '{}'", msg)).await - } - ``` -2. Manager events to log: SPAWN, DESPAWN, TRANSITION, WAVE_COMPLETE, ERROR, PAUSE, RESUME. -3. These messages use the same `[KASMOS:manager:]` format and are parseable by `read_messages`. -4. This is called from the serve tools when they execute manager actions (spawn, despawn, transition). - -**Files**: `crates/kasmos/src/serve/messages.rs` -**Validation**: Manager events appear in msg-log pane alongside worker events. - -### Subtask T050 - Add tests for message parsing and event tools - -**Purpose**: Test parser edge cases, duplicate protection, timeout semantics, and degraded mode. - -**Steps**: -1. Test parser with valid message format -2. Test parser ignores non-KASMOS lines -3. Test parser strips ANSI codes before matching -4. Test parser handles malformed JSON payload (returns null) -5. Test cursor-based deduplication (since_index filters correctly) -6. Test wait_for_event timeout returns correct elapsed time -7. Test wait_for_event match returns matched message -8. Test filter combinations (wp_id + event) - -**Files**: Test modules in messages.rs and tools files -**Validation**: `cargo test` passes with message/event tests. - -### Subtask T075 - Implement dashboard pane update side-effect - -**Purpose**: On each `wait_for_event` poll cycle, format the current worker status as a table and write it to the dashboard pane (FR-032). - -**Steps**: -1. After reading messages in the `wait_for_event` loop, gather current worker status from the registry: - ```rust - async fn update_dashboard(state: &KasmosServer) -> Result<()> { - let workers = state.registry.read().await; - let table = format_worker_table(&workers); - write_to_pane("dashboard", &format!("echo '{}'", table)).await?; - Ok(()) - } - ``` -2. Format as a simple ANSI table showing: WP ID, Role, Status, Elapsed Time -3. Clear and rewrite the dashboard pane on each update (not append). -4. Use the existing `DashboardState::format_ansi()` pattern from the data model if available, or implement a simple table formatter. -5. **Important**: Dashboard write failures MUST NOT crash the poll loop. Log the error and continue. -6. Dashboard updates are a side-effect of polling, not on a separate timer. - -**Files**: `crates/kasmos/src/serve/tools/wait_for_event.rs`, `crates/kasmos/src/serve/messages.rs` -**Validation**: Dashboard pane shows updated worker status. Write failures don't crash polling. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Scrollback noise causing parser misreads | Strict prefix matching plus JSON parse validation. Only `[KASMOS:` prefix triggers parsing. | -| Waiting loop blocking manager progress | Hard timeout with explicit status codes. Manager handles timeout as a recoverable event. | -| High-frequency messages overwhelming parser | Cursor-based incremental reading. Only process new messages. | - -## Review Guidance - -- Verify message format matches protocol: `[KASMOS::] ` -- Verify ANSI stripping happens before regex matching -- Verify cursor prevents duplicate processing -- Verify wait_for_event respects timeout -- Verify degraded mode works and logs warning -- Verify manager events are written to msg-log pane - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-15T01:42:14Z – coder – shell_pid=571423 – lane=doing – Assigned agent via workflow command -- 2026-02-15T06:27:33Z – coder – shell_pid=571423 – lane=for_review – Ready for review -- 2026-02-15T06:28:08Z – reviewer – shell_pid=1148797 – lane=doing – Started review via workflow command -- 2026-02-15T06:31:46Z – reviewer – shell_pid=1148797 – lane=planned – Moved to planned -- 2026-02-15T06:44:02Z – reviewer – shell_pid=1148797 – lane=for_review – Ready for review -- 2026-02-15T06:46:10Z – reviewer – shell_pid=1148797 – lane=doing – Started review via workflow command -- 2026-02-15T06:49:24Z – reviewer – shell_pid=1148797 – lane=done – Review passed (2nd round): All 7 subtasks verified, 262 tests pass, 4 prior findings confirmed fixed, no new issues diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP09-workflow-status-and-transitions.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP09-workflow-status-and-transitions.md deleted file mode 100644 index 5d812be..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP09-workflow-status-and-transitions.md +++ /dev/null @@ -1,329 +0,0 @@ ---- -work_package_id: WP09 -title: Workflow Status and Transition Controls -lane: "done" -dependencies: [WP04] -base_branch: 011-mcp-agent-swarm-orchestration-WP04 -base_commit: a02df49238a89b34cf57dc156237af2bad587046 -created_at: '2026-02-15T01:01:51.630409+00:00' -subtasks: -- T051 -- T052 -- T053 -- T054 -- T055 -- T056 -phase: Phase 2 - Safety, State, and Audit Guarantees -assignee: 'opencode' -agent: "reviewer" -shell_pid: "543338" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP09 - Workflow Status and Transition Controls - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP09 --base WP04 -``` - ---- - -## Objectives & Success Criteria - -Implement `workflow_status` and `transition_wp` MCP tools against spec-kitty task lanes as the single source of truth, with wave awareness and review loop caps. After this WP: - -1. `workflow_status` reports correct phase using expanded model (spec_only, clarifying, planned, analyzing, tasked, implementing, reviewing, releasing, complete) where clarifying/analyzing are optional -2. `workflow_status` includes computed wave structure from WP dependency metadata -3. `transition_wp` validates state machine rules and persists via lane translation -4. Advisory lock protection prevents concurrent task-file corruption -5. Review-rejection loop cap (default 3) is enforced with escalation to user -6. Lane translation follows the protocol: kasmos states -> spec-kitty lanes (see data-model.md) - -## Context & Constraints - -- **Depends on WP04**: Serve framework and typed tool structs available -- **Contract**: `workflow_status` and `transition_wp` in `contracts/kasmos-serve.json` -- **Data model**: Lane Translation Protocol in `data-model.md`: - - `pending` <-> `planned` (bidirectional) - - `active` <-> `doing` (bidirectional) - - `for_review` <-> `for_review` (shared) - - `done` <-> `done` (shared) - - `rework` -> `doing` (write-only; rework context in audit log reason field) -- **Existing code**: `crates/kasmos/src/parser.rs` (433 lines) has `WPFrontmatter`, `parse_frontmatter()`, `wp_state_to_lane()`. `crates/kasmos/src/graph.rs` (376 lines) has `DependencyGraph` with topological sort and wave computation. `crates/kasmos/src/state_machine.rs` (341 lines) has WPState/RunState transition validation. -- **Key constraint**: Task file lanes are SSOT. No parallel shadow state. Kasmos reads from and writes to spec-kitty lanes. - -## Subtasks & Detailed Guidance - -### Subtask T051 - Implement workflow_status artifact scan - -**Purpose**: Scan feature artifacts to determine the current workflow phase and report a complete status snapshot. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/workflow_status.rs`: - ```rust - pub async fn handle( - input: WorkflowStatusInput, - state: &KasmosServer, - ) -> Result { - let feature_dir = resolve_feature_dir(&input.feature_slug)?; - let phase = determine_phase(&feature_dir)?; - let waves = compute_waves(&feature_dir)?; - let lock = get_lock_state(&input.feature_slug)?; - Ok(WorkflowStatusOutput { - ok: true, - snapshot: WorkflowSnapshot { - feature_slug: input.feature_slug, - phase, - waves, - active_workers: get_active_workers(state).await, - last_event_at: get_last_event(state).await, - lock, - }, - }) - } - ``` -2. Phase determination logic (per expanded WorkflowSnapshot model in `data-model.md`): - - `spec_only`: spec.md exists, no plan.md, no clarification artifacts - - `clarifying`: spec.md exists, clarification session in progress (optional phase - smaller features skip this) - - `planned`: plan.md exists, no tasks.md or empty tasks/ - - `analyzing`: plan.md and tasks.md exist, analysis in progress (optional phase - smaller features skip this) - - `tasked`: tasks/ has WP files, all in `planned` lane - - `implementing`: any WP in `doing` lane - - `reviewing`: any WP in `for_review` lane (and none in `doing`) - - `releasing`: all WPs in `done` lane, release process initiated but not yet complete - - `complete`: all WPs in `done` lane and release completed - Note: `clarifying` and `analyzing` are optional. Phase derivation is from artifact presence, not WP lane states. -3. Read task file frontmatter using the existing `parser::parse_frontmatter()` function. -4. Include lock metadata from WP05's lock manager. - -**Files**: `crates/kasmos/src/serve/tools/workflow_status.rs` -**Validation**: Correct phase reported for each artifact combination. - -### Subtask T052 - Integrate dependency graph wave computation - -**Purpose**: Compute wave assignments from WP dependency metadata to enable wave-ordered execution. - -**Steps**: -1. Reuse the existing `DependencyGraph` in `crates/kasmos/src/graph.rs`: - ```rust - fn compute_waves(feature_dir: &Path) -> Result> { - let feature = FeatureDir::scan(feature_dir)?; - let mut graph = DependencyGraph::new(); - for wp_file in &feature.wp_files { - let fm = parse_frontmatter(wp_file)?; - graph.add_node(&fm.work_package_id, &fm.dependencies); - } - let waves = graph.compute_waves()?; - waves.iter().enumerate().map(|(i, wp_ids)| { - let complete = wp_ids.iter().all(|id| is_done(feature_dir, id)); - WaveStatus { wave: i as u32, wp_ids: wp_ids.clone(), complete } - }).collect() - } - ``` -2. The existing `DependencyGraph` already does topological sort and wave computation. Leverage it. -3. Map wave results to the contract's `WorkflowSnapshot.waves` format. -4. Each wave reports whether all its WPs are complete. - -**Parallel?**: Yes - can run alongside T054 once parser contract for task metadata is finalized. -**Files**: `crates/kasmos/src/serve/tools/workflow_status.rs`, using `crates/kasmos/src/graph.rs` -**Validation**: Wave computation matches expected dependency ordering. - -### Subtask T053 - Implement transition_wp with validation and lane translation - -**Purpose**: Validate and apply WP state transitions, translating between kasmos vocabulary and spec-kitty lanes. - -**Steps**: -1. Implement in `crates/kasmos/src/serve/tools/transition_wp.rs`: - ```rust - pub async fn handle( - input: TransitionWpInput, - state: &KasmosServer, - ) -> Result { - let feature_dir = resolve_feature_dir(&input.feature_slug)?; - let wp_file = find_wp_file(&feature_dir, &input.wp_id)?; - // 1. Read current state - let fm = parse_frontmatter(&wp_file)?; - let from_state = lane_to_kasmos_state(&fm.lane); - // 2. Validate transition - validate_transition(&from_state, &input.to_state)?; - // 3. Check rejection loop cap - if input.to_state == "rework" { - check_rejection_cap(&input.wp_id, state).await?; - } - // 4. Translate to spec-kitty lane - let new_lane = kasmos_state_to_lane(&input.to_state); - // 5. Write to task file (with advisory lock) - update_task_lane(&wp_file, &new_lane, &input.actor, input.reason.as_deref()).await?; - // 6. Audit log - audit_transition(state, &input, &from_state).await; - Ok(TransitionWpOutput { - ok: true, - wp_id: input.wp_id, - from_state: from_state.to_string(), - to_state: input.to_state, - }) - } - ``` -2. Lane translation (from data-model.md): - ```rust - fn kasmos_state_to_lane(state: &str) -> &str { - match state { - "pending" => "planned", - "active" => "doing", - "for_review" => "for_review", - "done" => "done", - "rework" => "doing", // rework context in audit log, not lane - _ => "planned", - } - } - - fn lane_to_kasmos_state(lane: &str) -> String { - match lane { - "planned" => "pending", - "doing" => "active", // may also be "rework" - check history - "for_review" => "for_review", - "done" => "done", - _ => "pending", - } - } - ``` -3. The existing `parser::wp_state_to_lane()` handles some of this. Extend or replace it with the full kasmos vocabulary. -4. **Rework handling**: `rework` writes as `doing` lane but preserves rework semantics via the audit log `reason` field. On read-back, distinguish `active` from `rework` by checking transition history (prior `for_review` implies rework). -5. Transition validation: Use existing `state_machine.rs` patterns or extend them for the new kasmos states. -6. Return `TRANSITION_NOT_ALLOWED` error code for invalid transitions. - -**Files**: `crates/kasmos/src/serve/tools/transition_wp.rs` -**Validation**: Valid transitions succeed. Invalid transitions return error. Lane translation is correct. - -### Subtask T054 - Implement advisory lock protection for task-file writes - -**Purpose**: Prevent concurrent corruption when multiple processes write to the same task file. - -**Steps**: -1. Before writing to a task file, acquire an advisory lock: - ```rust - async fn update_task_lane( - wp_file: &Path, - new_lane: &str, - actor: &str, - reason: Option<&str>, - ) -> Result<()> { - let lock_path = wp_file.with_extension("lock"); - let lock_file = std::fs::File::create(&lock_path)?; - // Advisory lock (non-blocking attempt) - nix::fcntl::flock( - lock_file.as_raw_fd(), - nix::fcntl::FlockArg::LockExclusiveNonblock, - ).map_err(|_| KasmosError::ConcurrentWrite { - file: wp_file.display().to_string(), - })?; - // Perform the write - write_lane_update(wp_file, new_lane, actor, reason)?; - // Lock released when lock_file is dropped - Ok(()) - } - ``` -2. Use nix's `flock` (already a dependency) for cross-process advisory locking. -3. Non-blocking: if lock can't be acquired, return an error rather than waiting. -4. Lock file: `.lock` (sibling to the task file). - -**Parallel?**: Yes - can run alongside T052 once parser contract is finalized. -**Files**: `crates/kasmos/src/serve/tools/transition_wp.rs` -**Validation**: Concurrent writes to same file are prevented. Lock files are cleaned up. - -### Subtask T055 - Enforce review-rejection loop cap - -**Purpose**: Prevent infinite review-rejection-rework cycles by capping at a configurable maximum (FR-023). - -**Steps**: -1. Track rejection count per WP: - ```rust - async fn check_rejection_cap( - wp_id: &str, - state: &KasmosServer, - ) -> Result<()> { - let count = get_rejection_count(wp_id, state).await; - if count >= state.config.agent.review_rejection_cap { - return Err(KasmosError::RejectionCapReached { - wp_id: wp_id.to_string(), - count, - cap: state.config.agent.review_rejection_cap, - }); - } - Ok(()) - } - ``` -2. The rejection count can be tracked in the worker registry or derived from audit log entries. -3. When the cap is reached, return a specific error that tells the manager to pause and escalate to the user. -4. Default cap: 3 iterations (from `config.agent.review_rejection_cap`). - -**Files**: `crates/kasmos/src/serve/tools/transition_wp.rs` -**Validation**: Third rejection triggers cap error. Manager is forced to pause. - -### Subtask T056 - Add tests for workflow and transition tools - -**Purpose**: Test phase derivation, transition guards, wave ordering, and concurrent writers. - -**Steps**: -1. Test phase detection for each artifact combination -2. Test wave computation from dependency graph -3. Test valid transitions succeed (pending->active, active->for_review, etc.) -4. Test invalid transitions fail (e.g., pending->done) -5. Test lane translation roundtrip (kasmos -> spec-kitty -> kasmos) -6. Test rejection cap enforcement -7. Test advisory lock prevents concurrent writes -8. Use tempfile for test isolation with mock task files - -**Files**: Test modules in workflow_status.rs and transition_wp.rs -**Validation**: `cargo test` passes with workflow/transition tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Malformed task files breaking status API | Robust parse errors with file-level context. Skip unparseable files with warning. | -| Race conditions under multi-process orchestration | Feature lock (WP05) prevents multiple managers. Advisory locks prevent concurrent writes. | -| rework/active ambiguity when reading `doing` lane | Use audit log history to distinguish. Document the heuristic clearly. | - -## Review Guidance - -- Verify lane translation matches data-model.md protocol exactly -- Verify rework writes as `doing` with reason in audit log -- Verify phase detection covers all artifact combinations -- Verify transition validation matches state machine rules -- Verify rejection cap is enforced and returns clear error -- Verify advisory locking uses nix flock correctly - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-15T01:18:03Z – unknown – shell_pid=212269 – lane=for_review – Ready for review -- 2026-02-15T01:19:16Z – reviewer – shell_pid=418409 – lane=doing – Started review via workflow command -- 2026-02-15T01:22:36Z – reviewer – shell_pid=418409 – lane=for_review – Moved to for_review -- 2026-02-15T01:22:39Z – reviewer – shell_pid=418409 – lane=done – Moved to done -- 2026-02-15T01:22:50Z – reviewer – shell_pid=418409 – lane=done – Review APPROVED — all subtasks verified, lane translation correct, advisory locking correct, 256 tests pass, clippy clean -- 2026-02-15T01:32:59Z – reviewer – shell_pid=418409 – lane=for_review – Moved to for_review -- 2026-02-15T01:33:02Z – reviewer – shell_pid=543338 – lane=doing – Started review via workflow command -- 2026-02-15T01:34:03Z – reviewer – shell_pid=543338 – lane=done – Moved to done -- 2026-02-15T01:34:07Z – reviewer – shell_pid=543338 – lane=done – Re-review APPROVED — all 5 prior minor findings resolved, 259 tests pass, clippy clean, contract compliant diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP10-setup-command-and-launch-hardening.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP10-setup-command-and-launch-hardening.md deleted file mode 100644 index 24a99ed..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP10-setup-command-and-launch-hardening.md +++ /dev/null @@ -1,283 +0,0 @@ ---- -work_package_id: WP10 -title: Setup Command and Launch Hardening -lane: "done" -dependencies: [WP03] -base_branch: 011-mcp-agent-swarm-orchestration-WP03 -base_commit: 5ede493dbac49ea7462a399719ed32e777981362 -created_at: '2026-02-15T01:01:51.958671+00:00' -subtasks: -- T057 -- T058 -- T059 -- T060 -- T061 -- T062 -phase: Phase 3 - Setup UX, Role Context, and End-to-End Hardening -assignee: 'opencode' -agent: "reviewer" -shell_pid: "418927" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP10 - Setup Command and Launch Hardening - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP10 --base WP03 -``` - ---- - -## Objectives & Success Criteria - -Deliver `kasmos setup` as a first-time environment validation and configuration generation tool, and ensure launch-time preflight remains strict. After this WP: - -1. `kasmos setup` validates all required dependencies and reports pass/fail for each -2. `kasmos setup` generates missing baseline config/profile assets idempotently -3. Missing dependencies produce actionable installation guidance -4. Launch preflight shares the same check engine and exits before session/tab creation -5. All failure paths return non-zero exit codes - -## Context & Constraints - -- **Depends on WP03**: Launch flow available with preflight stubs -- **Spec FR-022**: Setup command for first-time environment validation -- **Spec FR-021**: Launch preflight must validate dependencies before creating sessions/tabs -- **SC-009**: Clean setup on properly configured machine completes within 5 seconds -- **Existing code**: `which` crate (already a dependency) for binary detection - -## Subtasks & Detailed Guidance - -### Subtask T057 - Implement setup command validation flow - -**Purpose**: Create the `kasmos setup` command that validates each dependency and reports structured results. - -**Steps**: -1. Populate `crates/kasmos/src/setup/mod.rs`: - ```rust - pub struct SetupResult { - pub checks: Vec, - pub all_passed: bool, - } - - pub struct CheckResult { - pub name: String, - pub description: String, - pub status: CheckStatus, - pub guidance: Option, - } - - pub enum CheckStatus { Pass, Fail, Warn } - - pub async fn run() -> anyhow::Result<()> { - let config = crate::config::Config::load()?; - let result = validate_environment(&config)?; - print_results(&result); - if !result.all_passed { - std::process::exit(1); - } - Ok(()) - } - - pub fn validate_environment(config: &Config) -> Result { - let mut checks = Vec::new(); - checks.push(check_zellij(&config.paths.zellij_binary)); - checks.push(check_opencode(&config.agent.opencode_binary)); - checks.push(check_spec_kitty(&config.paths.spec_kitty_binary)); - checks.push(check_pane_tracker()); - checks.push(check_git()); - checks.push(check_config_files()); - let all_passed = checks.iter().all(|c| c.status != CheckStatus::Fail); - Ok(SetupResult { checks, all_passed }) - } - ``` -2. Each check validates one dependency: - - `zellij`: Binary exists and is executable - - `opencode` (ocx): Binary exists - - `spec-kitty`: Binary exists - - Pane-tracker: Zellij plugin is available - - `git`: Binary exists and we're in a git repo - - Config files: `kasmos.toml` exists (warn if missing, not fail) -3. Output format - clear, colorized terminal output: - ``` - kasmos setup - [PASS] zellij ........... /usr/local/bin/zellij (v0.41.2) - [PASS] opencode (ocx) .. /usr/local/bin/ocx - [PASS] spec-kitty ...... /usr/local/bin/spec-kitty - [PASS] pane-tracker .... plugin available - [PASS] git ............. /usr/bin/git (in git repo) - [WARN] config .......... kasmos.toml not found (using defaults) - - All required checks passed. - ``` - -**Files**: `crates/kasmos/src/setup/mod.rs` -**Validation**: `kasmos setup` reports all checks. Missing dependency shows [FAIL]. - -### Subtask T058 - Implement idempotent config/profile asset generation - -**Purpose**: Generate missing baseline configuration and agent profile files on first setup. - -**Steps**: -1. If `kasmos.toml` doesn't exist at repo root, generate a default: - ```rust - fn generate_default_config() -> Result<()> { - let path = PathBuf::from("kasmos.toml"); - if path.exists() { return Ok(()); } // idempotent - let config = Config::default(); - let toml_str = toml::to_string_pretty(&config)?; - std::fs::write(&path, toml_str)?; - println!("Generated: kasmos.toml"); - Ok(()) - } - ``` -2. If `config/profiles/kasmos/` doesn't exist, generate default profile directory with: - - `opencode.jsonc` - OpenCode MCP configuration pointing to `kasmos serve` - - Agent prompt templates: `manager.md`, `coder.md`, `reviewer.md`, `release.md` -3. Never overwrite existing files. Only create missing ones. -4. Report what was created. - -**Parallel?**: Yes - can proceed alongside T060 after core setup skeleton exists. -**Files**: `crates/kasmos/src/setup/mod.rs` -**Validation**: First run creates files. Second run creates nothing (idempotent). - -### Subtask T059 - Ensure launch shares preflight engine - -**Purpose**: The launch path and setup command should use the same validation engine to prevent drift. - -**Steps**: -1. Extract the validation logic into a shared function: - ```rust - // In setup/mod.rs - pub fn validate_environment(config: &Config) -> Result { ... } - - // In launch/mod.rs - pub fn preflight_checks(config: &Config) -> Result<()> { - let result = crate::setup::validate_environment(config)?; - if !result.all_passed { - let failures: Vec<_> = result.checks.iter() - .filter(|c| c.status == CheckStatus::Fail) - .collect(); - print_failures(&failures); - std::process::exit(1); - } - Ok(()) - } - ``` -2. The launch preflight calls the SAME validation function, just formats the output differently (launch shows only failures, setup shows all). -3. This ensures both paths check the same things. - -**Files**: `crates/kasmos/src/setup/mod.rs`, `crates/kasmos/src/launch/mod.rs` -**Validation**: Adding a new check to setup automatically adds it to launch preflight. - -### Subtask T060 - Add per-dependency remediation guidance - -**Purpose**: Each failed check should include specific installation/configuration guidance. - -**Steps**: -1. Per-dependency guidance strings: - ```rust - fn zellij_guidance() -> &'static str { - "Install zellij: cargo install zellij\n\ - Or see: https://zellij.dev/documentation/installation" - } - - fn opencode_guidance() -> &'static str { - "Install opencode: see project documentation\n\ - Ensure 'ocx' binary is in PATH" - } - - fn spec_kitty_guidance() -> &'static str { - "Install spec-kitty: pip install spec-kitty\n\ - Or see project documentation" - } - - fn pane_tracker_guidance() -> &'static str { - "Install zellij-pane-tracker plugin.\n\ - See: https://github.com/example/zellij-pane-tracker" - } - ``` -2. Guidance is attached to each `CheckResult` as the `guidance` field. -3. Launch preflight shows guidance for each failure. - -**Parallel?**: Yes - can proceed alongside T058 after core setup skeleton exists. -**Files**: `crates/kasmos/src/setup/mod.rs` -**Validation**: Missing dependency shows actionable guidance. - -### Subtask T061 - Ensure non-zero exit code mapping - -**Purpose**: All failure scenarios in setup and launch preflight must return non-zero exit codes. - -**Steps**: -1. `kasmos setup` with failures: exit code 1 -2. `kasmos` launch with missing deps: exit code 1 -3. `kasmos` launch with no specs: exit code 0 (not a failure, just nothing to do) -4. Ensure `std::process::exit()` is called appropriately, OR use `anyhow::Result` and let main() propagate the error. -5. Verify with shell: `kasmos setup; echo $?` should show 0 or 1. - -**Files**: `crates/kasmos/src/setup/mod.rs`, `crates/kasmos/src/launch/mod.rs`, `crates/kasmos/src/main.rs` -**Validation**: Failed checks produce non-zero exit. Successful checks produce zero. - -### Subtask T062 - Add tests for setup and launch hard-fail - -**Purpose**: Test setup pass/fail scenarios and launch preflight guarantees. - -**Steps**: -1. Test setup passes with all dependencies present -2. Test setup fails with missing binary -3. Test setup generates config file when missing -4. Test setup is idempotent (second run changes nothing) -5. Test launch preflight shares same checks as setup -6. Test launch exits non-zero before creating session on failure -7. Use PATH manipulation and tempfile for test isolation - -**Files**: Test modules in setup/mod.rs and launch/mod.rs -**Validation**: `cargo test` passes with setup/preflight tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Setup and launch checks drift over time | Shared validation function prevents drift by design | -| False positives for pane-tracker availability | Include functional probe (version check) in addition to binary lookup | -| Config generation conflicts with existing files | Never overwrite. Only create missing files. | - -## Review Guidance - -- Verify setup and launch share the same validation engine -- Verify all failures include actionable remediation guidance -- Verify idempotent config generation (no overwrites) -- Verify non-zero exit codes on failure -- Verify output is clear and readable -- Verify SC-009: clean setup completes within 5 seconds - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-15T01:18:04Z – unknown – shell_pid=212269 – lane=for_review – Ready for review -- 2026-02-15T01:19:23Z – reviewer – shell_pid=418927 – lane=doing – Started review via workflow command -- 2026-02-15T01:22:58Z – reviewer – shell_pid=418927 – lane=done – Review passed: All 6 subtasks satisfied. Setup validates deps, generates assets idempotently, shares engine with launch, provides guidance, returns non-zero on failure. 252/252 tests pass. -- 2026-02-15T01:33:05Z – reviewer – shell_pid=418927 – lane=for_review – Re-submitting after addressing review findings -- 2026-02-15T01:33:09Z – reviewer – shell_pid=418927 – lane=doing – Started review via workflow command -- 2026-02-15T01:33:53Z – reviewer – shell_pid=418927 – lane=done – Re-review passed: all 5 previous findings addressed (required_for field, single repo_root call, terminal-aware color, version reporting, planner.md). 252/252 tests pass. 0 findings remain. diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP11-agent-profiles-and-prompt-context.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP11-agent-profiles-and-prompt-context.md deleted file mode 100644 index 828e936..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP11-agent-profiles-and-prompt-context.md +++ /dev/null @@ -1,301 +0,0 @@ ---- -work_package_id: WP11 -title: Agent Profiles and Prompt Context Boundaries -lane: "done" -dependencies: [WP02] -base_branch: 011-mcp-agent-swarm-orchestration-WP02 -base_commit: 839ff563e7dfa7894ce4b53b37f439478bf887a6 -created_at: '2026-02-14T22:27:45.338853+00:00' -subtasks: -- T063 -- T064 -- T065 -- T066 -- T067 -- T068 -phase: Phase 3 - Setup UX, Role Context, and End-to-End Hardening -assignee: 'opencode' -agent: 'opencode' -shell_pid: "3114343" -review_status: "approved" -reviewed_by: "kas" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP11 - Agent Profiles and Prompt Context Boundaries - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -*[This section is empty initially.]* - ---- - -## Implementation Command - -```bash -spec-kitty implement WP11 --base WP02 -``` - ---- - -## Objectives & Success Criteria - -Implement role-specific prompt/context assembly that enforces scope boundaries and OpenCode runtime consistency (FR-025, FR-028-033). After this WP: - -1. Manager prompts include broadest context: full spec, plan, task board, architecture memory, project structure -2. Coder prompts include narrow context: specific WP task file only, coding standards, scoped architecture -3. Reviewer prompts include medium context: WP task file, coder changes, acceptance criteria, standards -4. Release prompts include broad structural context: all WP statuses, branch structure, merge target -5. Planner prompts include medium-broad context: full spec, plan, workflow state, architecture memory, workflow intelligence - but NOT individual WP task files or coding standards (FR-033) -6. All roles use a single agent runtime (OpenCode via ocx) - FR-025 -6. Profile assets exist under `config/profiles/kasmos/` with role-specific configurations - -## Context & Constraints - -- **Depends on WP02**: Config system available -- **Spec FR-025**: Single agent runtime (OpenCode) for all roles -- **Spec FR-028**: Manager = broadest context -- **Spec FR-029**: Coder = narrowest (WP task file as contract, standards, scoped arch memory) -- **Spec FR-030**: Reviewer = medium (WP task file, changes, acceptance criteria, standards) -- **Spec FR-031**: Release = broad structural (all WP statuses, branch structure, merge target) -- **Spec FR-033**: Planner = medium-broad (full spec, plan, workflow state, architecture, workflow intelligence; NOT WP task files or coding standards) -- **Existing code**: `crates/kasmos/src/prompt.rs` (490 lines) has `PromptGenerator` and `PromptContext` for generating agent prompts. `crates/kasmos/src/session.rs` builds opencode commands. -- **Plan**: Profile assets at `config/profiles/kasmos/` with `opencode.jsonc` and role `.md` files - -## Subtasks & Detailed Guidance - -### Subtask T063 - Define/update OpenCode profile assets - -**Purpose**: Create the OpenCode configuration and role-specific prompt templates that define how each agent role is configured. - -**Steps**: -1. Create `config/profiles/kasmos/opencode.jsonc`: - ```jsonc - { - // OpenCode profile for kasmos agent swarm - "mcpServers": { - "kasmos": { - "command": "kasmos", - "args": ["serve"], - "type": "stdio" - }, - "zellij-pane-tracker": { - // Pane tracking MCP server config - } - } - } - ``` -2. Create role-specific prompt templates: - - `config/profiles/kasmos/agent/manager.md` - Manager system prompt template - - `config/profiles/kasmos/agent/planner.md` - Planner (planning phase worker) template - - `config/profiles/kasmos/agent/coder.md` - Coder system prompt template - - `config/profiles/kasmos/agent/reviewer.md` - Reviewer system prompt template - - `config/profiles/kasmos/agent/release.md` - Release agent system prompt template -3. Each template defines: - - Role identity and responsibilities - - Available MCP tools and how to use them - - Communication protocol (how to send messages to msg-log) - - Context boundaries (what to read, what NOT to read) - - Completion signaling protocol - -**Files**: `config/profiles/kasmos/opencode.jsonc`, `config/profiles/kasmos/agent/*.md` -**Validation**: Profile directory has all required files with correct structure. - -### Subtask T064 - Rewrite prompt.rs for role-aware context assembly - -**Purpose**: Replace the current prompt generation with role-aware context assembly that respects scope boundaries. - -**Steps**: -1. Refactor `crates/kasmos/src/prompt.rs` to support role-based prompt generation: - ```rust - pub enum AgentRole { - Manager, - Planner, - Coder, - Reviewer, - Release, - } - - pub struct RolePromptBuilder { - role: AgentRole, - feature_slug: String, - feature_dir: PathBuf, - wp_id: Option, - wp_file: Option, - additional_context: Option, - } - - impl RolePromptBuilder { - pub fn build(&self) -> Result { - match self.role { - AgentRole::Manager => self.build_manager_prompt(), - AgentRole::Planner => self.build_planner_prompt(), - AgentRole::Coder => self.build_coder_prompt(), - AgentRole::Reviewer => self.build_reviewer_prompt(), - AgentRole::Release => self.build_release_prompt(), - } - } - } - ``` -2. Each role builder loads the corresponding template from `config/profiles/kasmos/agent/` and injects context. -3. Preserve the existing `PromptContext` and `PromptGenerator` behind `#[cfg(feature = "tui")]` for legacy compatibility. -4. The new `RolePromptBuilder` is the primary API for MCP-mode prompts. - -**Files**: `crates/kasmos/src/prompt.rs` -**Validation**: Each role produces a prompt with appropriate context. - -### Subtask T065 - Implement manager bootstrap prompt contract - -**Purpose**: Define the manager's startup prompt that instructs it on assessment, confirmation gates, and kasmos serve subprocess ownership. - -**Steps**: -1. Manager prompt includes: - - Full feature spec summary (read from spec.md) - - Plan summary (read from plan.md) - - Task board overview (read from tasks.md - all WP statuses) - - Architecture memory (read from `.kittify/memory/architecture.md`) - - Workflow intelligence (read from `.kittify/memory/workflow-intelligence.md`) - - Constitution reference (path to `.kittify/memory/constitution.md`) - - Project structure overview - - Instructions for `kasmos serve` MCP tools - - Explicit instruction: assess phase, present summary, wait for confirmation -2. The prompt is assembled by reading actual file contents and embedding relevant sections. -3. Keep the prompt focused despite breadth - summarize large documents rather than including them verbatim. -4. Include explicit tool usage guidance: which MCP tools to use for each operation. - -**Files**: `crates/kasmos/src/prompt.rs`, `config/profiles/kasmos/agent/manager.md` -**Validation**: Manager prompt includes all required context references. - -### Subtask T066 - Implement worker prompt contract for message-log communication - -**Purpose**: All worker prompts must include instructions for structured message-log communication. - -**Steps**: -1. Every worker prompt (coder, reviewer, release) must include: - ``` - ## Communication Protocol - When you reach a milestone, send a structured message to the msg-log pane: - - Use the zellij-pane-tracker run-in-pane tool - - Target pane: "msg-log" - - Message format: echo '[KASMOS::] {"wp_id":"", ...}' - - Events you must send: - - STARTED: When you begin work - - PROGRESS: At significant milestones - - DONE: When your task is complete - - ERROR: If you encounter a blocking error - - NEEDS_INPUT: If you need user/manager input - ``` -2. Coder-specific additions: REVIEW_PASS and REVIEW_REJECT are NOT sent by coders. -3. Reviewer-specific additions: Send REVIEW_PASS or REVIEW_REJECT with feedback payload. -4. Embed this protocol in each role's prompt template. - -**Files**: `config/profiles/kasmos/agent/coder.md`, `config/profiles/kasmos/agent/reviewer.md`, `config/profiles/kasmos/agent/release.md` -**Validation**: Worker prompts include communication protocol instructions. - -### Subtask T067 - Enforce context minimization rules by role - -**Purpose**: Ensure coders don't receive full spec, reviewers don't receive other WPs, etc. - -**Steps**: -1. Define context allowlists per role: - ```rust - fn allowed_context(role: &AgentRole) -> ContextBoundary { - match role { - AgentRole::Manager => ContextBoundary { - spec: true, plan: true, all_tasks: true, - architecture: true, workflow_intelligence: true, - constitution: true, project_structure: true, - }, - AgentRole::Coder => ContextBoundary { - spec: false, plan: false, all_tasks: false, - architecture: true, // scoped to relevant subsystems - workflow_intelligence: false, - constitution: true, - project_structure: false, - }, - AgentRole::Reviewer => ContextBoundary { - spec: false, plan: false, all_tasks: false, - architecture: true, // scoped to affected areas - workflow_intelligence: false, - constitution: true, - project_structure: false, - }, - AgentRole::Release => ContextBoundary { - spec: false, plan: false, all_tasks: true, // statuses only - architecture: false, - workflow_intelligence: false, - constitution: true, - project_structure: true, - }, - AgentRole::Planner => ContextBoundary { - spec: true, plan: true, all_tasks: false, - architecture: true, workflow_intelligence: true, - constitution: true, project_structure: true, - }, - } - } - ``` -2. The prompt builder uses this boundary to include or exclude context sections. -3. **Coder gets**: WP task file (as contract), constitution, scoped architecture memory -4. **Coder does NOT get**: Full spec, other WPs, plan, workflow intelligence -5. This is enforced at prompt generation time - the builder physically doesn't read excluded files. - -**Files**: `crates/kasmos/src/prompt.rs` -**Validation**: Coder prompt does not contain spec content. Reviewer prompt does not contain other WP content. - -### Subtask T068 - Add prompt snapshot tests and boundary validation - -**Purpose**: Test that each role's prompt contains required context and excludes forbidden context. - -**Steps**: -1. Snapshot test for each role using a test feature directory: - - Manager prompt contains: spec summary, plan summary, all WP statuses, architecture - - Coder prompt contains: specific WP task file content, constitution reference - - Coder prompt does NOT contain: spec content, plan content, other WP content - - Reviewer prompt contains: WP task file, acceptance criteria, constitution - - Release prompt contains: all WP statuses, branch info, constitution -2. Boundary validation tests: - - Verify `allowed_context` returns correct boundaries for each role - - Verify prompt builder respects boundaries (doesn't read excluded files) -3. Use insta or similar for snapshot testing, or simple string assertion tests. - -**Files**: Test modules in `crates/kasmos/src/prompt.rs` -**Validation**: `cargo test` passes with prompt boundary tests. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Prompts leak unnecessary context to coder role | Explicit allowlists and snapshot assertions verify exclusion | -| Manager prompt too vague for deterministic orchestration | Codify explicit stage gates and tool usage loops in template | -| Profile assets out of sync with code | Generate from templates, test that they match expected structure | - -## Review Guidance - -- Verify manager gets broadest context (FR-028) -- Verify coder gets narrowest context (FR-029) - NO spec, plan, or other WPs -- Verify reviewer gets medium context (FR-030) - task file + changes + criteria -- Verify release gets structural context (FR-031) -- Verify all roles use OpenCode (FR-025) -- Verify communication protocol is in all worker prompts -- Verify snapshot tests catch boundary violations - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-14T22:25:37Z – unknown – shell_pid=3674747 – lane=planned – Moved to planned -- 2026-02-14T22:27:10Z – unknown – shell_pid=3674747 – lane=planned – Moved to planned -- 2026-02-15T00:51:10Z – unknown – shell_pid=3114343 – lane=for_review – Ready for review -- 2026-02-15T00:56:10Z – unknown – shell_pid=3114343 – lane=done – Review passed: role-based prompt builder, context boundaries, 5 agent templates, 241 tests pass diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP12-integration-and-acceptance-hardening.md b/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP12-integration-and-acceptance-hardening.md deleted file mode 100644 index 2d36d5b..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/tasks/WP12-integration-and-acceptance-hardening.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -work_package_id: WP12 -title: Integration, Legacy Preservation, and Acceptance Hardening -lane: "done" -dependencies: [WP07, WP08, WP09, WP10, WP11] -base_branch: 011-mcp-agent-swarm-orchestration-WP07 -base_commit: ad49303038f379286b49bd243b78966248018c92 -created_at: '2026-02-15T06:55:22.669223+00:00' -subtasks: -- T069 -- T070 -- T071 -- T072 -- T073 -- T074 -phase: Phase 3 - Setup UX, Role Context, and End-to-End Hardening -assignee: 'opencode' -agent: "reviewer" -shell_pid: "1148797" -review_status: "approved" -reviewed_by: "reviewer" -history: -- timestamp: '2026-02-14T16:27:48Z' - lane: planned - agent: system - shell_pid: '' - action: Prompt generated via /spec-kitty.tasks ---- - -# Work Package Prompt: WP12 - Integration, Legacy Preservation, and Acceptance Hardening - -## Important: Review Feedback Status - -- **Has review feedback?**: Check the `review_status` field above. - ---- - -## Review Feedback - -**Reviewed by**: reviewer -**Status**: ❌ Changes Requested -**Date**: 2026-02-15 - -DECISION: NEEDS_CHANGES -TIER_REACHED: 3 -SEVERITY_SUMMARY: Critical=0, High=0, Medium=0, Low=4 - -SCOPE: -- spec: 011-mcp-agent-swarm-orchestration -- wp: WP12 - -SK_WORKFLOW_CHECKS: -- dependency_check: PASS (WP07, WP08, WP09, WP10, WP11 are all merged and in done lane) -- integration_seam: PASS (Rust module/public API seams compile and resolve correctly) - -FINDINGS: -- [Low] crates/kasmos/src/serve/audit.rs:149 - Archive file names use second-level timestamps only; two rotations in one second can collide and overwrite. -- [Low] crates/kasmos/src/launch/mod.rs:134 - Lock release failure after successful bootstrap returns an error even when launch already succeeded, which can mislead operators. -- [Low] crates/kasmos/src/feature_arg.rs:4 - `resolve_feature_dir` logic is duplicated across `feature_arg`, `serve/tools/mod.rs`, and `serve/audit.rs`, increasing drift risk. -- [Low] crates/kasmos/src/config.rs:100 - `audit.metadata_only` is configurable but not enforced in runtime behavior; `debug_full_payload` alone controls payload capture. - -REALITY_GAPS: -- None. Claimed WP12 outcomes match implementation and verification evidence. - -SIMPLIFICATION_SUGGESTIONS: -- Consolidate feature-dir resolution into one shared utility with explicit modes. -- Add sub-second or random suffix to rotated audit filenames. -- Either wire `metadata_only` into behavior or remove/deprecate it to avoid operator confusion. - -NEXT_ACTION: -- Move WP12 back to planned and address the low-severity findings. - -AUTOMATION: -- feedback_file: .kas/review-WP12.feedback.md and /tmp/spec-kitty-review-feedback-WP12.md -- lane_update: pending - - -## Implementation Command - -```bash -spec-kitty implement WP12 --base WP07 -``` - ---- - -## Objectives & Success Criteria - -Validate end-to-end behavior against locked decisions and success criteria, confirm legacy TUI preservation, and finalize documentation. After this WP: - -1. `cargo build` (default) succeeds with new MCP launcher flow -2. `cargo build --features tui` succeeds with legacy TUI code intact (FR-024) -3. Lock conflict and stale takeover flow work end-to-end -4. Audit logging modes and retention thresholds trigger correctly -5. Feature selector pre-launch gate works correctly -6. README and quickstart docs reflect final command behavior -7. FR/SC traceability checklist is complete - -## Context & Constraints - -- **Depends on WP07, WP08, WP09, WP10, WP11**: All core subsystems complete -- **Locked decisions 1-9**: Engineering alignment from plan.md must be validated -- **Success criteria SC-001 through SC-010**: Measurable outcomes from spec.md -- **FR-024**: Legacy TUI code compiles and passes tests after disconnection - -## Subtasks & Detailed Guidance - -### Subtask T069 - Verify and preserve legacy TUI compile path - -**Purpose**: Confirm that the TUI feature gate works and legacy code still compiles (FR-024, SC-008). - -**Steps**: -1. Run `cargo build --features tui` and verify zero errors -2. Run `cargo test --features tui` and verify all existing TUI tests pass -3. Verify these TUI modules still compile when feature-gated: - - `crates/kasmos/src/hub/` (hub TUI) - - `crates/kasmos/src/tui/` (orchestrator TUI) - - `crates/kasmos/src/report.rs` - - `crates/kasmos/src/tui_cmd.rs` - - `crates/kasmos/src/tui_preview.rs` -4. Run `cargo build` (default, no TUI) and verify it produces a working binary -5. Verify `kasmos --help` with default build shows new command surface -6. Document the feature gate in README: "Legacy TUI available via `cargo build --features tui`" - -**Files**: Various - verification only, minimal changes -**Validation**: Both build configs succeed. TUI tests pass with `--features tui`. - -### Subtask T070 - Add integration scenario for lock conflict and stale takeover - -**Purpose**: End-to-end test of the lock system under realistic conditions. - -**Steps**: -1. Scenario: Lock conflict - - Process A acquires lock for feature 011 - - Process B attempts to bind to feature 011 - - Verify B receives `FEATURE_LOCK_CONFLICT` with A's owner details - - Process A releases lock - - Process B retries and succeeds -2. Scenario: Stale takeover - - Create a lock file with old heartbeat (> 15 minutes ago) - - Attempt to bind to the feature - - Verify `STALE_LOCK_CONFIRMATION_REQUIRED` is returned - - Provide confirmation token - - Verify takeover succeeds and new lock is written -3. Use temp directories for isolation. Mock clock if needed for stale timeout testing. -4. These can be integration tests in `crates/kasmos/src/serve/lock.rs` or separate test files. - -**Parallel?**: Yes - independent of T071 and T072. -**Files**: Integration test module -**Validation**: Both scenarios pass deterministically. - -### Subtask T071 - Add integration scenario for audit logging modes and retention - -**Purpose**: End-to-end test of audit system behavior under both modes and retention triggers. - -**Steps**: -1. Scenario: Default metadata-only mode - - Trigger several audit events (spawn, transition, despawn) - - Read `messages.jsonl` and verify entries have metadata but no `debug_payload` -2. Scenario: Debug full payload mode - - Enable debug mode in config - - Trigger audit events - - Verify entries include `debug_payload` field -3. Scenario: Size-based retention trigger - - Create an audit file exceeding 512MB (or use a smaller test threshold) - - Trigger retention check - - Verify rotation occurs (file renamed, new file started) -4. Scenario: Age-based retention trigger - - Create entries with old timestamps (> 14 days) - - Trigger retention check - - Verify rotation occurs - -**Parallel?**: Yes - independent of T070 and T072. -**Files**: Integration test module -**Validation**: All audit scenarios pass with correct behavior. - -### Subtask T072 - Add integration scenario for feature selector pre-launch gate - -**Purpose**: Verify the selector runs before Zellij and the no-specs path exits cleanly. - -**Steps**: -1. Scenario: Selector gate - - Set up environment with no inferable feature (main branch, no spec prefix) - - Verify the selector is presented - - Verify NO Zellij commands have been executed before selection -2. Scenario: No specs available - - Remove all spec directories from `kitty-specs/` - - Run `kasmos` - - Verify clean exit message and exit code 0 - - Verify no Zellij session/tab was created -3. These tests may need to mock Zellij commands to verify they weren't called. - -**Parallel?**: Yes - independent of T070 and T071. -**Files**: Integration test module -**Validation**: Selector gate and no-specs path work correctly. - -### Subtask T073 - Align README, quickstart, and docs with final behavior - -**Purpose**: Update documentation to reflect the final command behavior and architecture. - -**Steps**: -1. Update `README.md`: - - Remove references to old TUI-first workflow - - Document new commands: `kasmos [spec-prefix]`, `kasmos serve`, `kasmos setup`, `kasmos list`, `kasmos status` - - Document the MCP agent swarm architecture - - Document the feature gate: `cargo build --features tui` for legacy TUI -2. Update `kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md`: - - Verify all scenarios match actual command behavior - - Update any outdated examples -3. Verify `kasmos --help` output matches documentation. -4. Do NOT update docs until final command behavior is stable (this runs last). - -**Files**: `README.md`, `kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md` -**Validation**: README matches actual command behavior. quickstart scenarios work. - -### Subtask T074 - Run final verification matrix and FR/SC traceability - -**Purpose**: Comprehensive verification that all functional requirements and success criteria are met. - -**Steps**: -1. Run `cargo test` for default features - all pass -2. Run `cargo test --features tui` - all pass -3. Run `cargo build` - succeeds -4. Manual smoke checks (document results): - - `kasmos setup` reports all checks - - `kasmos 011` launches session (if Zellij available) - - `kasmos list` shows features - - `kasmos status` shows WP progress - - `kasmos serve` starts and responds to tools/list -5. FR traceability checklist - map each FR to the WP that implements it: - - FR-001: WP03 (session launch) - - FR-002: WP03 (tab creation inside Zellij) - - FR-003: WP02 (spec prefix arg) - - FR-004: WP02 (branch inference) - - FR-005: WP02 (CLI selector) - - FR-006: WP04 (kasmos serve) - - FR-007: WP03 (swap layouts) - - FR-008 to FR-018: WP07-WP09 (manager orchestration) - - FR-019: WP08 (worker messages) - - FR-020: WP05 (feature locking) - - FR-021: WP02/WP10 (preflight) - - FR-022: WP10 (setup command) - - FR-023: WP09 (rejection cap) - - FR-024: WP01 (TUI preservation) - - FR-025: WP11 (single agent runtime) - - FR-026: WP08 (manager decision logging) - - FR-027: WP06 (audit persistence) - - FR-028-031: WP11 (role context boundaries) -6. SC traceability - verify each success criterion has a corresponding test or validation. - -**Files**: New traceability checklist file (if needed), test results -**Validation**: All FRs mapped to implementations. All SCs have validation evidence. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Flaky integration tests around terminal tooling | Split deterministic unit-level checks from optional environment-dependent smoke tests | -| Docs drifting from implementation | Update docs only after final command/help output is stable (this WP runs last) | -| TUI feature gate bit rot | Include `--features tui` in CI matrix to catch regressions early | - -## Review Guidance - -- Verify both build configs succeed (default and tui) -- Verify lock scenario tests are deterministic -- Verify audit scenario tests cover both modes and both retention triggers -- Verify selector gate test proves no Zellij commands before selection -- Verify README accurately describes new command surface -- Verify FR/SC traceability checklist is complete -- Verify all locked decisions (1-9 from plan.md) are validated - -## Activity Log - -- 2026-02-14T16:27:48Z - system - lane=planned - Prompt generated via /spec-kitty.tasks -- 2026-02-15T07:19:49Z – unknown – shell_pid=1417265 – lane=for_review – Ready for review -- 2026-02-15T07:26:32Z – reviewer – shell_pid=1544861 – lane=doing – Started review via workflow command -- 2026-02-15T08:14:59Z – reviewer – shell_pid=1544861 – lane=planned – Moved to planned -- 2026-02-16T02:14:16Z – reviewer – shell_pid=3974381 – lane=for_review – Moved to for_review -- 2026-02-16T02:14:20Z – reviewer – shell_pid=1148797 – lane=doing – Started review via workflow command -- 2026-02-16T02:16:25Z – reviewer – shell_pid=1148797 – lane=done – Review passed: resolved low findings and verified full matrix diff --git a/kitty-specs/011-mcp-agent-swarm-orchestration/traceability.md b/kitty-specs/011-mcp-agent-swarm-orchestration/traceability.md deleted file mode 100644 index 2587172..0000000 --- a/kitty-specs/011-mcp-agent-swarm-orchestration/traceability.md +++ /dev/null @@ -1,50 +0,0 @@ -# WP12 Traceability Checklist - -## Functional Requirements -> Implementing Work Package(s) - -- FR-001: WP03 -- FR-002: WP03 -- FR-003: WP02 -- FR-004: WP02 -- FR-005: WP02, WP10 -- FR-006: WP04 -- FR-007: WP03 -- FR-008: WP07, WP09 -- FR-009: WP07, WP09 -- FR-010: WP07, WP11 -- FR-011: WP08 -- FR-012: WP09 -- FR-013: WP09 -- FR-014: WP07, WP09 -- FR-015: WP09 -- FR-016: WP09 -- FR-017: WP07, WP09 -- FR-018: WP08, WP09 -- FR-019: WP08 -- FR-020: WP05 -- FR-021: WP02, WP10 -- FR-022: WP10 -- FR-023: WP09 -- FR-024: WP01 -- FR-025: WP11 -- FR-026: WP08 -- FR-027: WP06 -- FR-028: WP11 -- FR-029: WP11 -- FR-030: WP11 -- FR-031: WP11 -- FR-032: WP08 -- FR-033: WP11 - -## Success Criteria -> Evidence - -- SC-001: Launch path validated by `launch::layout::tests::full_layout_contains_required_panes`, `launch::session::tests::writes_temp_layout_file`, selector/no-spec gate coverage (`launch::tests::selection_gate_triggers_when_detection_none`, `launch::tests::selector_runs_before_preflight_failures`, `launch::tests::no_specs_path_exits_before_preflight_checks`), plus manual smoke run of `kasmos 011`. -- SC-002: Event detection path validated by `serve::tools::wait_for_event::*` tests and message parsing tests in `serve::messages::*`. -- SC-003: Planning phase transition surfaces validated by workflow status and transition tests (`serve::tools::workflow_status::*`, `serve::tools::transition_wp::*`) and manager confirmation flow design from WP09. -- SC-004: Code -> review -> approval orchestration validated by review coordinator and state transition tests (`review_coordinator::*`, `state_machine::*`). -- SC-005: Multi-worker and layout behavior validated by launch/layout tests (`launch::layout::tests::swap_layouts_cover_two_through_max_plus_three`, `layout::tests::test_generate_eight_panes`) and worker registry tests. -- SC-006: Stage-gate pausing behavior validated by `engine::tests::test_wave_gated_pause` and review policy tests. -- SC-007: Error detection/reporting behavior validated by lock, wait, and review rejection handling tests (`serve::lock::*`, `serve::tools::wait_for_event::*`, `review_coordinator::*`). -- SC-008: Legacy TUI compile path validated by `cargo build --features tui` and `cargo test --features tui` (324 tests passing). -- SC-009: Setup command behavior validated by `setup::tests::setup_passes_when_dependencies_are_present`, `setup::tests::setup_fails_when_dependency_is_missing`, and `setup::tests::launch_preflight_uses_setup_validation_engine`. -- SC-010: End-to-end lifecycle coverage validated by FR-to-WP mapping above and passing command/toolchain matrix (`cargo build`, `cargo test`, `cargo build --features tui`, `cargo test --features tui`). diff --git a/kitty-specs/012-kasmos-new-command/checklists/requirements.md b/kitty-specs/012-kasmos-new-command/checklists/requirements.md deleted file mode 100644 index 87e5fc0..0000000 --- a/kitty-specs/012-kasmos-new-command/checklists/requirements.md +++ /dev/null @@ -1,36 +0,0 @@ -# Specification Quality Checklist: Kasmos New Command - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-02-16 -**Feature**: `kitty-specs/012-kasmos-new-command/spec.md` - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- All items pass. Spec is ready for `/spec-kitty.clarify` or `/spec-kitty.plan`. -- FR-004 references `AgentRole::Planner` context boundaries by name -- this is a reference to existing project vocabulary (defined in spec 011), not an implementation detail. -- The spec intentionally omits Zellij concerns (FR-011, FR-013) since the core design decision is to run opencode directly in the terminal. diff --git a/kitty-specs/012-kasmos-new-command/data-model.md b/kitty-specs/012-kasmos-new-command/data-model.md deleted file mode 100644 index 4cc0b47..0000000 --- a/kitty-specs/012-kasmos-new-command/data-model.md +++ /dev/null @@ -1,37 +0,0 @@ -# Data Model: Kasmos New Command - -## Overview - -This feature introduces no new persistent entities or state. The data model is limited to transient runtime structures used during a single `kasmos new` invocation. - -## Structure: NewCommandConfig - -- Purpose: Holds resolved configuration for a `kasmos new` invocation. -- Lifetime: Created at command start, consumed to build the opencode command, discarded on exit. -- Fields: - - `opencode_binary: String` -- resolved path to the opencode launcher (from `Config.agent.opencode_binary`) - - `opencode_profile: Option` -- optional profile name (from `Config.agent.opencode_profile`) - - `spec_kitty_binary: String` -- resolved path to spec-kitty (from `Config.paths.spec_kitty_binary`, used for pre-flight only) - - `description: Option` -- user-provided initial feature description, joined from CLI args - - `repo_root: PathBuf` -- project root, used to locate `.kittify/memory/` and `kitty-specs/` - -## Structure: PlanningPrompt - -- Purpose: The fully rendered prompt string passed to opencode via `--prompt`. -- Lifetime: Built once from project context files, consumed by the opencode command. -- Sections (in order): - 1. Role instruction (fixed text: planning agent, invoke `/spec-kitty.specify`) - 2. User description (optional, only if provided) - 3. Constitution summary (from `.kittify/memory/constitution.md`, if present) - 4. Architecture summary (from `.kittify/memory/architecture.md`, if present) - 5. Workflow intelligence summary (from `.kittify/memory/workflow-intelligence.md`, if present) - 6. Existing specs list (directory names under `kitty-specs/`) - 7. Project structure (top-level directory listing) -- All sections degrade gracefully: if a source file is missing, the section is omitted (no error). - -## No Persistent State - -- No lock files created (FR-012) -- No audit logs written -- No feature directories created by kasmos (spec-kitty handles this during the agent session) -- No Zellij state tracked diff --git a/kitty-specs/012-kasmos-new-command/meta.json b/kitty-specs/012-kasmos-new-command/meta.json deleted file mode 100644 index 837c403..0000000 --- a/kitty-specs/012-kasmos-new-command/meta.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "feature_number": "012", - "slug": "012-kasmos-new-command", - "friendly_name": "Kasmos New Command", - "mission": "software-dev", - "source_description": "i want to make a new 'kasmos new' that opens up an opencode session with kasmos' planning agent calling /spec-kitty.specify", - "created_at": "2026-02-16T12:00:00Z", - "target_branch": "main", - "vcs": "git" -} diff --git a/kitty-specs/012-kasmos-new-command/plan.md b/kitty-specs/012-kasmos-new-command/plan.md deleted file mode 100644 index 4bf7c87..0000000 --- a/kitty-specs/012-kasmos-new-command/plan.md +++ /dev/null @@ -1,206 +0,0 @@ -# Implementation Plan: Kasmos New Command - -**Branch**: `012-kasmos-new-command` | **Date**: 2026-02-16 | **Spec**: `kitty-specs/012-kasmos-new-command/spec.md` -**Input**: Feature specification from `/home/kas/dev/kasmos/kitty-specs/012-kasmos-new-command/spec.md` - -## Summary - -Add a `kasmos new` CLI subcommand that launches opencode directly in the current terminal as a planning agent configured to run `/spec-kitty.specify`. The command loads project context (constitution, architecture memory, workflow intelligence, existing specs) into a purpose-built prompt, validates that opencode and spec-kitty are available, then spawns opencode as a child process and waits for it to exit. No Zellij sessions, feature locks, or complex layouts are involved. - -## Technical Context - -**Language/Version**: Rust 2024 edition (latest stable) -**Primary Dependencies**: `clap` (CLI parsing), `which` (binary validation), `anyhow` (error handling), `std::process::Command` (process spawning) -- all already in `Cargo.toml` -**Storage**: N/A (reads `.kittify/memory/` files; spec-kitty handles spec creation) -**Testing**: `cargo test` -- unit tests for pre-flight validation and prompt construction -**Target Platform**: Linux primary, macOS best-effort -**Project Type**: Single Rust binary (`crates/kasmos/`) -**Performance Goals**: Launch to interactive session in under 3 seconds (SC-001) -**Constraints**: -- No Zellij dependency (FR-011) -- No feature locks (FR-012) -- No new runtime dependencies (SC-005) -- Must propagate opencode's exit code (FR-010) -**Scale/Scope**: Small feature -- adds ~150 lines of new code across 2-3 files, modifies 3 existing files minimally - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -| Principle | Status | Notes | -|-----------|--------|-------| -| Rust 2024 edition | PASS | New code follows Rust 2024 conventions | -| tokio async runtime | PASS | Command handler uses sync `Command::status()` but is called from async `main()`; no conflict | -| Zellij substrate | N/A | This command intentionally bypasses Zellij (FR-011) | -| OpenCode primary agent | PASS | Launches opencode with planner agent profile | -| cargo test required | PASS | Unit tests for pre-flight and prompt construction | -| Linux primary, macOS best-effort | PASS | `std::process::Command` is cross-platform | -| Single binary distribution | PASS | New subcommand in existing binary | - -No constitution violations. No complexity exceptions needed. - -## Engineering Alignment - -Planning interrogation decisions accepted by stakeholder: - -1. Process execution strategy: spawn-and-wait via `std::process::Command::status()` (R-001) -2. Prompt construction: purpose-built function in `new.rs`, not `RolePromptBuilder` (R-002) -3. Agent profile: `--agent planner` with custom `--prompt` content (R-003) -4. Pre-flight scope: only opencode + spec-kitty, dedicated check function (R-004) -5. Helper reuse: make `read_file_if_exists` and `summarize_markdown` `pub(crate)` (R-005) - -No planning clarifications remain unresolved. - -## Project Structure - -### Documentation (this feature) - -``` -kitty-specs/012-kasmos-new-command/ - plan.md # This file - research.md # Phase 0 output (R-001 through R-005) - data-model.md # Phase 1 output (minimal for this feature) - quickstart.md # Phase 1 output - spec.md # Feature specification - meta.json # Feature metadata - checklists/ - requirements.md # Spec quality checklist -``` - -### Source Code (repository root) - -``` -crates/kasmos/src/ - main.rs # MODIFY: add Commands::New variant + dispatch - lib.rs # MODIFY: add `pub mod new;` - new.rs # NEW: command handler, pre-flight, prompt builder - prompt.rs # MODIFY: make read_file_if_exists + summarize_markdown pub(crate) -``` - -**Structure Decision**: Single new module `new.rs` following the pattern of other subcommand handlers (`list_specs.rs`, `status.rs`). These are thin modules that load config, perform validation, and delegate to library functions. - -## Architecture Decisions - -### AD-001: New Module Instead of Extending Launch Flow - -**Decision**: Create `crates/kasmos/src/new.rs` as a standalone module rather than adding a code path to `launch/mod.rs`. - -**Rationale**: The launch flow (`launch/mod.rs`) is built around a fundamentally different lifecycle: feature detection -> lock acquisition -> layout generation -> session bootstrap. None of these steps apply to `kasmos new`. Sharing code between the two flows would require threading conditionals through a pipeline that doesn't naturally fit the `new` use case. - -**Alternatives rejected**: Adding a `--no-zellij` flag to the launch flow -- introduces coupling between two unrelated execution models. - -### AD-002: Purpose-Built Prompt Instead of RolePromptBuilder - -**Decision**: Build the planning agent prompt directly in `new.rs` using a `build_new_prompt()` function that reads `.kittify/memory/` files and injects the `/spec-kitty.specify` instruction. - -**Rationale**: `RolePromptBuilder` assumes a feature directory exists (required constructor parameter), uses it for repo root discovery, and populates feature-specific context (spec, plan, tasks). For `kasmos new`, none of this exists. Working around the builder (passing dummy paths) is fragile and semantically wrong. - -The prompt structure for `kasmos new` is: - -``` -# kasmos planning agent - -Your task is to create a new feature specification for this project. -Run `/spec-kitty.specify` to begin the interactive specification workflow. - -[If description provided]: -The user has provided this initial feature description: -> - -Pass this to /spec-kitty.specify as the starting feature description. - -## Project Context - -### Constitution - - -### Architecture - - -### Workflow Intelligence - - -### Existing Specs - - -### Project Structure - -``` - -**Alternatives rejected**: Making `RolePromptBuilder` fields optional -- invasive change to a working system for one consumer. - -### AD-003: Lightweight Pre-flight Instead of Full Validation - -**Decision**: `new.rs` implements its own pre-flight function that checks only opencode and spec-kitty binaries via `which::which()`. - -**Rationale**: The existing `validate_environment()` (`setup/mod.rs:71-106`) checks 6 dependencies including Zellij, pane-tracker, git repo, and config file. Running all 6 checks would either: (a) fail unnecessarily on missing Zellij, or (b) require filtering logic to ignore irrelevant failures. A 2-check function is simpler and faster. - -**Alternatives rejected**: Reusing `validate_environment()` with result filtering -- still runs unnecessary filesystem checks for pane-tracker plugin detection. - -### AD-004: Synchronous Process Spawning - -**Decision**: Use `std::process::Command::status()` (blocking) wrapped in `tokio::task::spawn_blocking()` since `main()` is async. - -**Rationale**: `Command::status()` is the simplest way to spawn a child process and wait for it. Since `kasmos new` does nothing else after spawning opencode (no concurrent tasks, no polling), blocking is fine. The `spawn_blocking` wrapper satisfies tokio's requirement that blocking calls don't starve the runtime, though in practice the runtime is idle. - -**Alternatives rejected**: `tokio::process::Command` -- adds async complexity for no benefit since there's nothing to do concurrently. - -### AD-005: CLI Argument Structure - -**Decision**: `Commands::New` takes an optional `description` field using `Vec` with `trailing_var_arg = true` so both `kasmos new "add dark mode"` and `kasmos new add dark mode` work. - -```rust -/// Create a new feature specification -New { - /// Initial feature description (optional) - #[arg(trailing_var_arg = true)] - description: Vec, -} -``` - -The description words are joined with spaces before being injected into the prompt. - -**Rationale**: Reduces friction -- the user doesn't need to remember to quote their description. Both styles work naturally. - -**Alternatives rejected**: `Option` -- requires quoting for multi-word descriptions, which users commonly forget. - -## File Change Summary - -| File | Change | Lines | -|------|--------|-------| -| `crates/kasmos/src/new.rs` | NEW: command handler + pre-flight + prompt builder + tests | ~150 | -| `crates/kasmos/src/main.rs` | MODIFY: add `Commands::New` variant + dispatch arm | ~10 | -| `crates/kasmos/src/lib.rs` | MODIFY: add `pub mod new;` | 1 | -| `crates/kasmos/src/prompt.rs` | MODIFY: make 2 helper functions `pub(crate)` | 2 | - -**Total estimated new/changed lines**: ~165 - -## Testing Strategy - -### Unit Tests (in `new.rs`) - -1. **Pre-flight detects missing opencode**: Configure a fake binary name, verify the check returns an error with actionable guidance. -2. **Pre-flight detects missing spec-kitty**: Same pattern for spec-kitty. -3. **Pre-flight passes with real binaries**: Use known-present binaries (e.g., `bash`) as stand-ins. -4. **Prompt includes /spec-kitty.specify instruction**: Build a prompt with a tempdir repo root, verify the instruction text is present. -5. **Prompt includes user description when provided**: Build with a description string, verify it appears in the output. -6. **Prompt omits description section when not provided**: Build without description, verify no description section. -7. **Prompt loads project context from .kittify/memory/**: Create fixture files, verify constitution/architecture/workflow sections appear. -8. **Prompt handles missing .kittify/memory/ gracefully**: Build with no memory files, verify no errors and context sections are absent. - -### Integration Test Considerations - -- End-to-end `kasmos new` requires an interactive opencode session, which is not automatable in `cargo test`. Integration testing is manual: run `kasmos new` and verify the planning agent activates `/spec-kitty.specify`. -- The `Commands::New` clap parsing can be tested via `Cli::try_parse_from()`. - -## Dependency Impact - -No new crate dependencies. All required functionality (`which`, `clap`, `anyhow`, `std::process`, `std::fs`) is already in `Cargo.toml`. - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Prompt too large for opencode's `--prompt` arg | Low | Medium | Shell argument limits are typically 2MB+; project context is ~5KB summarized | -| Planner agent doesn't auto-invoke /spec-kitty.specify | Low | High | Prompt explicitly instructs it; test manually before merging | -| Description with special characters breaks shell escaping | Medium | Low | Use `shell-escape` crate (already a dependency) for the prompt argument | diff --git a/kitty-specs/012-kasmos-new-command/quickstart.md b/kitty-specs/012-kasmos-new-command/quickstart.md deleted file mode 100644 index 091570b..0000000 --- a/kitty-specs/012-kasmos-new-command/quickstart.md +++ /dev/null @@ -1,59 +0,0 @@ -# Quickstart: Kasmos New Command - -## What Success Looks Like - -### Basic usage (no description) - -```bash -$ kasmos new -# OpenCode launches in the current terminal as a planning agent. -# The agent automatically runs /spec-kitty.specify. -# The agent asks the user to describe their feature idea. -# After interactive discovery, a new spec is created: -# kitty-specs/013-my-feature/spec.md -# When the user exits opencode, the terminal returns to the shell. -$ echo $? -0 -``` - -### With an initial description - -```bash -$ kasmos new add webhook support for external integrations -# OpenCode launches with the description pre-loaded. -# The agent passes "add webhook support for external integrations" -# as the starting point for /spec-kitty.specify discovery. -# Discovery may still ask follow-up questions. -``` - -### Missing dependency - -```bash -$ kasmos new -Error: opencode not found in PATH - Needed for: launching the planning agent session - Fix: Install OpenCode and ensure its launcher binary is on PATH -$ echo $? -1 -``` - -### End-to-end workflow - -```bash -# 1. Create a new spec -$ kasmos new - -# 2. (After spec creation completes, launch orchestration) -$ kasmos 013 -``` - -## Verification Checklist - -1. `kasmos new` launches opencode in-place (no new terminal, no Zellij tab) -2. The planning agent's first action is to run `/spec-kitty.specify` -3. The agent has project context (check for constitution/architecture references in its output) -4. `kasmos new "some description"` passes the description to the agent -5. After exiting opencode, the shell prompt returns in the same terminal -6. `kasmos new` with opencode missing prints an error and exits with code 1 -7. `kasmos new` with spec-kitty missing prints an error and exits with code 1 -8. Running `kasmos new` inside a Zellij pane works identically to running outside diff --git a/kitty-specs/012-kasmos-new-command/research.md b/kitty-specs/012-kasmos-new-command/research.md deleted file mode 100644 index 11493b0..0000000 --- a/kitty-specs/012-kasmos-new-command/research.md +++ /dev/null @@ -1,73 +0,0 @@ -# Research: Kasmos New Command - -**Feature**: 012-kasmos-new-command -**Date**: 2026-02-16 - -## R-001: Process Execution Strategy for Launching OpenCode - -**Question**: Should `kasmos new` use `exec()` (process replacement) or spawn-and-wait to launch opencode? - -**Findings**: -- `std::process::Command::status()` spawns a child process, waits for it, and returns the exit status. It is cross-platform (Linux + macOS) and handles signal forwarding naturally (Ctrl+C reaches the child since it inherits the terminal's foreground process group). -- `std::os::unix::process::CommandExt::exec()` replaces the current process image entirely. Efficient (no parent lingers) but Unix-only, and requires the tokio runtime to be shut down cleanly before calling exec (which is awkward). -- `Command::status()` is the standard pattern used by CLI wrapper tools (e.g., `cargo` launching `rustc`, `npm` launching node scripts). - -**Decision**: Use `std::process::Command::status()` (spawn-and-wait). -**Rationale**: Cross-platform within supported targets, simpler signal/exit handling, no tokio shutdown concerns. -**Alternatives rejected**: `exec()` -- unnecessary complexity for negligible performance gain. The parent process exits immediately after the child anyway. - -## R-002: Prompt Construction Without a Feature Directory - -**Question**: The existing `RolePromptBuilder` requires `feature_slug` and `feature_dir` (both mandatory). For `kasmos new`, no feature exists yet. How should the prompt be built? - -**Findings**: -- `RolePromptBuilder::new()` takes `feature_slug: impl Into` and `feature_dir: impl Into` as required parameters (`crates/kasmos/src/prompt.rs:128-141`). -- The builder uses `feature_dir` to: locate `spec.md`/`plan.md` (lines 182-204), resolve WP task files (lines 297-341), find the repo root by walking ancestors (lines 389-401). -- The Planner context boundary (`prompt.rs:104-114`) enables `spec: true, plan: true` -- but `read_file_if_exists()` gracefully returns `None` when files are missing, so no errors would occur. -- However, `find_repo_root()` walks up from `feature_dir` looking for `Cargo.toml` or `.kittify/`. If we pass a fake feature_dir, this could fail. -- The template `config/profiles/kasmos/agent/planner.md` has `{{FEATURE_SLUG}}` placeholder, which would render as something meaningless for a new feature. - -**Decision**: Build the prompt directly in `new.rs` without `RolePromptBuilder`. Reuse the helper functions `read_file_if_exists` and `summarize_markdown` (made `pub(crate)`). -**Rationale**: The builder's assumptions (feature exists, feature_dir is a real path) don't hold for `kasmos new`. A purpose-built prompt function is cleaner than working around the builder's constraints. -**Alternatives rejected**: (1) Pass dummy values to RolePromptBuilder -- fragile, repo root discovery could fail. (2) Make all RolePromptBuilder fields optional -- invasive change for a single use case. - -## R-003: Opencode `--agent` Flag Semantics - -**Question**: What `--agent` value should `kasmos new` pass to opencode, and what does it control? - -**Findings**: -- The existing `ManagerCommand::to_kdl_pane()` (`launch/layout.rs:122-141`) passes `--agent manager` alongside `--prompt ""`. -- The `--agent` flag selects an agent profile within opencode's configuration, which may affect model selection, available tools, and system prompt behavior. -- The `--prompt` flag provides the full initial prompt/context injected into the session. -- For `kasmos new`, the planning agent runs `/spec-kitty.specify`, which is a controller-tier task. However, kasmos only defines agent profiles for: manager, planner, coder, reviewer, release. - -**Decision**: Use `--agent planner` as the agent profile selector. The `--prompt` content will override the planner template's feature-specific parts with the `/spec-kitty.specify` instruction. -**Rationale**: `planner` is the closest existing profile to the spec-creation use case. The prompt content (not the agent flag) is what drives behavior. -**Alternatives rejected**: Creating a new `controller` agent profile -- adds configuration complexity for no functional benefit. The planner profile's model and tool access are appropriate for specification work. - -## R-004: Pre-flight Check Scope for `kasmos new` - -**Question**: The existing `validate_environment()` in `setup/mod.rs` checks 6 dependencies (zellij, opencode, spec-kitty, pane-tracker, git, config). Which checks apply to `kasmos new`? - -**Findings**: -- `kasmos new` only needs: opencode binary (to launch the session) and spec-kitty binary (used by the planning agent during the session). -- Zellij, pane-tracker, git repo presence, and config file presence are NOT required since `kasmos new` doesn't create sessions/tabs/panes and operates without feature locks. -- The existing `check_binary()` function (`setup/mod.rs:108-124`) is private but can be replicated trivially (it's a `which::which()` call with formatted output). - -**Decision**: Implement a dedicated lightweight pre-flight in `new.rs` that only checks opencode and spec-kitty via `which::which()`. Do not reuse `validate_environment()`. -**Rationale**: Avoids false-negative failures (e.g., failing because zellij is missing when it's not needed). Keeps the pre-flight fast and focused. -**Alternatives rejected**: Filtering `validate_environment()` results -- still runs unnecessary checks, and the Zellij/pane-tracker checks involve filesystem probing that adds latency. - -## R-005: Visibility of Helper Functions in `prompt.rs` - -**Question**: The prompt context helpers (`read_file_if_exists`, `summarize_markdown`) are currently private in `prompt.rs`. Should `new.rs` reuse them? - -**Findings**: -- `read_file_if_exists` (`prompt.rs:470-476`) -- trivial 6-line function that checks file existence and reads to string. -- `summarize_markdown` (`prompt.rs:421-443`) -- 22-line function that extracts heading lines and first N content lines. -- Both are pure functions with no side effects. -- The `new.rs` prompt builder needs the same pattern: read optional files from `.kittify/memory/` and summarize their content. - -**Decision**: Make `read_file_if_exists` and `summarize_markdown` `pub(crate)` in `prompt.rs` so `new.rs` can import them. -**Rationale**: Avoids code duplication. These are stable utility functions unlikely to change. -**Alternatives rejected**: Copying the functions into `new.rs` -- duplicates logic and creates maintenance burden. diff --git a/kitty-specs/012-kasmos-new-command/spec.md b/kitty-specs/012-kasmos-new-command/spec.md deleted file mode 100644 index 00dbee5..0000000 --- a/kitty-specs/012-kasmos-new-command/spec.md +++ /dev/null @@ -1,96 +0,0 @@ -# Feature Specification: Kasmos New Command - -**Feature Branch**: `012-kasmos-new-command` -**Created**: 2026-02-16 -**Status**: Draft -**Input**: User description: "i want to make a new 'kasmos new' that opens up an opencode session with kasmos' planning agent calling /spec-kitty.specify" - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 - Create a New Feature Spec (Priority: P1) - -A developer wants to create a new feature specification for the kasmos project. They run `kasmos new` from their terminal. Kasmos validates that the opencode agent runtime and spec-kitty are available, constructs a planning agent prompt that includes project context (constitution, architecture memory, workflow intelligence, existing specs awareness), and launches opencode directly in the current terminal. The planning agent automatically invokes `/spec-kitty.specify` to begin the interactive discovery and specification workflow. The user interacts with the planning agent to describe their feature, answer discovery questions, and produce a complete `spec.md` in `kitty-specs/`. When the planning agent finishes (or the user exits opencode), control returns to the user's terminal. The resulting spec can then be launched with `kasmos [spec-prefix]`. - -**Why this priority**: This is the entire feature. Without the ability to launch the planning agent with the right context and command, nothing else exists. This is the foundational and only critical user journey. - -**Independent Test**: Run `kasmos new` from a terminal. Verify opencode launches in-place with the planning agent role. Verify the agent has project context loaded and initiates `/spec-kitty.specify`. Complete a spec creation flow. Verify a new `kitty-specs/###-feature/spec.md` is created. Exit and verify the terminal returns to the shell prompt. - -**Acceptance Scenarios**: - -1. **Given** a terminal with opencode and spec-kitty installed, **When** the user runs `kasmos new`, **Then** opencode launches in the current terminal configured as a planning agent and automatically begins the `/spec-kitty.specify` workflow. -2. **Given** the planning agent is active, **When** the user interacts with the discovery interview and completes the spec, **Then** a new feature directory and `spec.md` are created in `kitty-specs/`. -3. **Given** opencode finishes or the user exits, **When** the process ends, **Then** control returns to the user's shell in the same terminal. - ---- - -### User Story 2 - Pass an Initial Feature Description (Priority: P2) - -A developer already knows what they want to build and wants to skip ahead by providing an initial description. They run `kasmos new "add dark mode toggle to settings"`. The planning agent receives this description as seed input for `/spec-kitty.specify`, using it as the starting point for discovery rather than starting with a blank prompt. This reduces back-and-forth for users who have a clear idea of their feature. - -**Why this priority**: This is a convenience enhancement over the core P1 flow. It improves the experience for users with a clear feature idea but is not essential for the command to function. - -**Independent Test**: Run `kasmos new "add dark mode toggle"`. Verify the planning agent launches and begins `/spec-kitty.specify` with the provided description already captured as the initial feature input. - -**Acceptance Scenarios**: - -1. **Given** a terminal with dependencies installed, **When** the user runs `kasmos new "add dark mode toggle"`, **Then** the planning agent launches and uses "add dark mode toggle" as the seed description for the specification workflow. -2. **Given** an initial description is provided, **When** the planning agent starts, **Then** it treats the description as a starting point for discovery (not the final truth) and still conducts appropriate follow-up. - ---- - -### User Story 3 - Pre-flight Validation Catches Missing Dependencies (Priority: P2) - -A developer runs `kasmos new` but does not have spec-kitty or opencode installed. Before launching anything, kasmos detects the missing dependency, prints a clear error message with install guidance, and exits with a non-zero code. No partial state is created. - -**Why this priority**: Preventing cryptic runtime failures improves the developer experience, but the primary audience already has dependencies installed via `kasmos setup`. - -**Independent Test**: Temporarily rename the opencode binary. Run `kasmos new`. Verify an actionable error message is printed and the exit code is non-zero. Restore the binary and verify `kasmos new` works. - -**Acceptance Scenarios**: - -1. **Given** the opencode binary is missing from PATH, **When** the user runs `kasmos new`, **Then** the command prints an error identifying the missing dependency with install guidance and exits with a non-zero code. -2. **Given** spec-kitty is missing from PATH, **When** the user runs `kasmos new`, **Then** the command prints an error identifying spec-kitty as missing with install guidance and exits with a non-zero code. -3. **Given** all dependencies are present, **When** the user runs `kasmos new`, **Then** pre-flight passes silently and the planning agent launches. - ---- - -### Edge Cases - -- What happens when the user runs `kasmos new` from outside the project root (no `kitty-specs/` directory or `kasmos.toml`)? The command should detect the missing project context, print guidance to navigate to the project root or run `kasmos setup`, and exit. -- What happens when opencode crashes mid-session? The terminal returns to the shell prompt with a non-zero exit code from the opencode process. No cleanup is needed since no Zellij session or lock state was created. -- What happens when the user provides a very long initial description? The description should be passed through to the prompt without truncation. Opencode and the planning agent handle arbitrarily long prompt inputs. -- What happens when `kasmos new` is run inside a Zellij session? It should still work identically -- opencode runs in the current pane regardless of whether that pane is inside Zellij or a bare terminal. -- What happens when a planning agent session is interrupted (Ctrl+C)? The opencode process is terminated and the terminal returns to the shell. Any partially-created spec artifacts from spec-kitty remain on disk (spec-kitty handles its own atomicity). - -## Requirements *(mandatory)* - -### Functional Requirements - -- **FR-001**: The system MUST provide a `new` subcommand on the `kasmos` CLI that launches the planning agent workflow. -- **FR-002**: The `new` subcommand MUST accept an optional positional argument containing an initial feature description (e.g., `kasmos new "add dark mode toggle"`). -- **FR-003**: The system MUST launch the opencode agent runtime directly in the current terminal process (exec or equivalent), not in a Zellij session, tab, or pane. -- **FR-004**: The system MUST configure the launched opencode session with the planning agent role, using the existing `AgentRole::Planner` context boundaries (spec, plan, architecture, workflow intelligence, constitution, project structure). -- **FR-005**: The planning agent prompt MUST include an instruction to invoke `/spec-kitty.specify` as its primary task. -- **FR-006**: When an initial feature description is provided via the positional argument, the prompt MUST include that description so the planning agent can pass it through to `/spec-kitty.specify`. -- **FR-007**: The system MUST validate that the opencode binary and spec-kitty binary are available before launching. If either is missing, the command MUST print an actionable error with install guidance and exit with a non-zero code. -- **FR-008**: The system MUST load project configuration from `kasmos.toml` to determine the opencode binary path, profile, and specs root directory. -- **FR-009**: The system MUST use the configured opencode profile (if set) when launching the agent session. -- **FR-010**: When opencode exits, the `kasmos new` process MUST exit with the same exit code, returning control to the user's terminal. -- **FR-011**: The system MUST NOT create any Zellij sessions, tabs, or panes. The `new` subcommand has no Zellij dependency. -- **FR-012**: The system MUST NOT acquire feature locks, since no feature exists yet at invocation time. -- **FR-013**: The system MUST work regardless of whether it is run inside or outside of a Zellij session. - -### Key Entities - -- **Planning Agent**: An opencode session configured with the Planner role's context boundaries. Receives project-wide context (constitution, architecture memory, workflow intelligence, project structure) sufficient to make informed specification decisions. Its primary task is to run `/spec-kitty.specify`. -- **Initial Description**: An optional free-text string provided by the user at invocation time. Passed through to the planning agent's prompt as seed input for the specification workflow. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: A user can go from running `kasmos new` to an interactive planning agent session within 3 seconds. -- **SC-002**: The planning agent successfully invokes `/spec-kitty.specify` on first launch without manual user intervention to configure or trigger it. -- **SC-003**: A complete specification workflow (from `kasmos new` through a finished `spec.md`) can be completed in a single uninterrupted session. -- **SC-004**: When dependencies are missing, the user receives an actionable error message within 1 second of running the command. -- **SC-005**: The command adds no new runtime dependencies beyond what kasmos already requires (opencode, spec-kitty). diff --git a/kitty-specs/012-kasmos-new-command/tasks.md b/kitty-specs/012-kasmos-new-command/tasks.md deleted file mode 100644 index e53f68c..0000000 --- a/kitty-specs/012-kasmos-new-command/tasks.md +++ /dev/null @@ -1,107 +0,0 @@ -# Task Breakdown: Kasmos New Command - -**Feature**: 012-kasmos-new-command -**Total Work Packages**: 2 -**Total Subtasks**: 11 -**Estimated Total Lines**: ~165 new/changed - -## Subtask Index - -| ID | Description | WP | Parallel | -|----|-------------|----|----------| -| T001 | Make `read_file_if_exists` and `summarize_markdown` `pub(crate)` in prompt.rs | WP01 | [P] | -| T002 | Add CLI wiring: `pub mod new`, `Commands::New`, dispatch arm | WP01 | [P] | -| T003 | Create new.rs with `preflight_check()` for opencode + spec-kitty | WP01 | | -| T004 | Implement repo root discovery from CWD | WP01 | | -| T005 | Implement `build_prompt()` with context loading + description injection | WP01 | | -| T006 | Implement opencode process spawning with exit code propagation | WP01 | | -| T007 | Wire `run()` orchestrator: config -> preflight -> prompt -> spawn | WP01 | | -| T008 | Test pre-flight validation (missing/present binaries) | WP02 | [P] | -| T009 | Test prompt construction (instruction, description handling) | WP02 | [P] | -| T010 | Test prompt degradation (missing .kittify/memory/) | WP02 | [P] | -| T011 | Test CLI parsing for `Commands::New` | WP02 | [P] | - ---- - -## Phase 1: Setup & Core Implementation - -### WP01: CLI Wiring, Pre-flight & Prompt Builder - -**Priority**: P1 (critical path -- the entire feature) -**Subtasks**: T001, T002, T003, T004, T005, T006, T007 (7 subtasks) -**Estimated prompt size**: ~400 lines -**Dependencies**: None (foundation WP) -**Prompt file**: `tasks/WP01-cli-preflight-prompt-launch.md` - -**Goal**: Implement the complete `kasmos new` command from CLI parsing through opencode launch. After this WP, `kasmos new` is fully functional. - -**Summary**: -- Expose 2 helper functions in prompt.rs as `pub(crate)` (T001) -- Wire up `Commands::New` in main.rs/lib.rs with dispatch (T002) -- Create `new.rs` with pre-flight binary validation (T003) -- Add repo root discovery from CWD (T004) -- Build the planning agent prompt with project context and optional description (T005) -- Spawn opencode as child process, propagate exit code (T006) -- Wire the `run()` function that orchestrates all steps (T007) - -**Implementation sequence**: T001 and T002 are independent (parallel). T003-T007 are sequential within new.rs. T007 integrates everything. - -**Included subtasks**: -- [x] T001: Make `read_file_if_exists` and `summarize_markdown` `pub(crate)` in prompt.rs -- [x] T002: Add `pub mod new;` to lib.rs, `Commands::New` to main.rs, dispatch arm -- [x] T003: Create new.rs with `preflight_check()` for opencode + spec-kitty -- [x] T004: Implement repo root discovery from CWD -- [x] T005: Implement `build_prompt()` with context loading + description injection -- [x] T006: Implement opencode process spawning with exit code propagation -- [x] T007: Wire `run()` orchestrator: config -> preflight -> prompt -> spawn - -**Risks**: -- Shell escaping of prompt content with special characters (mitigate: use shell-escape crate) -- Prompt too long for --prompt arg (mitigate: summarize context with summarize_markdown, shell arg limit is ~2MB) - -**Independent test**: Run `kasmos new` from terminal, verify opencode launches with planner role and initiates `/spec-kitty.specify`. - ---- - -## Phase 2: Quality & Verification - -### WP02: Unit Tests - -**Priority**: P2 (required by constitution, but feature works without them) -**Subtasks**: T008, T009, T010, T011 (4 subtasks) -**Estimated prompt size**: ~300 lines -**Dependencies**: WP01 -**Prompt file**: `tasks/WP02-unit-tests.md` - -**Goal**: Add comprehensive unit tests for pre-flight validation, prompt construction, and CLI parsing. - -**Summary**: -- Test pre-flight catches missing opencode/spec-kitty binaries and passes when present (T008) -- Test prompt includes /spec-kitty.specify instruction and handles description correctly (T009) -- Test prompt degrades gracefully when .kittify/memory/ files are absent (T010) -- Test CLI parsing for `Commands::New` with various input formats (T011) - -**Implementation sequence**: All tests are independent (parallel). Each test function stands alone. - -**Included subtasks**: -- [x] T008: Test pre-flight validation (missing/present binaries) -- [x] T009: Test prompt construction (instruction present, description handling) -- [x] T010: Test prompt degradation (missing .kittify/memory/) -- [x] T011: Test CLI parsing for `Commands::New` - -**Risks**: -- Test fixture management for .kittify/memory/ files (mitigate: use tempfile crate, already in deps) - -**Independent test**: `cargo test -p kasmos -- new` passes all tests. - ---- - -## Parallelization - -- **WP01 and WP02 are sequential** (WP02 tests WP01's code) -- **Within WP01**: T001 and T002 are parallel (different files). T003-T007 are sequential. -- **Within WP02**: All tests are parallel (independent test functions). - -## MVP Scope - -**WP01 alone** is the MVP. It delivers a fully functional `kasmos new` command. WP02 adds test coverage required by the constitution but is not needed for the feature to work. diff --git a/kitty-specs/012-kasmos-new-command/tasks/.gitkeep b/kitty-specs/012-kasmos-new-command/tasks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/kitty-specs/012-kasmos-new-command/tasks/README.md b/kitty-specs/012-kasmos-new-command/tasks/README.md deleted file mode 100644 index e5ed574..0000000 --- a/kitty-specs/012-kasmos-new-command/tasks/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Tasks Directory - -This directory contains work package (WP) prompt files with lane status in frontmatter. - -## Directory Structure (v0.9.0+) - -``` -tasks/ -├── WP01-setup-infrastructure.md -├── WP02-user-authentication.md -├── WP03-api-endpoints.md -└── README.md -``` - -All WP files are stored flat in `tasks/`. The lane (planned, doing, for_review, done) is stored in the YAML frontmatter `lane:` field. - -## Work Package File Format - -Each WP file **MUST** use YAML frontmatter: - -```yaml ---- -work_package_id: "WP01" -title: "Work Package Title" -lane: "planned" -subtasks: - - "T001" - - "T002" -phase: "Phase 1 - Setup" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" -history: - - timestamp: "2025-01-01T00:00:00Z" - lane: "planned" - agent: "system" - action: "Prompt generated via /spec-kitty.tasks" ---- - -# Work Package Prompt: WP01 – Work Package Title - -[Content follows...] -``` - -## Valid Lane Values - -- `planned` - Ready for implementation -- `doing` - Currently being worked on -- `for_review` - Awaiting review -- `done` - Completed - -## Moving Between Lanes - -Use the CLI (updates frontmatter only, no file movement): -```bash -spec-kitty agent tasks move-task --to -``` - -Example: -```bash -spec-kitty agent tasks move-task WP01 --to doing -``` - -## File Naming - -- Format: `WP01-kebab-case-slug.md` -- Examples: `WP01-setup-infrastructure.md`, `WP02-user-auth.md` diff --git a/kitty-specs/012-kasmos-new-command/tasks/WP01-cli-preflight-prompt-launch.md b/kitty-specs/012-kasmos-new-command/tasks/WP01-cli-preflight-prompt-launch.md deleted file mode 100644 index 5117ebc..0000000 --- a/kitty-specs/012-kasmos-new-command/tasks/WP01-cli-preflight-prompt-launch.md +++ /dev/null @@ -1,444 +0,0 @@ ---- -work_package_id: "WP01" -title: "CLI Wiring, Pre-flight & Prompt Builder" -lane: "done" -dependencies: [] -subtasks: ["T001", "T002", "T003", "T004", "T005", "T006", "T007"] -reviewed_by: "kas" -review_status: "approved" -history: - - date: "2026-02-16" - action: "created" - by: "planner" ---- - -# WP01: CLI Wiring, Pre-flight & Prompt Builder - -## Objective - -Implement the complete `kasmos new` command: CLI wiring, dependency pre-flight, prompt construction with project context, and opencode process spawning with exit code propagation. After this WP, `kasmos new` is fully functional end-to-end. - -## Implementation Command - -```bash -spec-kitty implement WP01 -``` - -## Context - -**Feature**: 012-kasmos-new-command -**Architecture decisions**: AD-001 (new module), AD-002 (purpose-built prompt), AD-003 (lightweight pre-flight), AD-004 (sync spawn), AD-005 (trailing var arg) -**Research references**: R-001 through R-005 in `kitty-specs/012-kasmos-new-command/research.md` - -**Key files to read before starting**: -- `crates/kasmos/src/main.rs` -- current CLI structure and dispatch pattern -- `crates/kasmos/src/lib.rs` -- module exports -- `crates/kasmos/src/prompt.rs` -- helpers to make pub(crate), and existing RolePromptBuilder for pattern reference -- `crates/kasmos/src/config.rs` -- Config struct, especially `AgentConfig` and `PathsConfig` -- `crates/kasmos/src/launch/layout.rs:110-141` -- ManagerCommand for opencode invocation pattern -- `config/profiles/kasmos/agent/planner.md` -- existing planner template for reference - ---- - -## Subtask T001: Make Helper Functions `pub(crate)` in prompt.rs - -**Purpose**: Expose `read_file_if_exists` and `summarize_markdown` so `new.rs` can reuse them for loading project context without duplicating code. - -**Steps**: -1. Open `crates/kasmos/src/prompt.rs` -2. Change `fn read_file_if_exists` (line ~470) to `pub(crate) fn read_file_if_exists` -3. Change `fn summarize_markdown` (line ~421) to `pub(crate) fn summarize_markdown` -4. No other changes needed -- both functions are pure and have no side effects - -**Files**: -- `crates/kasmos/src/prompt.rs` (modify 2 lines) - -**Validation**: -- [ ] `cargo build` succeeds (no breakage from visibility change) -- [ ] `cargo test -p kasmos` still passes (existing tests unaffected) - ---- - -## Subtask T002: Add CLI Wiring (lib.rs + main.rs) - -**Purpose**: Register the `new` subcommand so `kasmos new [description...]` is parseable by clap and dispatches to the handler. - -**Steps**: -1. In `crates/kasmos/src/lib.rs`, add `pub mod new;` in the module list (alphabetical order, after `pub mod logging;`) - -2. In `crates/kasmos/src/main.rs`, add the `New` variant to the `Commands` enum: - ```rust - /// Create a new feature specification - New { - /// Initial feature description (optional, can be multiple words) - #[arg(trailing_var_arg = true)] - description: Vec, - }, - ``` - -3. In `crates/kasmos/src/main.rs`, add the dispatch arm in `match cli.command`: - ```rust - Some(Commands::New { description }) => { - if let Err(err) = kasmos::init_logging(false) { - eprintln!("Warning: logging init failed: {err}"); - } - let desc = if description.is_empty() { - None - } else { - Some(description.join(" ")) - }; - let code = kasmos::new::run(desc.as_deref()) - .context("New feature spec failed")?; - std::process::exit(code); - } - ``` - -4. Update the `after_help` string to include the `new` command: - ``` - kasmos new [description] Create a new feature specification - ``` - -**Files**: -- `crates/kasmos/src/lib.rs` (add 1 line) -- `crates/kasmos/src/main.rs` (add ~15 lines) - -**Validation**: -- [ ] `kasmos --help` shows the `new` subcommand with description -- [ ] `kasmos new --help` shows the optional description argument -- [ ] Code compiles (will fail until new.rs exists -- that's expected if doing T001+T002 before T003) - -**Note**: This subtask and T001 can be done in parallel since they touch different files. - ---- - -## Subtask T003: Create new.rs with preflight_check() - -**Purpose**: Create the new module and implement dependency validation that checks only opencode and spec-kitty are present, per AD-003. - -**Steps**: -1. Create `crates/kasmos/src/new.rs` - -2. Add module-level doc comment: - ```rust - //! `kasmos new` -- launch a planning agent to create a new feature specification. - ``` - -3. Implement `preflight_check()`: - ```rust - use crate::config::Config; - use anyhow::{Context, Result, bail}; - use std::path::PathBuf; - - /// Validate that required binaries (opencode, spec-kitty) are in PATH. - /// Returns Ok(()) if both found, or a descriptive error with install guidance. - fn preflight_check(config: &Config) -> Result<()> { - // Check opencode - if which::which(&config.agent.opencode_binary).is_err() { - bail!( - "{} not found in PATH\n Needed for: launching the planning agent session\n Fix: Install OpenCode and ensure its launcher binary is on PATH", - config.agent.opencode_binary - ); - } - // Check spec-kitty - if which::which(&config.paths.spec_kitty_binary).is_err() { - bail!( - "{} not found in PATH\n Needed for: feature/task lifecycle commands\n Fix: Install spec-kitty and ensure `spec-kitty` is on PATH", - config.paths.spec_kitty_binary - ); - } - Ok(()) - } - ``` - -**Files**: -- `crates/kasmos/src/new.rs` (new file, ~30 lines so far) - -**Validation**: -- [ ] With a valid config, `preflight_check()` returns `Ok(())` -- [ ] With a bad opencode binary name, it returns an error mentioning the binary and install guidance -- [ ] With a bad spec-kitty binary name, same pattern - ---- - -## Subtask T004: Implement Repo Root Discovery - -**Purpose**: Find the project root directory from CWD so we can locate `.kittify/memory/` and `kitty-specs/` for context loading. - -**Steps**: -1. In `new.rs`, add a function to discover repo root: - ```rust - /// Walk up from CWD to find project root (directory containing Cargo.toml or .kittify/). - fn find_repo_root() -> Result { - let cwd = std::env::current_dir().context("failed to determine current directory")?; - for ancestor in cwd.ancestors() { - if ancestor.join("Cargo.toml").exists() || ancestor.join(".kittify").exists() { - return Ok(ancestor.to_path_buf()); - } - } - bail!( - "Could not find project root (no Cargo.toml or .kittify/ found).\n\ - Run `kasmos new` from the project root, or run `kasmos setup` first." - ); - } - ``` - -**Design note**: This mirrors `RolePromptBuilder::find_repo_root()` (`prompt.rs:389-401`) but starts from CWD instead of `feature_dir`. We can't reuse the existing function because it's a method on `RolePromptBuilder` and requires a `&self` reference. - -**Files**: -- `crates/kasmos/src/new.rs` (add ~15 lines) - -**Validation**: -- [ ] Running from project root returns the root path -- [ ] Running from a subdirectory (e.g., `crates/kasmos/`) still finds the root -- [ ] Running from outside any project returns an actionable error - ---- - -## Subtask T005: Implement build_prompt() with Context and Description - -**Purpose**: Construct the full planning agent prompt that instructs opencode to run `/spec-kitty.specify`, includes project context, and optionally embeds the user's initial description. - -**Steps**: -1. In `new.rs`, implement the prompt builder: - ```rust - use crate::prompt::{read_file_if_exists, summarize_markdown}; - - /// Build the planning agent prompt with project context and optional description. - fn build_prompt(repo_root: &Path, description: Option<&str>) -> Result { - let mut sections = Vec::new(); - - // Role instruction - sections.push( - "# kasmos planning agent\n\n\ - Your task is to create a new feature specification for this project.\n\ - Run `/spec-kitty.specify` to begin the interactive specification workflow.\n\ - Follow the discovery interview process to understand the feature before generating the spec." - .to_string(), - ); - - // Optional description - if let Some(desc) = description { - sections.push(format!( - "## Initial Feature Description\n\n\ - The user has provided this initial feature description:\n\n\ - > {desc}\n\n\ - Pass this to /spec-kitty.specify as the starting feature description." - )); - } - - // Project context from .kittify/memory/ - let memory_dir = repo_root.join(".kittify/memory"); - - if let Some(constitution) = read_file_if_exists(&memory_dir.join("constitution.md"))? { - sections.push(format!( - "## Constitution\n\n{}", - summarize_markdown(&constitution, 15) - )); - } - - if let Some(architecture) = read_file_if_exists(&memory_dir.join("architecture.md"))? { - sections.push(format!( - "## Architecture\n\n{}", - summarize_markdown(&architecture, 15) - )); - } - - if let Some(workflow) = read_file_if_exists(&memory_dir.join("workflow-intelligence.md"))? { - sections.push(format!( - "## Workflow Intelligence\n\n{}", - summarize_markdown(&workflow, 12) - )); - } - - // Existing specs for awareness - let specs_dir = repo_root.join("kitty-specs"); - if specs_dir.is_dir() { - let mut specs = Vec::new(); - for entry in std::fs::read_dir(&specs_dir)? { - let entry = entry?; - if entry.path().is_dir() { - if let Some(name) = entry.file_name().to_str() { - specs.push(format!("- `{name}`")); - } - } - } - if !specs.is_empty() { - specs.sort(); - sections.push(format!( - "## Existing Feature Specs\n\n{}", - specs.join("\n") - )); - } - } - - // Project structure (top-level dirs) - let mut dirs = Vec::new(); - for entry in std::fs::read_dir(repo_root)? { - let entry = entry?; - if entry.path().is_dir() { - if let Some(name) = entry.file_name().to_str() { - if !name.starts_with('.') { - dirs.push(format!("- `{name}/`")); - } - } - } - } - if !dirs.is_empty() { - dirs.sort(); - sections.push(format!("## Project Structure\n\n{}", dirs.join("\n"))); - } - - Ok(sections.join("\n\n")) - } - ``` - -2. The prompt structure follows AD-002 exactly: - - Role instruction with `/spec-kitty.specify` command - - Optional description (only if provided) - - Constitution, architecture, workflow intelligence (each summarized) - - Existing specs list - - Project structure - -**Files**: -- `crates/kasmos/src/new.rs` (add ~70 lines) - -**Validation**: -- [ ] Prompt always contains "/spec-kitty.specify" -- [ ] With description "add dark mode", prompt contains `> add dark mode` -- [ ] Without description, no "Initial Feature Description" section -- [ ] With .kittify/memory/ files present, their summaries appear -- [ ] With .kittify/memory/ absent, no error and those sections are simply missing -- [ ] Existing specs in kitty-specs/ are listed -- [ ] Top-level directories are listed (excluding dotfiles) - ---- - -## Subtask T006: Implement Opencode Process Spawning - -**Purpose**: Build the opencode command with correct arguments and spawn it as a child process, returning the exit code to the caller. - -**Steps**: -1. In `new.rs`, implement the spawn function: - ```rust - use std::process::Command; - - /// Spawn opencode as a child process with the planning agent prompt. - /// Returns the process exit code (0 on success). - fn spawn_opencode(config: &Config, prompt: &str) -> Result { - let mut cmd = Command::new(&config.agent.opencode_binary); - cmd.arg("oc"); - - // Add profile if configured - if let Some(ref profile) = config.agent.opencode_profile { - cmd.args(["-p", profile]); - } - - // Separator and agent args - cmd.arg("--"); - cmd.args(["--agent", "planner"]); - cmd.args(["--prompt", prompt]); - - let status = cmd - .status() - .with_context(|| format!("failed to launch {}", config.agent.opencode_binary))?; - - Ok(status.code().unwrap_or(1)) - } - ``` - -2. **Key design points**: - - Uses `Command::status()` per AD-004 (spawn-and-wait) - - Passes `--agent planner` per R-003 - - Passes `--prompt` with the full rendered prompt - - Returns the exit code for propagation per FR-010 - - `unwrap_or(1)` handles signal termination (where code() returns None) - -**Shell escaping note**: `Command::arg()` handles argument boundaries correctly -- no shell escaping needed since we're not going through a shell. The prompt string is passed as a single OS argument directly. - -**Files**: -- `crates/kasmos/src/new.rs` (add ~25 lines) - -**Validation**: -- [ ] With valid config and prompt, opencode launches in current terminal -- [ ] Profile flag is included when config has `opencode_profile = Some("kas")` -- [ ] Profile flag is omitted when config has `opencode_profile = None` -- [ ] Exit code from opencode is returned correctly - ---- - -## Subtask T007: Wire run() Orchestrator - -**Purpose**: Create the public `run()` function that ties together config loading, pre-flight, prompt building, and process spawning into the complete `kasmos new` flow. - -**Steps**: -1. In `new.rs`, implement the top-level orchestrator: - ```rust - /// Run the `kasmos new` command. - /// - /// Loads config, validates dependencies, builds the planning agent prompt - /// with project context, and launches opencode in the current terminal. - /// Returns the opencode process exit code. - pub fn run(description: Option<&str>) -> Result { - let config = Config::load().context("Failed to load config")?; - - preflight_check(&config)?; - - let repo_root = find_repo_root()?; - let prompt = build_prompt(&repo_root, description)?; - - spawn_opencode(&config, &prompt) - } - ``` - -2. This is the function called from `main.rs` (wired in T002). The flow is: - - Load config (kasmos.toml + env overrides + defaults) - - Pre-flight (validate opencode + spec-kitty in PATH) - - Find repo root (walk up from CWD) - - Build prompt (load project context + description) - - Spawn opencode (launch child process, wait, return exit code) - -3. The function is intentionally synchronous. It's called from the async `main()` but does no async work. Since `main()` calls `std::process::exit()` with the returned code, the tokio runtime is dropped cleanly. - -**Files**: -- `crates/kasmos/src/new.rs` (add ~15 lines) - -**Validation**: -- [ ] `kasmos new` from project root launches opencode with planner agent -- [ ] `kasmos new add dark mode` launches opencode with description in prompt -- [ ] `kasmos new` with missing opencode prints error and returns non-zero -- [ ] `kasmos new` from outside project root prints guidance and returns non-zero -- [ ] After opencode exits, `kasmos new` exits with same code - ---- - -## Definition of Done - -- [ ] `kasmos new` launches opencode in current terminal as planning agent -- [ ] Planning agent prompt contains `/spec-kitty.specify` instruction -- [ ] `kasmos new "description"` and `kasmos new description words` both work -- [ ] Missing opencode/spec-kitty produces actionable error -- [ ] Exit code propagation works -- [ ] `cargo build` succeeds -- [ ] `kasmos --help` shows `new` subcommand -- [ ] No Zellij sessions/tabs/panes created -- [ ] No feature locks acquired - -## Risks - -- **Shell argument size**: Prompt could theoretically exceed OS argument limits. Mitigated by summarize_markdown() keeping context compact (~5KB). OS limits are typically 2MB+. -- **Special characters in description**: User might include quotes or shell metacharacters. Mitigated by `Command::arg()` passing args directly without shell interpretation. -- **Agent doesn't invoke /spec-kitty.specify**: The prompt explicitly instructs it, but LLM behavior isn't deterministic. Manual verification required before merge. - -## Reviewer Guidance - -- Verify the prompt structure matches AD-002 in plan.md -- Check that `preflight_check()` only checks opencode + spec-kitty (not zellij or other deps) -- Confirm `Command::arg()` is used (not shell string interpolation) for process spawning -- Verify exit code propagation handles signal termination (code() returns None) -- Run `kasmos new` manually to verify end-to-end behavior - -## Activity Log - -- 2026-02-16T14:31:30Z – unknown – lane=doing – Implementation complete -- 2026-02-16T14:31:44Z – unknown – lane=for_review – All subtasks complete, implementation reviewed -- 2026-02-16T14:31:48Z – unknown – lane=done – Review passed, bug fix applied diff --git a/kitty-specs/012-kasmos-new-command/tasks/WP02-unit-tests.md b/kitty-specs/012-kasmos-new-command/tasks/WP02-unit-tests.md deleted file mode 100644 index d2619e9..0000000 --- a/kitty-specs/012-kasmos-new-command/tasks/WP02-unit-tests.md +++ /dev/null @@ -1,344 +0,0 @@ ---- -work_package_id: WP02 -title: Unit Tests -lane: "done" -dependencies: [] -subtasks: [T008, T009, T010, T011] -reviewed_by: "kas" -review_status: "approved" -history: -- date: '2026-02-16' - action: created - by: planner ---- - -# WP02: Unit Tests - -## Objective - -Add comprehensive unit tests for the `kasmos new` command covering pre-flight validation, prompt construction, prompt degradation, and CLI parsing. Tests verify all code paths from WP01 are exercised. - -## Implementation Command - -```bash -spec-kitty implement WP02 --base WP01 -``` - -## Context - -**Feature**: 012-kasmos-new-command -**Depends on**: WP01 (all code under test) -**Testing strategy**: From `kitty-specs/012-kasmos-new-command/plan.md` Testing Strategy section -**Constitution requirement**: "All features must have corresponding tests" -- these tests satisfy that requirement. - -**Key files to read before starting**: -- `crates/kasmos/src/new.rs` -- the code under test (created in WP01) -- `crates/kasmos/src/prompt.rs` lines 684-843 -- existing test patterns and Fixture struct for reference -- `crates/kasmos/src/main.rs` -- CLI struct for parsing tests - ---- - -## Subtask T008: Test Pre-flight Validation - -**Purpose**: Verify that `preflight_check()` correctly detects missing and present binaries, returning actionable errors. - -**Steps**: -1. In `crates/kasmos/src/new.rs`, add a `#[cfg(test)] mod tests` block at the bottom of the file. - -2. Add test for missing opencode: - ```rust - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn preflight_fails_when_opencode_missing() { - let mut config = Config::default(); - config.agent.opencode_binary = "__nonexistent_opencode_xyz__".to_string(); - - let err = preflight_check(&config).expect_err("should fail"); - let msg = err.to_string(); - assert!(msg.contains("__nonexistent_opencode_xyz__")); - assert!(msg.contains("not found in PATH")); - assert!(msg.contains("Install OpenCode")); - } - } - ``` - -3. Add test for missing spec-kitty: - ```rust - #[test] - fn preflight_fails_when_spec_kitty_missing() { - let mut config = Config::default(); - // opencode must pass first, so use a real binary - config.agent.opencode_binary = "bash".to_string(); - config.paths.spec_kitty_binary = "__nonexistent_spec_kitty_xyz__".to_string(); - - let err = preflight_check(&config).expect_err("should fail"); - let msg = err.to_string(); - assert!(msg.contains("__nonexistent_spec_kitty_xyz__")); - assert!(msg.contains("not found in PATH")); - assert!(msg.contains("spec-kitty")); - } - ``` - -4. Add test for successful pre-flight: - ```rust - #[test] - fn preflight_passes_with_real_binaries() { - let mut config = Config::default(); - config.agent.opencode_binary = "bash".to_string(); - config.paths.spec_kitty_binary = "bash".to_string(); - - preflight_check(&config).expect("preflight should pass with real binaries"); - } - ``` - -5. **Important**: Use `"bash"` as a stand-in for real binaries since it's always present on Linux/macOS. Use clearly-fake names like `"__nonexistent_xyz__"` for missing binary tests. - -**Files**: -- `crates/kasmos/src/new.rs` (add ~40 lines in test module) - -**Validation**: -- [ ] `cargo test -p kasmos -- preflight_fails_when_opencode_missing` passes -- [ ] `cargo test -p kasmos -- preflight_fails_when_spec_kitty_missing` passes -- [ ] `cargo test -p kasmos -- preflight_passes_with_real_binaries` passes - ---- - -## Subtask T009: Test Prompt Construction - -**Purpose**: Verify that `build_prompt()` includes the `/spec-kitty.specify` instruction and correctly handles the optional description. - -**Steps**: -1. Create a test fixture helper (follow the pattern from `prompt.rs` tests): - ```rust - use tempfile::tempdir; - - fn setup_test_repo() -> tempfile::TempDir { - let root = tempdir().expect("create tempdir"); - // Create Cargo.toml so find_repo_root() would work - std::fs::write(root.path().join("Cargo.toml"), "[workspace]\n").expect("write Cargo.toml"); - // Create .kittify/memory/ with test content - let memory = root.path().join(".kittify/memory"); - std::fs::create_dir_all(&memory).expect("create memory dir"); - std::fs::write( - memory.join("constitution.md"), - "# Constitution\n\n## Technical Standards\n\n- Rust 2024\n- tokio async", - ).expect("write constitution"); - std::fs::write( - memory.join("architecture.md"), - "# Architecture\n\nARCH_CONTENT_SENTINEL", - ).expect("write architecture"); - // Create kitty-specs/ with a sample feature - let specs = root.path().join("kitty-specs/011-test-feature"); - std::fs::create_dir_all(&specs).expect("create specs dir"); - root - } - ``` - -2. Test that prompt always contains the /spec-kitty.specify instruction: - ```rust - #[test] - fn prompt_contains_specify_instruction() { - let repo = setup_test_repo(); - let prompt = build_prompt(repo.path(), None).expect("build prompt"); - assert!(prompt.contains("/spec-kitty.specify")); - assert!(prompt.contains("planning agent")); - } - ``` - -3. Test that description is included when provided: - ```rust - #[test] - fn prompt_includes_description_when_provided() { - let repo = setup_test_repo(); - let prompt = build_prompt(repo.path(), Some("add dark mode toggle")).expect("build prompt"); - assert!(prompt.contains("add dark mode toggle")); - assert!(prompt.contains("Initial Feature Description")); - } - ``` - -4. Test that description section is absent when not provided: - ```rust - #[test] - fn prompt_omits_description_when_not_provided() { - let repo = setup_test_repo(); - let prompt = build_prompt(repo.path(), None).expect("build prompt"); - assert!(!prompt.contains("Initial Feature Description")); - } - ``` - -5. Test that project context sections appear: - ```rust - #[test] - fn prompt_includes_project_context() { - let repo = setup_test_repo(); - let prompt = build_prompt(repo.path(), None).expect("build prompt"); - // Constitution content is present - assert!(prompt.contains("Rust 2024")); - // Architecture content is present - assert!(prompt.contains("ARCH_CONTENT_SENTINEL")); - // Existing specs are listed - assert!(prompt.contains("011-test-feature")); - } - ``` - -**Files**: -- `crates/kasmos/src/new.rs` (add ~60 lines in test module) - -**Validation**: -- [ ] `cargo test -p kasmos -- prompt_contains_specify_instruction` passes -- [ ] `cargo test -p kasmos -- prompt_includes_description_when_provided` passes -- [ ] `cargo test -p kasmos -- prompt_omits_description_when_not_provided` passes -- [ ] `cargo test -p kasmos -- prompt_includes_project_context` passes - ---- - -## Subtask T010: Test Prompt Degradation - -**Purpose**: Verify that `build_prompt()` handles missing `.kittify/memory/` files gracefully (no errors, sections simply omitted). - -**Steps**: -1. Create a minimal test repo with NO .kittify/memory/ directory: - ```rust - fn setup_bare_repo() -> tempfile::TempDir { - let root = tempdir().expect("create tempdir"); - std::fs::write(root.path().join("Cargo.toml"), "[workspace]\n").expect("write Cargo.toml"); - root - } - ``` - -2. Test that prompt builds without error on bare repo: - ```rust - #[test] - fn prompt_handles_missing_memory_gracefully() { - let repo = setup_bare_repo(); - let prompt = build_prompt(repo.path(), None).expect("build prompt"); - // Core instruction is always present - assert!(prompt.contains("/spec-kitty.specify")); - // Memory-dependent sections are absent (not errored) - assert!(!prompt.contains("Constitution")); - assert!(!prompt.contains("Architecture")); - assert!(!prompt.contains("Workflow Intelligence")); - } - ``` - -3. Test with partial memory (only constitution, no architecture): - ```rust - #[test] - fn prompt_handles_partial_memory() { - let repo = setup_bare_repo(); - let memory = repo.path().join(".kittify/memory"); - std::fs::create_dir_all(&memory).expect("create memory dir"); - std::fs::write( - memory.join("constitution.md"), - "# Constitution\n\nPARTIAL_SENTINEL", - ).expect("write constitution"); - - let prompt = build_prompt(repo.path(), None).expect("build prompt"); - assert!(prompt.contains("PARTIAL_SENTINEL")); - assert!(!prompt.contains("Architecture")); - } - ``` - -**Files**: -- `crates/kasmos/src/new.rs` (add ~30 lines in test module) - -**Validation**: -- [ ] `cargo test -p kasmos -- prompt_handles_missing_memory_gracefully` passes -- [ ] `cargo test -p kasmos -- prompt_handles_partial_memory` passes - ---- - -## Subtask T011: Test CLI Parsing - -**Purpose**: Verify that `Commands::New` parses correctly with various description input formats. - -**Steps**: -1. In the test module in `crates/kasmos/src/new.rs`, or better in `crates/kasmos/src/main.rs` if the `Cli` struct is accessible, add parsing tests. Since `Cli` is in main.rs and private, these tests should go in `main.rs` or the Cli struct needs to be made accessible. - - **Recommended approach**: Add a test in `main.rs` at the bottom: - ```rust - #[cfg(test)] - mod tests { - use super::*; - use clap::Parser; - - #[test] - fn new_command_parses_without_description() { - let cli = Cli::try_parse_from(["kasmos", "new"]).expect("parse"); - match cli.command { - Some(Commands::New { description }) => { - assert!(description.is_empty()); - } - _ => panic!("expected Commands::New"), - } - } - - #[test] - fn new_command_parses_quoted_description() { - let cli = Cli::try_parse_from(["kasmos", "new", "add dark mode toggle"]) - .expect("parse"); - match cli.command { - Some(Commands::New { description }) => { - assert_eq!(description, vec!["add", "dark", "mode", "toggle"]); - } - _ => panic!("expected Commands::New"), - } - } - - #[test] - fn new_command_parses_unquoted_trailing_words() { - let cli = Cli::try_parse_from([ - "kasmos", "new", "add", "dark", "mode" - ]).expect("parse"); - match cli.command { - Some(Commands::New { description }) => { - assert_eq!(description.join(" "), "add dark mode"); - } - _ => panic!("expected Commands::New"), - } - } - } - ``` - -2. **Note on `trailing_var_arg`**: With `trailing_var_arg = true`, clap treats everything after `new` as positional arguments. Both `kasmos new "add dark mode"` (shell splits into 3 words) and `kasmos new add dark mode` produce the same `Vec`. - -**Files**: -- `crates/kasmos/src/main.rs` (add ~35 lines in test module) - -**Validation**: -- [ ] `cargo test -p kasmos -- new_command_parses_without_description` passes -- [ ] `cargo test -p kasmos -- new_command_parses_quoted_description` passes -- [ ] `cargo test -p kasmos -- new_command_parses_unquoted_trailing_words` passes - ---- - -## Definition of Done - -- [ ] All 10 unit tests pass: `cargo test -p kasmos` -- [ ] Tests cover pre-flight (missing opencode, missing spec-kitty, success) -- [ ] Tests cover prompt (/spec-kitty.specify instruction, description present/absent, context loading, missing context) -- [ ] Tests cover CLI parsing (no description, quoted, trailing words) -- [ ] No test relies on external binaries other than `bash` (available on all supported platforms) -- [ ] Temp directories are used for all filesystem fixtures (no pollution) - -## Risks - -- **Test fixture management**: Tests create temp directories with .kittify/memory/ files. The `tempfile` crate handles cleanup. If tests fail mid-run, temp dirs are still cleaned up by the OS eventually. -- **Platform differences**: `bash` is used as a stand-in for binary existence tests. It's present on both Linux and macOS. If someone runs tests on Windows (unsupported), these tests would fail -- acceptable per constitution. - -## Reviewer Guidance - -- Verify all test names are descriptive and follow existing naming conventions in the codebase -- Check that test fixtures match real project structure (Cargo.toml at root, .kittify/memory/ directory) -- Confirm no tests depend on the real opencode or spec-kitty binaries being installed -- Ensure `tempdir()` is used (not hardcoded paths) for all fixture directories -- Run `cargo test -p kasmos` to verify all tests pass - -## Activity Log - -- 2026-02-16T14:31:53Z – unknown – lane=doing – Tests implemented -- 2026-02-16T14:32:01Z – unknown – lane=for_review – All test subtasks complete -- 2026-02-16T14:32:02Z – unknown – lane=done – Review passed diff --git a/kitty-specs/013-setup-plugin-path-discovery/meta.json b/kitty-specs/013-setup-plugin-path-discovery/meta.json deleted file mode 100644 index 7f6103e..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/meta.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "feature_number": "013", - "slug": "013-setup-plugin-path-discovery", - "friendly_name": "Setup Plugin Path Discovery", - "mission": "software-dev", - "source_description": "Make kasmos setup auto-detect zellij-pane-tracker install path, validate zjstatus.wasm, and document external dependencies", - "created_at": "2026-02-17T00:00:00Z", - "target_branch": "main", - "vcs": "git" -} diff --git a/kitty-specs/013-setup-plugin-path-discovery/plan.md b/kitty-specs/013-setup-plugin-path-discovery/plan.md deleted file mode 100644 index 05dc619..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/plan.md +++ /dev/null @@ -1,113 +0,0 @@ -# 013 - Implementation Plan - -## Architecture Decisions - -### AD-01: Auto-detection strategy for pane-tracker path - -Search these locations in order, stop at first hit where `mcp-server/index.ts` exists: - -1. Value from `kasmos.toml` `[paths].pane_tracker_dir` (if already configured) -2. `KASMOS_PATHS_PANE_TRACKER_DIR` env var -3. Sibling of the WASM plugin: if `zellij-pane-tracker.wasm` was found in a plugins dir, look for the repo checkout at common paths -4. `/opt/zellij-pane-tracker` -5. `$HOME/zellij-pane-tracker` -6. `$HOME/.local/share/zellij-pane-tracker` - -Interactive mode presents the auto-detected path as the default; user can override. Non-interactive mode uses auto-detected path silently. - -### AD-02: MCP command fixup mechanism - -Extend the existing `apply_selections_and_fixup()` with a new `fixup_mcp_pane_tracker_path()` function that walks into `config.mcp.zellij.command` and replaces any path element containing `/opt/zellij-pane-tracker` with the resolved path. This mirrors the existing `fixup_external_directory()` pattern. - -### AD-03: zjstatus check follows existing plugin check pattern - -Model `check_zjstatus()` identically to `check_pane_tracker()` -- look in `zellij_plugin_dir()` for the WASM file, report pass/fail with install guidance. - -## Work Packages - -### Dependency Graph - -``` -WP01 (config + template) ----+ - | -WP02 (zjstatus check) ----+--> WP04 (tests) - | -WP03 (docs) ----+ - -WP01 --> WP01b (interactive prompt + fixup) -WP01b -----> WP04 -``` - -### Wave Assignment - -| Wave | Work Packages | Rationale | -|------|--------------|-----------| -| 1 | WP01, WP02, WP03 | Independent foundations -- zero cross-deps | -| 2 | WP01b | Depends on WP01 config schema being in place | -| 3 | WP04 | Integration tests spanning all changes | - -### WP01 - Config schema + template placeholder (Wave 1) - -**Scope**: Add `pane_tracker_dir` to `PathsConfig`, wire up TOML/env loading, update the embedded opencode.jsonc template to use a recognizable placeholder path, and add the `detect_pane_tracker_dir()` auto-detection function. - -**Files**: -- `crates/kasmos/src/config.rs` -- add field + defaults + env/TOML loading -- `config/profiles/kasmos/opencode.jsonc` -- keep `/opt/zellij-pane-tracker` (fixup replaces it) - -**Acceptance**: `Config::default().paths.pane_tracker_dir` returns `"/opt/zellij-pane-tracker"`. TOML and env overrides work. `detect_pane_tracker_dir()` returns the first valid path or the default. - -### WP02 - zjstatus.wasm setup check (Wave 1) - -**Scope**: Add `check_zjstatus()` to the validation engine alongside the existing pane-tracker check. Include in the checks vector so both `kasmos setup` and launch preflight see it. - -**Files**: -- `crates/kasmos/src/setup/mod.rs` -- new `check_zjstatus()` fn, add to checks vector - -**Acceptance**: `kasmos setup` shows `[PASS] zjstatus` when the WASM exists, `[FAIL] zjstatus` with install guidance when missing. Launch preflight also catches it. - -### WP03 - Documentation updates (Wave 1) - -**Scope**: Add a Dependencies section to README.md. Update INTEGRATIONS.md with zjstatus entry. Update quickstart.md prerequisites. - -**Files**: -- `README.md` -- `.planning/codebase/INTEGRATIONS.md` -- `kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md` - -**Acceptance**: README lists all runtime deps (zellij, opencode/ocx, spec-kitty, git, zellij-pane-tracker, zjstatus). INTEGRATIONS.md has zjstatus entry. - -### WP01b - Interactive pane-tracker path prompt + MCP fixup (Wave 2) - -**Scope**: In `interactive_opencode_config()`, after the model/reasoning customization, prompt for the pane-tracker directory (using auto-detected default). Add `fixup_mcp_pane_tracker_path()` to `apply_selections_and_fixup()` to replace `/opt/zellij-pane-tracker` in the MCP command array. Handle the non-interactive codepath too. - -**Depends on**: WP01 (needs `detect_pane_tracker_dir()` and the config field) - -**Files**: -- `crates/kasmos/src/setup/mod.rs` -- prompt + fixup - -**Acceptance**: Generated `.opencode/opencode.jsonc` has the user-provided path in `mcp.zellij.command`. Non-interactive mode uses auto-detected path. Existing `check_pane_tracker()` also validates the MCP server script exists at the configured path. - -### WP04 - Tests (Wave 3) - -**Scope**: Add/update tests for all new functionality. Update existing tests that assume no zjstatus check. - -**Depends on**: WP01, WP01b, WP02 - -**Files**: -- `crates/kasmos/src/setup/mod.rs` (test module) -- `crates/kasmos/src/config.rs` (test module) - -**Acceptance**: `cargo test` passes. New tests cover: zjstatus pass/fail, pane-tracker path detection, MCP path fixup, config field serialization, env override. - -## Parallel Execution Plan - -``` -Time --> - -Coder A: [=== WP01: config + detect ===]-->[=== WP01b: prompt + fixup ===] -Coder B: [=== WP02: zjstatus check ===] -Coder C: [=== WP03: docs ============] - --> [== WP04: tests ==] -``` - -Wave 1 dispatches three coders simultaneously. WP01b starts as soon as WP01 merges. WP04 starts after WP01b, WP02 complete (WP03 is not a code dep for tests but should be done by then too). diff --git a/kitty-specs/013-setup-plugin-path-discovery/spec.md b/kitty-specs/013-setup-plugin-path-discovery/spec.md deleted file mode 100644 index 392f0c3..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/spec.md +++ /dev/null @@ -1,60 +0,0 @@ -# 013 - Setup Plugin Path Discovery - -## Problem - -`kasmos setup` hardcodes the zellij-pane-tracker installation directory to `/opt/zellij-pane-tracker` when generating the opencode MCP config (`opencode.jsonc`). Users who install the pane tracker elsewhere get a broken MCP server entry. Additionally, the `zjstatus.wasm` plugin referenced in generated Zellij layouts is never validated during setup, and external dependencies are not documented in the README. - -### Hardcoded path - -In `config/profiles/kasmos/opencode.jsonc` (the compile-time embedded template): - -```json -"zellij": { - "type": "local", - "command": ["bun", "run", "/opt/zellij-pane-tracker/mcp-server/index.ts"], - "enabled": true -} -``` - -`apply_selections_and_fixup()` already replaces `~/dev/kasmos` with the real repo root, but nothing touches `/opt/zellij-pane-tracker`. - -### Missing zjstatus check - -`layout.rs` hardcodes `file:~/.config/zellij/plugins/zjstatus.wasm` in every generated layout. If zjstatus is missing, Zellij sessions fail to start with a cryptic plugin error. Setup validates `zellij-pane-tracker.wasm` but not `zjstatus.wasm`. - -### Missing dependency docs - -The README lists commands but not external runtime dependencies. `INTEGRATIONS.md` documents pane-tracker but not zjstatus. - -## Requirements - -### FR-001: Interactive pane-tracker path prompt - -During `kasmos setup`, when installing the opencode config interactively, prompt for the zellij-pane-tracker installation directory. Auto-detect if possible (search common locations, check if the MCP server script exists). Default to the auto-detected path or `/opt/zellij-pane-tracker` as fallback. In non-interactive mode, use auto-detection or the fallback silently. - -### FR-002: Pane-tracker path fixup in generated config - -`apply_selections_and_fixup()` must replace the hardcoded `/opt/zellij-pane-tracker` in the MCP server command with the user-provided path. The replacement must work for the `mcp.zellij.command` array in the opencode.jsonc template. - -### FR-003: Config persistence of pane-tracker path - -Store the resolved pane-tracker path in `PathsConfig` so it persists in `kasmos.toml` and can be overridden via `KASMOS_PATHS_PANE_TRACKER_DIR` env var. The setup check for the pane-tracker WASM plugin should also validate that the MCP server script exists at this path. - -### FR-004: zjstatus.wasm validation - -Add a setup check for `zjstatus.wasm` in the Zellij plugin directory. If missing, report `[FAIL]` with installation guidance. Include this in the shared validation engine so launch preflight also catches it. - -### FR-005: Dependency documentation - -Add a "Dependencies" section to `README.md` listing all required external tools and Zellij plugins. Update `INTEGRATIONS.md` with zjstatus entry. - -## Acceptance Criteria - -- AC-001: `kasmos setup` prompts for pane-tracker path when installing opencode config interactively -- AC-002: Generated `.opencode/opencode.jsonc` contains the user-provided pane-tracker path (not `/opt`) -- AC-003: `pane_tracker_dir` is persisted in `kasmos.toml` under `[paths]` -- AC-004: `kasmos setup` validates `zjstatus.wasm` presence and reports pass/fail -- AC-005: README documents all external dependencies -- AC-006: Non-interactive setup auto-detects or uses fallback without prompting -- AC-007: `cargo test` passes with no regressions -- AC-008: Launch preflight includes the new zjstatus check diff --git a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP01-config-and-autodetect.md b/kitty-specs/013-setup-plugin-path-discovery/tasks/WP01-config-and-autodetect.md deleted file mode 100644 index 6ee0ab2..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP01-config-and-autodetect.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -work_package_id: WP01 -title: Config schema and pane-tracker auto-detection -lane: "done" -dependencies: [] -base_branch: main -base_commit: 8e32626467b9c4feb8da950f6d308cd6e52ea858 -created_at: '2026-02-17T09:50:51.891875+00:00' -phase: Wave 1 -assignee: coder -shell_pid: "3854650" -reviewed_by: "kas" -review_status: "approved" ---- - -# WP01 - Config schema and pane-tracker auto-detection - -## Objective - -Add `pane_tracker_dir` to the config system and implement auto-detection of the zellij-pane-tracker installation directory. This is the foundation that WP01b builds on for the interactive prompt and MCP fixup. - -## Context - -Currently the opencode.jsonc template hardcodes `/opt/zellij-pane-tracker/mcp-server/index.ts` as the MCP server command. The goal is to make this configurable. This WP adds the config plumbing and detection logic; WP01b wires it into the interactive setup flow. - -## Detailed Steps - -### 1. Add `pane_tracker_dir` to `PathsConfig` - -In `crates/kasmos/src/config.rs`: - -```rust -// In PathsConfig struct (around line 73): -pub struct PathsConfig { - pub zellij_binary: String, - pub spec_kitty_binary: String, - pub specs_root: String, - /// Installation directory of zellij-pane-tracker (contains mcp-server/). - pub pane_tracker_dir: String, -} - -// In Default for PathsConfig (around line 136): -impl Default for PathsConfig { - fn default() -> Self { - Self { - zellij_binary: "zellij".to_string(), - spec_kitty_binary: "spec-kitty".to_string(), - specs_root: "kitty-specs".to_string(), - pane_tracker_dir: "/opt/zellij-pane-tracker".to_string(), - } - } -} -``` - -### 2. Wire up TOML and env loading - -In `crates/kasmos/src/config.rs`: - -- Add `pane_tracker_dir: Option` to `PathsConfigFile` (around line 562) -- Add TOML loading in `load_from_file()` (around line 368-378): - ```rust - if let Some(v) = paths.pane_tracker_dir { - self.paths.pane_tracker_dir = v; - } - ``` -- Add env override in `load_from_env()` (around line 250-255): - ```rust - if let Ok(val) = std::env::var("KASMOS_PATHS_PANE_TRACKER_DIR") { - self.paths.pane_tracker_dir = val; - } - ``` - -### 3. Implement `detect_pane_tracker_dir()` in setup - -In `crates/kasmos/src/setup/mod.rs`, add a detection function: - -```rust -/// Auto-detect the zellij-pane-tracker installation directory. -/// -/// Searches common locations for a directory containing `mcp-server/index.ts`. -/// Returns the first match or falls back to the config default. -fn detect_pane_tracker_dir(config: &Config) -> String { - let candidates = [ - // Configured value (from kasmos.toml or env) - Some(config.paths.pane_tracker_dir.clone()), - // Common install locations - Some("/opt/zellij-pane-tracker".to_string()), - std::env::var("HOME").ok().map(|h| format!("{h}/zellij-pane-tracker")), - std::env::var("HOME").ok().map(|h| format!("{h}/.local/share/zellij-pane-tracker")), - std::env::var("HOME").ok().map(|h| format!("{h}/src/zellij-pane-tracker")), - ]; - - for candidate in candidates.into_iter().flatten() { - let mcp_script = PathBuf::from(&candidate).join("mcp-server/index.ts"); - if mcp_script.is_file() { - return candidate; - } - } - - // No valid location found; return the configured default so the user sees - // it as the pre-filled prompt value and can correct it. - config.paths.pane_tracker_dir.clone() -} -``` - -### 4. Enhance `check_pane_tracker()` to also validate MCP server - -Extend the existing `check_pane_tracker()` in `crates/kasmos/src/setup/mod.rs` to also check that the MCP server script exists at the configured path. After the existing WASM plugin checks, add: - -```rust -// After the WASM plugin pass, also check MCP server availability -let mcp_script = PathBuf::from(&config.paths.pane_tracker_dir) - .join("mcp-server/index.ts"); -if !mcp_script.is_file() { - return CheckResult { - name: "pane-tracker".to_string(), - required_for: required_for.to_string(), - description: format!( - "{} (MCP server not found at {})", - plugin_path.display(), - mcp_script.display() - ), - status: CheckStatus::Warn, - guidance: Some(format!( - "Set pane_tracker_dir in kasmos.toml or run `kasmos setup` to configure.\n\ - Expected: {}/mcp-server/index.ts", - config.paths.pane_tracker_dir - )), - }; -} -``` - -Note: `check_pane_tracker()` currently takes no arguments. You will need to change its signature to accept a `&Config` parameter, and update the call site in `validate_environment_with_repo()` accordingly. - -## Files to modify - -- `crates/kasmos/src/config.rs` -- `crates/kasmos/src/setup/mod.rs` - -## Validation - -- `cargo build` succeeds -- `cargo test` passes (update `setup_passes_when_dependencies_are_present` test to create a fake MCP server script, or make the MCP script check a Warn not Fail) -- `Config::default().paths.pane_tracker_dir` returns `"/opt/zellij-pane-tracker"` -- Setting `KASMOS_PATHS_PANE_TRACKER_DIR=/custom/path` and loading config reflects it - -## Activity Log -- 2026-02-17T09:53:46Z – unknown – shell_pid=3854650 – lane=done – Code already on main - all tests pass diff --git a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP01b-interactive-prompt-and-fixup.md b/kitty-specs/013-setup-plugin-path-discovery/tasks/WP01b-interactive-prompt-and-fixup.md deleted file mode 100644 index 076a3f7..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP01b-interactive-prompt-and-fixup.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -work_package_id: WP01b -title: Interactive pane-tracker path prompt and MCP fixup -lane: "done" -dependencies: [WP01] -phase: "Wave 2" -assignee: "coder" -reviewed_by: "kas" -review_status: "approved" ---- - -# WP01b - Interactive pane-tracker path prompt and MCP fixup - -## Objective - -Wire the pane-tracker auto-detection (from WP01) into the interactive `kasmos setup` flow and implement the MCP command path fixup so generated `.opencode/opencode.jsonc` files use the correct path instead of the hardcoded `/opt/zellij-pane-tracker`. - -## Context - -### Current interactive flow (`interactive_opencode_config()`) - -Located at `crates/kasmos/src/setup/mod.rs:549-623`. The flow is: - -1. Parse the embedded template -2. Discover available models -3. Show role defaults -4. Ask "Customize per role?" -> model/reasoning selection -5. Call `apply_selections_and_fixup()` to patch model/reasoning and external_directory paths -6. Serialize to JSON - -### Current fixup flow (`apply_selections_and_fixup()`) - -Located at `crates/kasmos/src/setup/mod.rs:494-519`. It: - -1. Patches model/reasoning per role -2. Calls `fixup_external_directory()` to replace `~/dev/kasmos` with actual repo root - -Neither touches the `mcp` section of the config. - -### Target template section - -In `config/profiles/kasmos/opencode.jsonc` lines 135-146: - -```json -"mcp": { - "zellij": { - "type": "local", - "command": ["bun", "run", "/opt/zellij-pane-tracker/mcp-server/index.ts"], - "enabled": true, - }, - ... -} -``` - -## Detailed Steps - -### 1. Add pane-tracker path prompt to `interactive_opencode_config()` - -After the model/reasoning customization block (around line 618) and before `apply_selections_and_fixup()`, add: - -```rust -// Detect pane-tracker installation -let detected_dir = detect_pane_tracker_dir(/* pass config or use the function from WP01 */); -let pane_tracker_dir: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("zellij-pane-tracker install directory") - .default(detected_dir) - .validate_with(|input: &String| -> Result<(), String> { - let script = PathBuf::from(input).join("mcp-server/index.ts"); - if script.is_file() { - Ok(()) - } else { - Err(format!( - "mcp-server/index.ts not found at {}/mcp-server/index.ts", - input - )) - } - }) - .interact_text() - .context("Interactive prompt cancelled")?; -``` - -### 2. Pass pane-tracker path to `apply_selections_and_fixup()` - -Update the function signature: - -```rust -fn apply_selections_and_fixup( - config: &mut serde_json::Value, - selections: &BTreeMap, - repo_root: &Path, - pane_tracker_dir: &str, // <-- NEW -) -``` - -Update both call sites (interactive at ~line 620, non-interactive at ~line 654). - -For non-interactive mode (around line 650-655), auto-detect without prompting: - -```rust -let pane_tracker_dir = detect_pane_tracker_dir(&config_obj); -// where config_obj is the Config loaded at the top of run() -``` - -Note: you'll need to thread the `Config` reference into `install_opencode_config()` or pass just the detected path. - -### 3. Implement `fixup_mcp_pane_tracker_path()` - -Add a new fixup function and call it from `apply_selections_and_fixup()`: - -```rust -/// Replace `/opt/zellij-pane-tracker` in mcp.zellij.command with the actual path. -fn fixup_mcp_pane_tracker_path(config: &mut serde_json::Value, pane_tracker_dir: &str) { - let command = config - .get_mut("mcp") - .and_then(|m| m.get_mut("zellij")) - .and_then(|z| z.get_mut("command")) - .and_then(|c| c.as_array_mut()); - - let Some(command) = command else { - return; - }; - - for elem in command.iter_mut() { - if let Some(s) = elem.as_str() { - if s.contains("/opt/zellij-pane-tracker") { - *elem = serde_json::Value::String( - s.replace("/opt/zellij-pane-tracker", pane_tracker_dir), - ); - } - } - } -} -``` - -Call it at the end of `apply_selections_and_fixup()`: - -```rust -fixup_mcp_pane_tracker_path(config, pane_tracker_dir); -``` - -### 4. Verify the full flow - -The resulting `.opencode/opencode.jsonc` should have: - -```json -"mcp": { - "zellij": { - "type": "local", - "command": ["bun", "run", "/mcp-server/index.ts"], - "enabled": true - } -} -``` - -## Files to modify - -- `crates/kasmos/src/setup/mod.rs` - -## Validation - -- `cargo build` succeeds -- `cargo test` passes -- Running `kasmos setup` interactively shows a pane-tracker directory prompt with auto-detected default -- The generated `.opencode/opencode.jsonc` contains the user-provided path, NOT `/opt/zellij-pane-tracker` -- Non-interactive mode (piped stdin) uses auto-detected path without prompting -- If user enters a path where `mcp-server/index.ts` doesn't exist, validation rejects it - -## Activity Log -- 2026-02-17T09:53:52Z – unknown – lane=done – Code already on main - interactive prompt, MCP fixup, all tests pass diff --git a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP02-zjstatus-check.md b/kitty-specs/013-setup-plugin-path-discovery/tasks/WP02-zjstatus-check.md deleted file mode 100644 index 985308d..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP02-zjstatus-check.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -work_package_id: WP02 -title: zjstatus.wasm setup validation -lane: "done" -dependencies: [] -base_branch: main -base_commit: f3978873a0d7303208a59decfd70868fe527042a -created_at: '2026-02-17T09:51:21.273774+00:00' -phase: Wave 1 -assignee: coder -shell_pid: "3856010" -reviewed_by: "kas" -review_status: "approved" ---- - -# WP02 - zjstatus.wasm setup validation - -## Objective - -Add a `check_zjstatus()` validation to `kasmos setup` and launch preflight. This mirrors the existing `check_pane_tracker()` pattern. Currently, generated Zellij layouts reference `file:~/.config/zellij/plugins/zjstatus.wasm` but setup never verifies it exists -- leading to cryptic Zellij plugin errors at launch time. - -## Context - -In `crates/kasmos/src/layout.rs` line 258-260, every generated layout includes: - -```rust -plugin.entries_mut().push(kdl_str_prop( - "location", - "file:~/.config/zellij/plugins/zjstatus.wasm", -)); -``` - -The existing `check_pane_tracker()` function at `setup/mod.rs:180-223` is the exact pattern to follow. It uses `zellij_plugin_dir()` to resolve the plugin directory. - -## Detailed Steps - -### 1. Add `check_zjstatus()` function - -In `crates/kasmos/src/setup/mod.rs`, add after `check_pane_tracker()`: - -```rust -fn check_zjstatus() -> CheckResult { - let required_for = "status bar in Zellij layouts (zjstatus plugin)"; - let plugin_dir = zellij_plugin_dir(); - let plugin_path = plugin_dir.join("zjstatus.wasm"); - - if !plugin_path.is_file() { - return CheckResult { - name: "zjstatus".to_string(), - required_for: required_for.to_string(), - description: "zjstatus.wasm not found".to_string(), - status: CheckStatus::Fail, - guidance: Some(format!( - "Install the zjstatus plugin:\n\ - \x20 Download from https://github.com/dj95/zjstatus/releases\n\ - \x20 mkdir -p {dir} && cp zjstatus.wasm {dir}/", - dir = plugin_dir.display() - )), - }; - } - - CheckResult { - name: "zjstatus".to_string(), - required_for: required_for.to_string(), - description: plugin_path.display().to_string(), - status: CheckStatus::Pass, - guidance: None, - } -} -``` - -### 2. Add to the checks vector - -In `validate_environment_with_repo()` (around line 126-158), add `check_zjstatus()` to the checks vector, right after `check_pane_tracker()`: - -```rust -let mut checks = vec![ - check_binary(/* zellij */), - check_binary(/* opencode */), - check_binary(/* spec-kitty */), - check_pane_tracker(), - check_zjstatus(), // <-- NEW -]; -``` - -This automatically makes it part of launch preflight too, since `launch/mod.rs` calls `validate_environment()`. - -### 3. Update the test `setup_passes_when_dependencies_are_present` - -The existing test (around line 910-993) creates fake plugin files. Add a fake `zjstatus.wasm`: - -```rust -// After the existing pane-tracker.wasm creation: -std::fs::write(plugin_dir.join("zjstatus.wasm"), b"fake-wasm") - .expect("write fake zjstatus wasm"); -``` - -And add an assertion: - -```rust -assert!( - result.checks.iter() - .any(|c| c.name == "zjstatus" && c.status == CheckStatus::Pass) -); -``` - -## Files to modify - -- `crates/kasmos/src/setup/mod.rs` - -## Validation - -- `cargo build` succeeds -- `cargo test` passes (including the updated `setup_passes_when_dependencies_are_present` test) -- Running `kasmos setup` shows a `zjstatus` check line -- If `~/.config/zellij/plugins/zjstatus.wasm` doesn't exist, shows `[FAIL]` with install URL -- If it exists, shows `[PASS]` - -## Activity Log -- 2026-02-17T09:53:47Z – unknown – shell_pid=3856010 – lane=done – Code already on main - all tests pass diff --git a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP03-documentation.md b/kitty-specs/013-setup-plugin-path-discovery/tasks/WP03-documentation.md deleted file mode 100644 index c324434..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP03-documentation.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -work_package_id: WP03 -title: Dependency documentation -lane: "done" -dependencies: [] -base_branch: main -base_commit: abeda7b5876b81200f3f41d7158844665fae79ea -created_at: '2026-02-17T09:51:21.993340+00:00' -phase: Wave 1 -assignee: coder -shell_pid: "3856130" -reviewed_by: "kas" -review_status: "approved" ---- - -# WP03 - Dependency documentation - -## Objective - -Document all external runtime dependencies in README.md and update INTEGRATIONS.md with the zjstatus plugin entry. Users should be able to read the README and know exactly what needs to be installed before running `kasmos setup`. - -## Detailed Steps - -### 1. Add Dependencies section to README.md - -Insert a "Dependencies" section after the "Architecture" section (before "Legacy TUI Feature Gate"). Use a table format: - -```markdown -## Dependencies - -kasmos requires these external tools at runtime. Run `kasmos setup` to validate. - -### Required binaries - -| Tool | Purpose | Install | -|------|---------|---------| -| `zellij` | Terminal multiplexer hosting all sessions/panes | [zellij.dev](https://zellij.dev/documentation/installation) | -| `ocx` / OpenCode | AI agent launcher | Project docs | -| `spec-kitty` | Feature/task lifecycle management | `pip install spec-kitty` | -| `git` | Repository and worktree management | System package manager | -| `bun` | Runs the pane-tracker MCP server | [bun.sh](https://bun.sh) | - -### Required Zellij plugins - -These WASM plugins must be in `~/.config/zellij/plugins/`: - -| Plugin | Purpose | Source | -|--------|---------|--------| -| `zjstatus.wasm` | Status bar in generated layouts | [github.com/dj95/zjstatus](https://github.com/dj95/zjstatus/releases) | -| `zellij-pane-tracker.wasm` | Pane metadata tracking for agent coordination | [github.com/theslyprofessor/zellij-pane-tracker](https://github.com/theslyprofessor/zellij-pane-tracker) | - -### Required companion projects - -| Project | Purpose | Default location | -|---------|---------|-----------------| -| `zellij-pane-tracker` (repo checkout) | MCP server for inter-agent pane communication | Configurable via `kasmos.toml` `[paths].pane_tracker_dir` | - -> `kasmos setup` auto-detects the pane-tracker installation directory -> and writes it into `.opencode/opencode.jsonc`. Override with -> `[paths].pane_tracker_dir` in `kasmos.toml` or `KASMOS_PATHS_PANE_TRACKER_DIR` env var. - -### Optional Zellij plugins - -| Plugin | Purpose | Source | -|--------|---------|--------| -| `zjstatus-hints` | Keybinding hints piped into zjstatus bar | Loaded globally in `config.kdl` | -| `zjframes` | Pane frame toggling | Loaded globally in `config.kdl` | -``` - -### 2. Add zjstatus entry to INTEGRATIONS.md - -In `.planning/codebase/INTEGRATIONS.md`, add a new section after the "pane-tracker / zellij-pane-tracker" entry (around line 75): - -```markdown -**zjstatus (Zellij Status Bar Plugin):** -- Purpose: Renders the status bar in all kasmos-generated Zellij layouts -- Plugin file: `~/.config/zellij/plugins/zjstatus.wasm` -- Source: https://github.com/dj95/zjstatus -- Integration: Hardcoded in `crates/kasmos/src/layout.rs` (`build_tab_template()`) -- Setup validation: `check_zjstatus()` in `crates/kasmos/src/setup/mod.rs` -- Configuration: Rose Pine Moon theme with zjstatus-hints pipe integration -- Features used: mode indicators, tab styles, datetime, pipe format (zjstatus_hints) -``` - -### 3. Update quickstart.md prerequisites - -In `kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md`, update the Prerequisites section to include zjstatus: - -```markdown -## Prerequisites - -- Rust stable toolchain (2024 edition support) -- `zellij` in `PATH` -- `opencode` in `PATH` -- `pane-tracker` (or `zellij-pane-tracker`) in `PATH` -- `zjstatus.wasm` in `~/.config/zellij/plugins/` -- `zellij-pane-tracker.wasm` in `~/.config/zellij/plugins/` -- `bun` in `PATH` (runs pane-tracker MCP server) -``` - -## Files to modify - -- `README.md` -- `.planning/codebase/INTEGRATIONS.md` -- `kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md` - -## Validation - -- README has a Dependencies section with tables for binaries, plugins, and companion projects -- INTEGRATIONS.md has a zjstatus entry with all fields matching the pane-tracker entry format -- quickstart.md lists zjstatus and bun in prerequisites -- No encoding issues (UTF-8 only, no smart quotes) - -## Activity Log -- 2026-02-17T09:53:48Z – unknown – shell_pid=3856130 – lane=done – Code already on main - all tests pass diff --git a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP04-tests.md b/kitty-specs/013-setup-plugin-path-discovery/tasks/WP04-tests.md deleted file mode 100644 index ba350aa..0000000 --- a/kitty-specs/013-setup-plugin-path-discovery/tasks/WP04-tests.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -work_package_id: WP04 -title: Tests for plugin path discovery and zjstatus check -lane: "done" -dependencies: [WP01, WP01b, WP02] -phase: "Wave 3" -assignee: "coder" -reviewed_by: "kas" -review_status: "approved" ---- - -# WP04 - Tests for plugin path discovery and zjstatus check - -## Objective - -Add comprehensive tests for all new functionality introduced in WP01, WP01b, and WP02. Update existing tests that may be affected by the new zjstatus check and config field. - -## Context - -The test module in `crates/kasmos/src/setup/mod.rs` (starting at line 902) already has: - -- `setup_passes_when_dependencies_are_present` -- creates fake binaries and plugins -- `setup_fails_when_dependency_is_missing` -- `setup_generates_assets_idempotently` -- `install_opencode_agents_creates_missing_roles` -- `check_opencode_agents_reports_missing` -- `launch_preflight_uses_setup_validation_engine` - -The test module in `crates/kasmos/src/config.rs` (starting at line 653) has: - -- `default_config_validates` -- `partial_toml_loads_with_defaults` -- `invalid_values_produce_clear_errors` -- `env_overrides_take_precedence` - -## Detailed Steps - -### 1. Config tests (`config.rs`) - -Add to the existing test module: - -```rust -#[test] -fn pane_tracker_dir_default() { - let config = Config::default(); - assert_eq!(config.paths.pane_tracker_dir, "/opt/zellij-pane-tracker"); -} - -#[test] -fn pane_tracker_dir_from_toml() { - let tmp = tempfile::tempdir().expect("create tempdir"); - let path = tmp.path().join("kasmos.toml"); - std::fs::write( - &path, - r#" -[paths] -pane_tracker_dir = "/home/user/zellij-pane-tracker" -"#, - ) - .expect("write toml"); - - let mut config = Config::default(); - config.load_from_file(&path).expect("load toml"); - assert_eq!( - config.paths.pane_tracker_dir, - "/home/user/zellij-pane-tracker" - ); -} - -#[test] -fn pane_tracker_dir_from_env() { - let _guard = ENV_TEST_LOCK.lock().expect("env lock"); - - let mut config = Config::default(); - unsafe { - std::env::set_var("KASMOS_PATHS_PANE_TRACKER_DIR", "/custom/tracker"); - } - config.load_from_env().expect("load env"); - unsafe { - std::env::remove_var("KASMOS_PATHS_PANE_TRACKER_DIR"); - } - - assert_eq!(config.paths.pane_tracker_dir, "/custom/tracker"); -} -``` - -### 2. zjstatus check tests (`setup/mod.rs`) - -Add to the existing test module: - -```rust -#[test] -fn zjstatus_check_passes_when_present() { - let _guard = ENV_LOCK.lock().expect("env lock"); - let old_zellij_config = std::env::var("ZELLIJ_CONFIG_DIR").ok(); - - let tmp = tempfile::tempdir().expect("tempdir"); - let plugin_dir = tmp.path().join("plugins"); - std::fs::create_dir_all(&plugin_dir).expect("create plugin dir"); - std::fs::write(plugin_dir.join("zjstatus.wasm"), b"fake-wasm") - .expect("write fake zjstatus"); - - unsafe { - std::env::set_var("ZELLIJ_CONFIG_DIR", tmp.path().display().to_string()); - } - - let check = check_zjstatus(); - assert_eq!(check.status, CheckStatus::Pass); - assert_eq!(check.name, "zjstatus"); - - unsafe { - if let Some(dir) = old_zellij_config { - std::env::set_var("ZELLIJ_CONFIG_DIR", dir); - } else { - std::env::remove_var("ZELLIJ_CONFIG_DIR"); - } - } -} - -#[test] -fn zjstatus_check_fails_when_missing() { - let _guard = ENV_LOCK.lock().expect("env lock"); - let old_zellij_config = std::env::var("ZELLIJ_CONFIG_DIR").ok(); - - let tmp = tempfile::tempdir().expect("tempdir"); - // No plugins directory, no zjstatus.wasm - - unsafe { - std::env::set_var("ZELLIJ_CONFIG_DIR", tmp.path().display().to_string()); - } - - let check = check_zjstatus(); - assert_eq!(check.status, CheckStatus::Fail); - assert!(check.guidance.is_some()); - assert!(check.guidance.unwrap().contains("zjstatus")); - - unsafe { - if let Some(dir) = old_zellij_config { - std::env::set_var("ZELLIJ_CONFIG_DIR", dir); - } else { - std::env::remove_var("ZELLIJ_CONFIG_DIR"); - } - } -} -``` - -### 3. MCP path fixup test (`setup/mod.rs`) - -```rust -#[test] -fn fixup_mcp_pane_tracker_path_replaces_opt() { - let json_str = r#"{ - "mcp": { - "zellij": { - "type": "local", - "command": ["bun", "run", "/opt/zellij-pane-tracker/mcp-server/index.ts"], - "enabled": true - } - } - }"#; - - let mut config: serde_json::Value = - serde_json::from_str(json_str).expect("parse json"); - - fixup_mcp_pane_tracker_path(&mut config, "/home/user/zellij-pane-tracker"); - - let command = config["mcp"]["zellij"]["command"] - .as_array() - .expect("command array"); - assert_eq!(command[2].as_str().unwrap(), - "/home/user/zellij-pane-tracker/mcp-server/index.ts"); -} - -#[test] -fn fixup_mcp_pane_tracker_path_noop_when_no_mcp_section() { - let mut config: serde_json::Value = - serde_json::from_str(r#"{"agent": {}}"#).expect("parse json"); - - // Should not panic - fixup_mcp_pane_tracker_path(&mut config, "/some/path"); -} -``` - -### 4. Pane-tracker auto-detection test (`setup/mod.rs`) - -```rust -#[test] -fn detect_pane_tracker_dir_finds_valid_path() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mcp_dir = tmp.path().join("mcp-server"); - std::fs::create_dir_all(&mcp_dir).expect("create mcp dir"); - std::fs::write(mcp_dir.join("index.ts"), "// mcp server").expect("write index.ts"); - - let mut config = Config::default(); - config.paths.pane_tracker_dir = tmp.path().display().to_string(); - - let detected = detect_pane_tracker_dir(&config); - assert_eq!(detected, tmp.path().display().to_string()); -} - -#[test] -fn detect_pane_tracker_dir_falls_back_to_default() { - let config = Config::default(); - // None of the candidate paths will have mcp-server/index.ts in a test env - let detected = detect_pane_tracker_dir(&config); - // Should fall back to the config default - assert_eq!(detected, config.paths.pane_tracker_dir); -} -``` - -### 5. Update `setup_passes_when_dependencies_are_present` - -The existing test (line 910) needs to also create a fake `zjstatus.wasm`. Add: - -```rust -std::fs::write(plugin_dir.join("zjstatus.wasm"), b"fake-wasm") - .expect("write fake zjstatus wasm"); -``` - -And add the zjstatus assertion: - -```rust -assert!( - result.checks.iter() - .any(|c| c.name == "zjstatus" && c.status == CheckStatus::Pass) -); -``` - -## Files to modify - -- `crates/kasmos/src/setup/mod.rs` (test module) -- `crates/kasmos/src/config.rs` (test module) - -## Validation - -- `cargo test` passes with all new and existing tests -- `cargo clippy -p kasmos -- -D warnings` clean - -## Activity Log -- 2026-02-17T09:53:54Z – unknown – lane=done – Code already on main - all WP04 tests present and passing diff --git a/kitty-specs/014-architecture-pivot-evaluation/plan.md b/kitty-specs/014-architecture-pivot-evaluation/plan.md deleted file mode 100644 index 8929d92..0000000 --- a/kitty-specs/014-architecture-pivot-evaluation/plan.md +++ /dev/null @@ -1,377 +0,0 @@ -# 014 - Architecture Pivot Evaluation: Plan - -> Architecture evaluation with recommendation, PoC scope, and migration roadmap. -> Based on verified research in research.md. -> Produced: 2026-02-17 - -## Executive Summary - -**Recommendation: Option C — Hybrid Bridge Architecture** - -kasmos should keep its MCP server binary and add a purpose-built Zellij plugin as a bridge for pane lifecycle observability, process management, and inter-agent communication. This solves the three highest-severity pain points (blind orchestrator, fragile message parsing, CLI opacity) while preserving all existing MCP infrastructure and requiring the least migration effort. - -Confidence: **High**. The Zellij plugin API has verified coverage for every capability kasmos needs on the Zellij side (R-001 through R-003), and the hybrid approach avoids the showstopper constraint that blocks Option A (WASM plugins cannot host MCP servers — R-006). - ---- - -## Per-Option Evaluation - -### Option A: Zellij WASM Plugin (Full Migration) - -Move all orchestration logic into a Zellij WASM plugin compiled to `wasm32-wasip1`. - -#### Strengths -- **Pane lifecycle**: Native event subscription (PaneUpdate, CommandPaneOpened/Exited, PaneClosed). No polling, no stale registry. [R-001] -- **Process management**: open_command_pane variants with context dicts, close_terminal_pane by ID, rerun_command_pane. [R-002] -- **Inter-agent comms**: Pipes for structured plugin↔plugin and CLI↔plugin messaging with backpressure. [R-003] -- **Deployment**: Single .wasm file in `~/.config/zellij/plugins/`. No cargo install, no PATH setup. -- **Layout control**: new_tabs_with_layout accepts stringified KDL. dump_session_layout serializes current state. [R-002] - -#### Showstoppers -- **Cannot host MCP server** (R-006): WASM plugins have no TCP/stdio server capability. kasmos's 9 MCP tools served via rmcp cannot run in WASM. AI agents connect to MCP servers via stdio — a plugin cannot expose this interface. This is a **hard blocker**. -- **No tokio/async**: All async must go through Zellij workers (message passing). The current MCP server uses tokio extensively. [R-004] -- **Migration cost**: 12,325 LOC of Rust must be rewritten for wasm32-wasip1 target. Key crates (rmcp, tokio) do not compile to WASM. [R-009] -- **Filesystem limitations**: Mapped paths (/host, /data, /tmp), slow scanning, no native path access without FullHdAccess. [R-005] - -#### Verdict -**Eliminated.** The MCP server incompatibility is a hard blocker. Without MCP tools, AI agents cannot interact with kasmos. The migration cost (complete rewrite) compounds this into a non-viable option. - ---- - -### Option B: OpenCode Extension / SDK Integration - -Embed orchestration logic into the AI agent runtime via OpenCode's plugin system, custom agents, and SDK. - -OpenCode (anomalyco/opencode) is a 106k-star TypeScript project with a rich extension model — plugins, custom agents, SDK, and headless server mode. This is NOT the archived Go project (opencode-ai/opencode, now Crush). [R-008] - -#### Sub-Options (per EC-002) - -- **B1: OpenCode Plugin** — Write a kasmos-orchestrator plugin that hooks into session events, registers custom tools, and coordinates agents within a single OpenCode instance. -- **B2: SDK-Driven Multi-Instance** — kasmos remains a separate Rust binary but drives multiple `opencode serve` instances via `@opencode-ai/sdk`, one per worker pane. -- **B3: Fork** — Fork the OpenCode TypeScript codebase and embed orchestration directly. MIT license, no restrictions, but 9,380 commits / 757 contributors. - -#### Strengths -- **Rich plugin system** (R-008): JS/TS plugins with event hooks (session.idle, tool.execute.before/after, file.edited, etc.) and custom tool registration. -- **Custom agents** (R-008): Can define planner, coder, reviewer, release as OpenCode agents with per-role prompts, tools, permissions, and model overrides. -- **SDK for programmatic control** (R-008): Type-safe client can create sessions, send prompts, subscribe to events (SSE), manage files. -- **Server mode** (R-008): `opencode serve` runs headless with HTTP API. Multiple instances can run in parallel in separate panes. -- **MIT license**: No restrictions on forking or distribution. -- **Native filesystem access**: No WASM constraints. - -#### Weaknesses -- **Does NOT solve Zellij pane management**: OpenCode manages its own TUI. It does NOT create, monitor, or manage Zellij terminal panes. The blind orchestrator problem persists — kasmos still needs to create/observe/close Zellij panes. This is the root cause and Option B alone doesn't fix it. -- **Language mismatch**: kasmos is Rust; OpenCode plugins are TypeScript. B1/B2 require maintaining two languages. B3 means abandoning Rust. -- **Subagent model ≠ pane model**: OpenCode subagents run within a single process. kasmos needs agents in separate Zellij panes for visual isolation, independent crash domains, and manual intervention. -- **Resource overhead** (B2): Each worker pane running a full `opencode serve` is heavier than a simple `opencode run` command. -- **Fork maintenance** (B3): 9,380 commits, 757 contributors, very active development. Unsustainable fork. - -#### Verdict -**Partially viable but doesn't solve the core problem.** OpenCode's extension model could significantly improve agent coordination (custom agents, SDK-driven sessions, plugin hooks), but it does NOT address Zellij pane lifecycle opacity. The best sub-option is **B2 combined with Option C** — use the Zellij bridge plugin for pane management and OpenCode's SDK for richer agent control. This is noted as a future enhancement in the roadmap. - ---- - -### Option C: Hybrid Bridge (kasmos binary + Zellij plugin) - -Keep kasmos as the MCP server binary. Add a Zellij plugin that acts as a bridge, providing pane introspection and lifecycle events that kasmos currently lacks. Communication between kasmos and the plugin via pipes and/or `zellij pipe` CLI. - -#### Strengths -- **Pane lifecycle**: Plugin subscribes to PaneUpdate, CommandPaneOpened/Exited/ReRun, PaneClosed events and forwards them to kasmos. [R-001] -- **MCP server preserved**: kasmos keeps its rmcp MCP server with all 9 tools. No migration needed for agent-facing interface. [R-006 avoided] -- **Code reuse**: 90%+ of existing kasmos code remains. Plugin is a new ~500-1500 LOC WASM component. [R-009] -- **Process management**: Plugin can open_command_pane, close_terminal_pane, rename_terminal_pane, focus_terminal_pane by ID. kasmos tells the plugin what to do via pipes. [R-002, R-003] -- **Inter-agent comms**: Two-channel approach — MCP for agent↔kasmos (structured tools), pipes for kasmos↔plugin (pane operations). Replaces fragile message-log pane parsing. -- **Incremental migration**: Can be adopted module-by-module. Start with pane lifecycle, then add process management, then replace message-log parsing. Current architecture continues to work during migration. -- **Reversible**: If the plugin approach fails, remove the plugin and fall back to current CLI-based approach. No bridges burned. -- **Dependency reduction**: Plugin replaces both zellij-pane-tracker plugin AND bun MCP server. Net reduction in external dependencies. - -#### Risks -- **Bridge protocol design**: Need to define the communication protocol between kasmos binary and the Zellij plugin (message format, pipe naming, error handling). -- **Two artifacts to distribute**: A Rust binary (kasmos) and a WASM binary (plugin). Build pipeline needs to produce both. -- **Plugin API stability**: Zellij plugin API is pre-1.0. Breaking changes are possible. Mitigation: pin Zellij version, abstract plugin API calls behind an interface. -- **Latency**: Pipe-based communication adds latency vs direct function calls. For orchestration operations (spawn worker, check status), this is negligible. - -#### Architecture - -``` -Manager Agent (OpenCode/Claude Code) - │ - ├── MCP stdio ──→ kasmos serve (Rust binary) - │ │ - │ ├── Workflow logic (task transitions, feature detection) - │ ├── Worker registry (now backed by plugin events) - │ ├── Config, audit, lock management - │ └── zellij pipe ──→ kasmos-bridge plugin (WASM) - │ │ - │ ├── PaneUpdate subscription - │ ├── CommandPaneOpened/Exited events - │ ├── open_command_pane / close_terminal_pane - │ ├── rename_terminal_pane / focus_terminal_pane - │ └── write_to_pane_id (agent STDIN) - │ -Worker Agents (in Zellij panes) -``` - -#### Verdict -**Recommended.** Solves the core problems, preserves existing investment, incremental and reversible. - ---- - -### Option D: Status Quo with Targeted Fixes - -Accept the current architecture and address pain points incrementally. - -#### Per-Pain-Point Assessment - -| Pain Point | Potential Fix | Effort | Effectiveness | -|-----------|--------------|--------|--------------| -| 1. Zellij CLI opacity / stale registry | Periodic polling via `zellij action dump-layout`, parse KDL output | Medium | Low — polling is unreliable, dump-layout may not include process state | -| 2. Process boundary friction (3 processes) | No fix possible without architectural change | N/A | None — this is structural | -| 3. KDL layout generation brittleness | Template-based KDL instead of programmatic generation | Low | Medium — reduces code but doesn't eliminate KDL v1/v2 issues | -| 4. Worktree path confusion | Centralize path resolution into a `PathResolver` type | Low | Medium — reduces bugs but doesn't eliminate the problem | -| 5. External dependency chain | Bundle zellij-pane-tracker and zjstatus configs, automate setup | Low | Low — still 7+ dependencies | -| 6. Fragile message-log pane parsing | Structured JSON messages with schema validation | Medium | Medium — more robust but still indirect channel | - -#### Strengths -- **Zero migration cost**: Continue with existing code. -- **MCP server works**: No changes to agent interface. -- **Full filesystem access**: No WASM constraints. - -#### Weaknesses -- **Blind orchestrator remains**: No fix can provide real-time pane lifecycle events without a plugin. -- **Process boundary remains**: Three-process architecture cannot be simplified without a bridge. -- **Diminishing returns**: Each targeted fix is isolated. They don't compound into a fundamentally better architecture. -- **Technical debt accumulates**: Workarounds on top of workarounds increase maintenance burden over time. - -#### Verdict -**Viable but insufficient.** Appropriate if the hybrid approach proves too complex in the PoC phase, but does not solve the root cause (external process trying to orchestrate an uncooperative terminal multiplexer). - ---- - -## Comparison Matrix - -### Scoring: 1 (poor) to 5 (excellent) - -| Dimension | Weight | A: WASM Plugin | B: OC Extension | C: Hybrid Bridge | D: Status Quo | -|-----------|--------|----------------|-----------------|-------------------|---------------| -| ED-001: Pane Lifecycle Observability | 5 | 5 | 2 | 5 | 1 | -| ED-002: Inter-Agent Communication | 4 | 4 | 4 | 4 | 2 | -| ED-003: Process Spawning & Management | 4 | 5 | 3 | 4 | 2 | -| ED-004: Filesystem & State Access | 3 | 2 | 5 | 5 | 5 | -| ED-005: MCP Server Compatibility | 5 | 1 | 4 | 5 | 5 | -| ED-006: Development Velocity | 4 | 1 | 2 | 4 | 5 | -| ED-007: User Experience | 3 | 4 | 3 | 4 | 2 | -| ED-008: Extensibility | 2 | 3 | 5 | 4 | 3 | -| **Weighted Total** | | **90** | **95** | **131** | **91** | - -### Weight Rationale - -- **5 (critical)**: ED-001 and ED-005 — pane lifecycle is the root cause problem; MCP compatibility is non-negotiable for agent integration. -- **4 (high)**: ED-002, ED-003, ED-006 — inter-agent comms and process management are daily friction; dev velocity determines whether the migration is practical. -- **3 (moderate)**: ED-004, ED-007 — filesystem access is a concern but solvable; UX matters but is secondary to functionality. -- **2 (low)**: ED-008 — extensibility is forward-looking, not a current pain point. - -### Score Justification - -**Option A scores:** -- ED-001 (5): Native event subscription, complete lifecycle coverage. -- ED-005 (1): Cannot host MCP server. Hard blocker. -- ED-006 (1): 12,325 LOC rewrite to WASM target. Key deps (rmcp, tokio) incompatible. - -**Option B scores:** -- ED-001 (2): OpenCode doesn't manage Zellij panes. Same opacity problem persists. -- ED-002 (4): SDK + events (SSE) + plugin hooks enable rich structured communication between agents. Scores well here. -- ED-005 (4): OpenCode natively consumes MCP servers. kasmos MCP could be exposed to OpenCode agents. Not a perfect 5 because the current rmcp stdio transport would need adaptation for SDK-driven flow. -- ED-006 (2): Plugin/SDK work is TypeScript alongside Rust kasmos. Two-language maintenance. Better than forking but still costly. -- ED-008 (5): OpenCode's plugin system, custom agents, custom tools, and SDK make it the most extensible option by far. - -**Option C scores:** -- ED-001 (5): Plugin provides same events as Option A. -- ED-005 (5): MCP server preserved as-is. -- ED-006 (4): ~90% code reuse, plugin is new but small (~500-1500 LOC). -- ED-007 (4): Replaces 2 external deps (pane-tracker + bun) with 1 WASM file. - -**Option D scores:** -- ED-001 (1): No real fix possible without plugin. Polling is unreliable. -- ED-006 (5): Zero migration cost — highest velocity for existing code. -- ED-007 (2): All current UX issues remain. - ---- - -## Recommendation - -### Primary: Option C — Hybrid Bridge Architecture - -**Rationale:** -1. Scores highest in weighted comparison (131 vs next-best 91). -2. Solves the root cause (pane lifecycle opacity) with verified API support. -3. Preserves the most valuable existing investment (MCP server, workflow logic, config system). -4. Incremental migration — each module can be migrated independently. -5. Fully reversible — remove plugin and fall back to current approach. -6. Net dependency reduction — replaces pane-tracker plugin + bun runtime with single WASM file. - -**Dissenting considerations:** -- Option D (status quo) is tempting for its zero migration cost. However, the "blind orchestrator" problem is fundamental and worsens as complexity grows. We experienced it firsthand in this session — `spawn_worker` registered a worker but the Zellij pane was never created, with no error feedback. This class of bug is **unsolvable** without pane lifecycle events. -- Option B (OpenCode extension) scores well on extensibility and communication, and the SDK-driven approach (B2) is genuinely compelling. However, it doesn't solve the root cause (Zellij pane opacity). The ideal long-term architecture is **C + B2**: Zellij bridge plugin for pane management, OpenCode SDK for agent control. Phase 5 in the roadmap could explore this. -- Option A (full plugin) would be ideal architecturally but is blocked by MCP server incompatibility. If Zellij ever supports WASI networking, this option should be revisited. - -**Conditions to revisit:** -1. If Zellij plugin API adds WASI socket support → reconsider Option A. -2. If the PoC bridge protocol proves too complex or latency-sensitive → fall back to Option D with targeted fixes. -3. After Option C is stable, consider **Phase 5: OpenCode SDK integration** (B2) to replace CLI-based agent spawning with programmatic SDK-driven sessions. This would give kasmos richer control over agent behavior without changing the Zellij bridge layer. - ---- - -## PoC Scope Definition - -### Goal -Validate that a Zellij WASM plugin can reliably bridge pane lifecycle events to the kasmos binary via pipes, and that the kasmos binary can request pane operations from the plugin. - -### Highest-Risk Integration Point -**Pipe-based bidirectional communication between kasmos binary and Zellij plugin.** This is the novel component — everything else (plugin events, pane commands, MCP server) uses verified APIs. - -### PoC Work Packages - -#### WP01: kasmos-bridge plugin (WASM) - -**Scope**: Minimal Zellij plugin that: -1. Subscribes to `PaneUpdate`, `CommandPaneOpened`, `CommandPaneExited`, `PaneClosed` events. -2. On each event, serializes pane state to JSON and sends via `cli_pipe_output` or writes to `/data/kasmos-events.jsonl`. -3. Implements `pipe` lifecycle method to receive commands from kasmos (e.g., "open pane with command X", "close pane Y", "rename pane Z"). -4. Executes received commands via plugin API (open_command_pane, close_terminal_pane, etc.). -5. Requests permissions: `ReadApplicationState`, `ChangeApplicationState`, `RunCommands`, `OpenTerminalsOrPlugins`, `WriteToStdin`, `ReadCliPipes`. - -**Deliverables**: `kasmos-bridge.wasm` compiled from Rust using `zellij-tile` crate. - -**Success criteria**: -- Plugin loads in Zellij without errors. -- Pane events are captured and serialized. -- `zellij pipe --plugin kasmos-bridge -- '{"cmd":"open_pane","command":"echo hello"}'` creates a pane. -- Pane close event is captured when user closes the pane. - -**Estimated effort**: 2-3 days. - -#### WP02: kasmos integration layer - -**Scope**: Add to the kasmos MCP server: -1. A `PluginBridge` module that communicates with kasmos-bridge plugin via `zellij pipe`. -2. Modify `spawn_worker` to route pane creation through the plugin bridge instead of `zellij action new-pane`. -3. Modify `WorkerRegistry` to reconcile state from plugin-reported pane events (instead of trusting in-memory state only). -4. Event polling: periodically read `/data/kasmos-events.jsonl` or receive events via pipe callback. - -**Deliverables**: Updated kasmos serve with plugin bridge integration. - -**Success criteria**: -- `spawn_worker` MCP tool creates a pane via the plugin (not via `zellij action`). -- If a pane is closed externally, the WorkerRegistry reflects this within 2 seconds. -- `list_workers` reports accurate pane status based on plugin events. -- All existing MCP tools continue to work. - -**Estimated effort**: 2-3 days. - -### Failure Criteria -- Pipe communication is unreliable (messages lost, out of order, or >500ms latency). -- Plugin event subscription doesn't capture all pane lifecycle events. -- Plugin permissions model prevents necessary operations. -- Build/distribution complexity (two artifacts) is unmanageable. - -### Reversibility -The PoC is fully reversible: -- The plugin is an additional artifact, not a replacement. -- kasmos spawn_worker can have a config flag to use plugin bridge vs direct CLI. -- If PoC fails, remove the plugin and config flag. Zero impact on existing functionality. - ---- - -## Migration Roadmap - -### Phase 0: PoC (WP01 + WP02) -**Duration**: 1 week -**Goal**: Validate bridge architecture. -**Modules affected**: New plugin crate, modified spawn_worker and registry in kasmos. -**Reversible**: Yes — config flag toggles between plugin bridge and CLI mode. - -### Phase 1: Pane Lifecycle Migration -**Duration**: 1 week -**Goal**: Replace in-memory-only WorkerRegistry with plugin-event-backed registry. -**Modules affected**: -| Current module | Change | -|---------------|--------| -| `serve/registry.rs` | Add event reconciliation from plugin pane events | -| `serve/mod.rs` (spawn_worker) | Route through plugin bridge | -| `serve/mod.rs` (despawn_worker) | Use plugin's close_terminal_pane | -| `serve/mod.rs` (list_workers) | Reconcile with plugin state | - -**Reusable code**: All workflow logic, config, audit, lock management, prompt building. (~80% of codebase) -**Rewrite required**: WorkerRegistry reconciliation (~200 LOC), spawn/despawn routing (~150 LOC). - -### Phase 2: Message Channel Migration -**Duration**: 1 week -**Goal**: Replace message-log pane parsing with pipe-based structured communication. -**Modules affected**: -| Current module | Change | -|---------------|--------| -| `serve/mod.rs` (read_messages) | Read from plugin event stream instead of pane scrollback | -| `serve/mod.rs` (wait_for_event) | Wait on plugin pipe events instead of pane polling | -| Message-log pane | Becomes optional debug/audit view, not a communication channel | - -**Dependency eliminated**: zellij-pane-tracker plugin, bun runtime. - -### Phase 3: Layout Simplification -**Duration**: 3 days -**Goal**: Simplify KDL layout generation using plugin's `new_tabs_with_layout`. -**Modules affected**: -| Current module | Change | -|---------------|--------| -| `launch/layout.rs` | Simplify or replace with template-based approach. Plugin can apply layouts via command. | -| KDL v1/v2 workarounds | Eliminated — plugin's new_tabs_with_layout handles serialization. | - -### Phase 4: Cleanup -**Duration**: 2 days -**Goal**: Remove deprecated code paths, update documentation, update AGENTS.md. -**Modules affected**: -- Remove CLI-based pane management fallback (or keep as --no-plugin mode). -- Remove bun/pane-tracker from `kasmos setup` validation. -- Update architecture.md with new system overview. -- Update constitution.md if needed. - -### Total Effort Estimate - -| Phase | Effort | Cumulative | -|-------|--------|------------| -| Phase 0 (PoC) | 1 week | 1 week | -| Phase 1 (Pane lifecycle) | 1 week | 2 weeks | -| Phase 2 (Message channels) | 1 week | 3 weeks | -| Phase 3 (Layout simplification) | 3 days | ~3.5 weeks | -| Phase 4 (Cleanup) | 2 days | ~4 weeks | - -### Module Reuse Map - -| Current module | Fate in hybrid architecture | -|---------------|----------------------------| -| `config.rs` | **Reuse** — add `[plugin]` config section | -| `serve/mod.rs` (MCP server) | **Reuse** — core MCP server unchanged | -| `serve/registry.rs` | **Modify** — add plugin event reconciliation | -| `serve/lock.rs` | **Reuse** — feature lock unchanged | -| `serve/audit.rs` | **Reuse** — audit writer unchanged | -| `launch/detect.rs` | **Reuse** — feature detection unchanged | -| `launch/session.rs` | **Modify** — integrate plugin loading into session setup | -| `launch/layout.rs` | **Simplify** — reduce KDL generation, delegate to plugin | -| `prompt.rs` | **Reuse** — prompt building unchanged | -| `types.rs` | **Reuse** — core types unchanged | -| `main.rs` / `cli.rs` | **Reuse** — CLI unchanged | -| `setup.rs` | **Modify** — validate plugin .wasm instead of pane-tracker/bun | - -**Summary**: ~75% reuse, ~20% modify, ~5% new (plugin crate). - ---- - -## Risk Register - -| Risk | Severity | Likelihood | Mitigation | -|------|----------|-----------|------------| -| Zellij plugin API breaking changes (pre-1.0) | High | Medium | Pin Zellij version in kasmos setup validation. Abstract plugin calls behind trait. | -| Pipe communication unreliability | High | Low | PoC validates this first. Fallback to file-based event passing (/data/). | -| WASM build complexity (two artifacts) | Medium | Medium | Single `cargo build` command builds both. Distribute plugin .wasm alongside binary. | -| Plugin permission prompts annoy users | Low | High | Plugins in layout config auto-approve. Document required permissions. | -| Filesystem access limitations in plugin | Medium | Low | Plugin uses run_command for file ops that need full paths. Most file ops stay in kasmos binary. | -| Plugin state loss on reload | Medium | Low | Plugin state is transient (pane tracking). kasmos binary is the source of truth for persistent state. | diff --git a/kitty-specs/014-architecture-pivot-evaluation/research.md b/kitty-specs/014-architecture-pivot-evaluation/research.md deleted file mode 100644 index de9643d..0000000 --- a/kitty-specs/014-architecture-pivot-evaluation/research.md +++ /dev/null @@ -1,232 +0,0 @@ -# 014 - Architecture Pivot Evaluation: Research - -> Verified technical findings for architecture evaluation. -> All claims cite documentation sources. -> Researched: 2026-02-17 - -## R-001: Zellij Plugin API — Pane Lifecycle Events - -**Source**: https://zellij.dev/documentation/plugin-api-events - -The plugin API provides **event-driven** pane lifecycle observability via subscription: - -| Event | What it provides | Permission | -|-------|-----------------|------------| -| `PaneUpdate` | Info on ALL active panes: title, command, exit code | `ReadApplicationState` | -| `CommandPaneOpened` | Terminal pane ID + context dict when a command pane opens | `ReadApplicationState` | -| `CommandPaneExited` | Pane ID + exit code when command inside pane exits (pane stays open) | `ReadApplicationState` | -| `CommandPaneReRun` | Pane ID + exit code when user re-runs command (e.g., presses Enter) | `ReadApplicationState` | -| `PaneClosed` | Pane ID when any pane in the session is closed | `ReadApplicationState` | -| `EditPaneOpened` / `EditPaneExited` | Editor pane lifecycle | `ReadApplicationState` | -| `TabUpdate` | All tab info (name, position, pane counts, swap layout info) | `ReadApplicationState` | -| `SessionUpdate` | Active sessions of current version on the machine | `ReadApplicationState` | -| `ListClients` | Connected clients, their focused pane, running command/plugin | `ReadApplicationState` | -| `FileSystemCreate/Read/Update/Delete` | File change notifications in Zellij's CWD | None | - -**Key finding**: `PaneUpdate` + `CommandPaneOpened` + `CommandPaneExited` + `PaneClosed` together provide **complete pane lifecycle observability** — exactly what kasmos lacks. No polling needed; these are push events delivered to the plugin's `update()` method. - -**Key finding**: `CommandPaneExited` fires when the command inside a pane exits but the pane remains open. This means a plugin can detect when an agent process finishes without the pane disappearing. The event includes the numeric exit code. - -## R-002: Zellij Plugin API — Pane Management Commands - -**Source**: https://zellij.dev/documentation/plugin-api-commands - -Comprehensive pane management via plugin commands: - -| Command | What it does | Permission | -|---------|-------------|------------| -| `open_command_pane` | Open command pane (tiled) | `RunCommands` | -| `open_command_pane_floating` | Open command pane (floating) | `RunCommands` | -| `open_command_pane_near_plugin` | Open command pane in plugin's tab (tiled) | `RunCommands` | -| `open_command_pane_floating_near_plugin` | Open command pane in plugin's tab (floating) | `RunCommands` | -| `open_command_pane_in_place` | Replace focused pane with command pane | `RunCommands` | -| `open_command_pane_background` | Open hidden/background command pane | `RunCommands` | -| `rerun_command_pane` | Re-run command in existing pane | `RunCommands` | -| `close_terminal_pane` | Close terminal pane by ID | `ChangeApplicationState` | -| `close_plugin_pane` | Close plugin pane by ID | `ChangeApplicationState` | -| `close_multiple_panes` | Close multiple panes at once | `ChangeApplicationState` | -| `focus_terminal_pane` | Focus pane by ID (switches tab/layer) | `ChangeApplicationState` | -| `rename_terminal_pane` | Rename pane UI title by ID | `ChangeApplicationState` | -| `hide_pane_with_id` / `show_pane_with_id` | Suppress/unsuppress panes | `ChangeApplicationState` | -| `open_terminal` + variants | Open terminal panes (no command) | `OpenTerminalsOrPlugins` | -| `run_command` | Run command in background (result via event) | `RunCommands` | -| `write_to_pane_id` / `write_chars_to_pane_id` | Write to specific pane's STDIN | `WriteToStdin` | - -**Key finding**: `open_command_pane_near_plugin` + `close_terminal_pane` + `rename_terminal_pane` + `focus_terminal_pane` provide everything kasmos needs for worker pane management — and they work by pane ID, not by name or focus state. - -**Key finding**: `new_tabs_with_layout` accepts a **stringified KDL layout** and applies it to the session. This means layout generation could remain in Rust (compiled into the plugin) or be passed via pipe. `dump_session_layout` serializes the current layout back to KDL. - -**Key finding**: `run_command` runs a command in the background on the host machine and returns results via `RunCommandResult` event. This enables non-pane command execution (e.g., git operations, file checks). - -## R-003: Zellij Plugin API — Inter-Plugin Communication (Pipes) - -**Source**: https://zellij.dev/documentation/plugin-pipes - -Pipes are unidirectional communication channels to/from plugins: - -- **CLI → Plugin**: `zellij pipe` CLI command sends messages to plugins. Supports STDIN streaming with backpressure. -- **Plugin → Plugin**: `pipe_message_to_plugin` command sends messages to other plugins (by URL or internal ID). Target plugin is auto-launched if not running. -- **Plugin → CLI**: `cli_pipe_output` writes to the STDOUT of a CLI pipe. -- **Backpressure**: Plugins can `block_cli_pipe_input` / `unblock_cli_pipe_input` to control flow. -- **Pipe lifecycle method**: Plugins implement `fn pipe(&mut self, pipe_message: PipeMessage) -> bool` to receive messages. - -`PipeMessage` contains: -- `source`: `PipeSource::Cli(input_pipe_id)` or `PipeSource::Plugin(source_plugin_id)` -- `name`: pipe name (user-provided or random UUID) -- `payload`: optional arbitrary string content -- `args`: optional string→string dictionary -- `is_private`: whether directed specifically at this plugin - -**Key finding**: Pipes + `write_to_pane_id` together can replace the message-log pane system. The plugin could receive structured messages via pipes from the manager agent (through `zellij pipe` CLI calls), and could write structured data to agent pane STDIN. - -**Key finding**: `pipe_message_to_plugin` with `zellij:OWN_URL` destination allows a plugin to launch new instances of itself with different configurations — useful for multi-role scenarios. - -## R-004: Zellij Plugin API — Workers for Async Tasks - -**Source**: https://zellij.dev/documentation/plugin-api-workers - -Since WASM/WASI threads are not stable, plugins use workers for async: - -- Workers implement `ZellijWorker` trait with `on_message(message: String, payload: String)`. -- Registered via `register_worker!` macro with a namespace. -- Plugin sends to worker via `post_message_to("worker_namespace", ...)`. -- Worker sends back to plugin via `post_message_to_plugin(...)` → received as `CustomMessage` event. - -**Key finding**: Workers are the ONLY async mechanism. All long-running operations (file watching, polling, network-equivalent tasks) must go through workers. There is no tokio, no async/await, no threads. - -## R-005: Zellij Plugin API — Filesystem Access - -**Source**: https://zellij.dev/documentation/plugin-api-file-system - -Three mapped paths: -- `/host` — CWD of last focused terminal, or Zellij start folder -- `/data` — per-plugin shared folder (created on load, deleted on unload) -- `/tmp` — system temp directory - -Additional commands: -- `change_host_folder` (requires `FullHdAccess`) — change `/host` to arbitrary path -- `scan_host_folder` — performance workaround because WASI filesystem scanning is "extremely slow" - -**Key finding**: Filesystem access is possible but mediated. With `FullHdAccess` permission, a plugin can access any path via `change_host_folder`, but this changes the `/host` mapping globally for that plugin instance. Reading spec files, task files, and config requires either: -1. Starting Zellij in the repo root (so `/host` = repo root), OR -2. Using `change_host_folder` to point at the repo, OR -3. Using `run_command` to read files via host commands (e.g., `cat`) - -**Key finding**: `scan_host_folder` exists specifically because WASI filesystem traversal is slow. This is a yellow flag for operations that scan many files (e.g., task file detection, spec resolution). - -## R-006: Zellij Plugin API — WASM Runtime Constraints - -**Source**: https://docs.rs/zellij-tile/latest/zellij_tile/ (inferred from API design + WASI target) - -| Constraint | Impact | -|-----------|--------| -| Target: `wasm32-wasip1` | No native code, no C FFI, limited crate ecosystem | -| No tokio | All async via Zellij workers (message-passing) | -| No TCP/UDP sockets | Cannot host MCP stdio server, HTTP server, or any network listener | -| No threads (stable) | Workers are the workaround | -| Mapped filesystem only | `/host`, `/data`, `/tmp` — not arbitrary paths without `FullHdAccess` | -| Slow filesystem scanning | `scan_host_folder` exists as a workaround | -| WASM binary size | Can grow large with complex logic (serde, etc.) | -| Plugin state | Lives in WASM memory; lost on plugin unload/reload | -| State persistence | Must manually serialize to `/data` filesystem | - -**Key finding**: The showstopper for Option A (full plugin) is **no TCP/stdio server capability**. kasmos's MCP server (`rmcp` crate, stdio transport) cannot run inside a WASM plugin. AI agents (OpenCode/Crush) connect to MCP servers via stdio — a WASM plugin has no way to expose this interface. - -## R-007: Zellij Plugin API — Permissions System - -**Source**: https://zellij.dev/documentation/plugin-api-permissions - -Granular permission model — plugin requests permissions on load, user approves: - -| Permission | Needed for | -|-----------|------------| -| `ReadApplicationState` | Pane/tab/session events, clipboard events | -| `ChangeApplicationState` | Pane management (open/close/focus/rename), tab operations, mode switching | -| `RunCommands` | Command panes, background commands | -| `WriteToStdin` | Writing to pane STDIN | -| `OpenTerminalsOrPlugins` | Opening terminal panes | -| `OpenFiles` | Opening editor panes | -| `FullHdAccess` | Arbitrary filesystem access | -| `Reconfigure` | Changing Zellij config at runtime | -| `MessageAndLaunchOtherPlugins` | Sending pipes to other plugins | -| `ReadCliPipes` | Receiving CLI pipe messages | -| `InterceptInput` | Intercepting user keypresses | -| `StartWebServer` | Controlling Zellij web server | - -**Key finding**: A kasmos plugin would need: `ReadApplicationState`, `ChangeApplicationState`, `RunCommands`, `WriteToStdin`, `OpenTerminalsOrPlugins`, `FullHdAccess`, `ReadCliPipes`, `MessageAndLaunchOtherPlugins`. This is a broad permission surface but plugins can request all needed permissions upfront. - -## R-008: OpenCode — Extensibility Model - -**Source**: https://github.com/anomalyco/opencode, https://opencode.ai/docs - -### OpenCode Status -- **Active and thriving**: 106k stars, 10.4k forks, 757 contributors, 9,380 commits, v1.2.6 (Feb 16, 2026). -- TypeScript monorepo (50.8% TS), MIT licensed. -- Maintained by Anomaly (anomalyco). NOT the archived opencode-ai/opencode Go project (which became Crush/charmbracelet). -- Client/server architecture: TUI is one client; desktop app, web, and IDE extensions are others. - -### Architecture -- **Client/server split**: `opencode serve` runs a headless HTTP server with OpenAPI 3.1 spec. The TUI connects to it. Multiple clients can connect simultaneously. -- **TypeScript/Bun runtime**: Monorepo with packages/ directory. -- **Built-in agents**: `build` (full access), `plan` (read-only), `general` (subagent), `explore` (read-only subagent), plus system agents (compaction, title, summary). -- **MCP support**: Consume external MCP servers via stdio, HTTP, SSE transports. - -### Extension Points Available - -1. **Plugin system** (https://opencode.ai/docs/plugins): - - JS/TS modules loaded from `.opencode/plugins/` (project) or `~/.config/opencode/plugins/` (global), or npm packages. - - Plugins receive context: `{ project, client, $, directory, worktree }`. - - Hook into events: `tool.execute.before`, `tool.execute.after`, `session.idle`, `session.created`, `session.compacted`, `file.edited`, `message.updated`, `shell.env`, etc. - - Can register **custom tools** with Zod schemas available to the AI alongside built-in tools. - - Can modify behavior (e.g., intercept tool calls, inject env vars, add compaction context). - - TypeScript support with `@opencode-ai/plugin` types. - -2. **Custom agents** (https://opencode.ai/docs/agents): - - Defined via JSON config or Markdown files. - - Two modes: `primary` (Tab-switchable) and `subagent` (invoked via @mention or by primary agents). - - Configurable: model, prompt, temperature, tools, permissions, max steps, task permissions (which subagents it can invoke). - - Granular permission control: per-tool allow/ask/deny, per-bash-command patterns. - -3. **SDK** (https://opencode.ai/docs/sdk): - - `@opencode-ai/sdk` npm package. Type-safe JS/TS client. - - `createOpencode()` starts server + client; `createOpencodeClient()` connects to existing server. - - Full API: sessions (create/list/prompt/abort/fork), messages, files, events (SSE), config, TUI control. - - Can programmatically send prompts, manage sessions, subscribe to events. - -4. **Server API** (https://opencode.ai/docs/server): - - `opencode serve` exposes HTTP endpoints: sessions, messages, files, tools, events, agents, config. - - SSE event stream for real-time monitoring. - - OpenAPI 3.1 spec at `/doc`. - -**Key finding**: OpenCode HAS a rich extension model. Unlike the old Go project, this is highly extensible via plugins (custom tools, event hooks), custom agents (configurable roles), and a programmatic SDK/server API. kasmos could potentially embed orchestration logic as an OpenCode plugin + custom agent set. - -**Key finding**: However, OpenCode does NOT manage Zellij panes. It has its own TUI (bubbletea-equivalent in TS). Orchestrating multiple concurrent agents in separate terminal panes is not part of OpenCode's model — it uses subagents within the same process. The Zellij pane management problem persists regardless of how deep the OpenCode integration goes. - -**Key finding**: The SDK + server architecture opens a different approach for Option B: instead of forking, kasmos could act as an **external orchestrator that drives multiple OpenCode server instances via SDK**. Each worker pane would run `opencode serve`, and kasmos coordinates them programmatically. This preserves kasmos's role while leveraging OpenCode's agent infrastructure. - -**Key finding**: MIT license. No forking restrictions. - -## R-009: Current kasmos Codebase Metrics - -**Source**: Direct codebase inspection - -| Metric | Value | -|--------|-------| -| Total LOC (Rust) | 12,325 | -| Source files | 35 | -| MCP tools | 9 | -| Crates | 1 (kasmos) | -| Key dependencies | rmcp, tokio, kdl, serde, clap, toml | -| Test coverage | Unit tests present, integration tests for MCP tools | - -**Note**: The spec estimated ~3,500 LOC across 17 files. Actual is **12,325 LOC across 35 files** — significantly more code to migrate than initially scoped. - -## R-010: Zellij Plugin API — Web Requests - -**Source**: https://zellij.dev/documentation/plugin-api-commands#web_request, https://zellij.dev/documentation/plugin-api-events#webrequestresult - -- `web_request` command (requires `WebAccess` permission) makes HTTP requests from plugin. -- Result returned via `WebRequestResult` event (status code, body, context dict). -- This is an async operation (request fires, result arrives later as event). - -**Key finding**: While plugins can't host servers, they CAN make outbound HTTP requests. This opens the possibility of a plugin communicating with an external kasmos process via HTTP polling or webhooks — relevant for Option C hybrid architecture. diff --git a/kitty-specs/014-architecture-pivot-evaluation/spec.md b/kitty-specs/014-architecture-pivot-evaluation/spec.md deleted file mode 100644 index ebd3a09..0000000 --- a/kitty-specs/014-architecture-pivot-evaluation/spec.md +++ /dev/null @@ -1,259 +0,0 @@ -# 014 - Architecture Pivot Evaluation - -## Problem - -kasmos is an MCP-first orchestration CLI that coordinates AI coding agent swarms inside Zellij terminal sessions. While tests pass and core functionality works, development has hit repeated friction walls stemming from fundamental architectural constraints: - -1. **Zellij CLI opacity**: There is no `list-panes` or `focus-pane-by-name` CLI command (Zellij 0.41+). kasmos must maintain its own in-memory `WorkerRegistry` to track panes it spawned, but has no way to reconcile this with Zellij's actual pane state. If a pane crashes, is closed by the user, or Zellij restarts, the registry is stale and unrecoverable. - -2. **Process boundary friction**: kasmos runs as three separate processes -- a launcher binary, an MCP stdio server (subprocess of the manager agent), and the manager agent itself (OpenCode). Communication between these is indirect: the manager agent calls MCP tools, which shell out to `zellij action` commands, which affect panes that kasmos cannot observe. This creates a blind orchestrator problem. - -3. **Layout generation complexity**: kasmos generates KDL layout files programmatically using the `kdl` crate, with extensive workarounds for KDL v2 vs Zellij's KDL v1 expectations (`#true` -> `true` replacements), manual string escaping, and zjstatus configuration embedded in Rust code. This is brittle and hard to maintain. - -4. **Worktree path confusion**: Every subsystem must distinguish between main repo paths and worktree paths. File watchers, task file transitions, prompt construction, and agent CWD setup all need to resolve the correct path variant. This has been a recurring source of bugs. - -5. **External dependency chain**: kasmos requires Zellij, OpenCode (ocx), spec-kitty, bun (for pane-tracker MCP server), git, and two Zellij plugins (zjstatus, zellij-pane-tracker) all installed and configured. The `kasmos setup` command validates these, but the dependency surface is large and fragile. - -6. **No pane introspection**: The MCP server's `read_messages` and `wait_for_event` tools rely on parsing a message-log pane's scrollback via the zellij-pane-tracker plugin. This is an indirect, fragile communication channel. If the pane-tracker plugin isn't loaded or the message format changes, inter-agent communication breaks silently. - -### Root Cause - -kasmos is an external process trying to orchestrate a terminal multiplexer that was not designed for programmatic orchestration. The Zellij CLI provides session/tab/pane creation but minimal introspection or event subscription. kasmos compensates with workarounds (in-memory registry, message-log pane parsing, layout KDL generation), but each workaround introduces its own failure modes. - -### The Question - -Would kasmos be better served by one of these alternative architectures? - -- **A) Zellij plugin (WASM)**: Move orchestration logic into a Zellij plugin that has native access to pane lifecycle events, pane metadata, and the plugin API. -- **B) OpenCode fork/extension**: Embed orchestration directly into the AI agent runtime, eliminating the Zellij dependency for agent coordination. -- **C) Hybrid**: Keep kasmos as the MCP server but replace the Zellij CLI shelling with a Zellij plugin that acts as a bridge, providing the introspection kasmos currently lacks. -- **D) Status quo with targeted fixes**: Accept the current architecture and address specific pain points incrementally. - -## Evaluation Dimensions - -This specification defines the dimensions along which each architecture option must be evaluated. The plan phase will conduct the actual evaluation. - -### ED-001: Pane Lifecycle Observability - -Can the architecture observe pane creation, destruction, focus changes, and command exit status without polling or indirect inference? - -- Current: No. Registry is maintained manually; no reconciliation with Zellij state. -- Zellij plugin API offers: `PaneUpdate` event subscription, `ListClients` command, pane metadata access. - -### ED-002: Inter-Agent Communication - -How do agents (manager, workers) exchange structured messages? - -- Current: Message-log pane with text parsing via zellij-pane-tracker MCP server. -- Zellij plugin API offers: `pipe_message_to_plugin`, plugin-to-plugin messaging, `write_chars`/`write_bytes` to pane stdin. - -### ED-003: Process Spawning and Management - -Can the architecture spawn, monitor, and terminate agent processes? - -- Current: `zellij action new-pane` with `--command` flag, no process monitoring. -- Zellij plugin API offers: `open_command_pane`, `open_terminal_pane`, `close_pane`, `run_command` (background), command exit status events. - -### ED-004: Filesystem and State Access - -Can the architecture read/write spec files, task files, config, and worktree state? - -- Current: Full filesystem access (native Rust binary). -- Zellij plugin API offers: Mapped filesystem access (`/host/`, `/data/`, `/tmp/`), but with performance caveats. - -### ED-005: MCP Server Compatibility - -Can the architecture continue to expose MCP tools for AI agent consumption? - -- Current: `rmcp` crate with stdio transport, works well. -- Zellij plugin: WASM plugins cannot run TCP/stdio servers directly; would need a bridge process or pipe-based transport. - -### ED-006: Development Velocity - -How much existing code can be reused? What is the migration cost? - -- Current codebase: ~3500 lines of Rust across 17 source files, 9 MCP tools, comprehensive test suite. -- Zellij plugin: Requires `wasm32-wasip1` target, different async model, different dependency constraints (no tokio in WASM). - -### ED-007: User Experience - -Setup complexity, runtime reliability, error recovery, and debugging ergonomics. - -- Current: 7+ external dependencies, `kasmos setup` validation, config generation. -- Zellij plugin: Single WASM file in `~/.config/zellij/plugins/`, loaded via config. - -### ED-008: Extensibility - -How easily can new agent roles, workflow patterns, or integrations be added? - -- Current: Add a new MCP tool handler, update registry types, add prompt template. -- Zellij plugin: Add event handler, update plugin state, re-compile WASM. - -## User Stories - -### US-001: Architecture Decision - -As a kasmos maintainer, I want a structured evaluation of alternative architectures so that I can make an informed decision about whether to pivot, and if so, to which architecture. - -**Acceptance Scenario:** -- GIVEN the current kasmos codebase and its known pain points -- WHEN the evaluation is complete -- THEN there is a clear recommendation with rationale, migration cost estimate, and risk assessment for each option - -### US-002: Proof of Concept Scope - -As a kasmos maintainer, I want to know what a minimal proof-of-concept looks like for each viable option so that I can validate the recommendation before committing to a full migration. - -**Acceptance Scenario:** -- GIVEN the recommended architecture -- WHEN a PoC scope is defined -- THEN it covers the highest-risk integration point (pane lifecycle observability) and can be built in 1-2 work packages - -### US-003: Migration Path - -As a kasmos maintainer, I want to understand the migration path from the current architecture to the recommended one so that I can plan the transition without losing existing functionality. - -**Acceptance Scenario:** -- GIVEN the recommended architecture -- WHEN the migration path is documented -- THEN it identifies which current modules are reusable, which must be rewritten, and which can be incrementally migrated - -### US-004: Risk Identification - -As a kasmos maintainer, I want to understand the risks of each architecture option so that I can weigh them against the known pain points of the status quo. - -**Acceptance Scenario:** -- GIVEN each architecture option -- WHEN risks are documented -- THEN they cover technical risk (API stability, WASM limitations), operational risk (deployment complexity), and strategic risk (upstream dependency changes) - -## Functional Requirements - -### FR-001: Evaluate Zellij Plugin Architecture (Option A) - -MUST evaluate the feasibility of implementing kasmos as a Zellij WASM plugin, covering: -- Plugin API coverage for all 9 current MCP tool equivalents -- WASM runtime constraints (no tokio, no TCP sockets, mapped filesystem) -- Plugin-to-agent communication patterns (replacing MCP stdio transport) -- Build and distribution model (single .wasm file vs current cargo install) -- State persistence across plugin reloads - -### FR-002: Evaluate OpenCode Fork/Extension Architecture (Option B) - -MUST evaluate the feasibility of embedding orchestration into the AI agent runtime, covering: -- OpenCode's extension/plugin model (if any) -- Whether orchestration logic can run inside the agent's process -- Impact on agent-agnostic design (currently supports OpenCode and Claude Code) -- Maintenance burden of forking vs extending - -### FR-003: Evaluate Hybrid Architecture (Option C) - -MUST evaluate a hybrid where kasmos keeps its MCP server but uses a Zellij plugin as a bridge for pane introspection, covering: -- Plugin-to-external-process communication (pipes, shared files, HTTP) -- Which current pain points this resolves vs which remain -- Additional complexity of maintaining both a binary and a plugin -- Whether the zellij-pane-tracker plugin already partially fills this role - -### FR-004: Evaluate Status Quo with Targeted Fixes (Option D) - -MUST evaluate incremental improvements to the current architecture, covering: -- Specific fixes for each pain point listed in the Problem section -- Estimated effort per fix -- Whether fixes compound or remain isolated improvements -- Long-term maintainability trajectory - -### FR-005: Comparative Scoring - -MUST produce a comparison matrix scoring each option against all evaluation dimensions (ED-001 through ED-008) on a consistent scale, with weighted scoring based on pain point severity. - -### FR-006: Recommendation with Rationale - -MUST produce a single recommended architecture with: -- Clear rationale tied to evaluation dimensions -- Dissenting considerations (why the other options were not chosen) -- Confidence level (high/medium/low) with explanation -- Conditions under which the recommendation should be revisited - -### FR-007: PoC Definition - -MUST define a proof-of-concept scope for the recommended architecture that: -- Targets the highest-risk integration point -- Can be implemented in 1-2 work packages -- Has clear success/failure criteria -- Is reversible (does not burn bridges with the current architecture) - -### FR-008: Migration Roadmap - -MUST produce a migration roadmap from current to recommended architecture that: -- Maps current modules to their equivalents in the new architecture -- Identifies reusable code vs rewrite-required code -- Defines migration phases (can be done incrementally, not big-bang) -- Estimates total effort in work packages - -## Non-Functional Requirements - -### NFR-001: Objectivity - -The evaluation MUST consider each option on its technical merits, not on sunk cost in the current implementation. The status quo option (D) must be evaluated with the same rigor as the alternatives. - -### NFR-002: Verifiability - -Claims about Zellij plugin API capabilities, OpenCode extensibility, and WASM runtime constraints MUST be verified against current documentation or source code, not assumed. Research artifacts must cite sources. - -### NFR-003: Actionability - -The output must be specific enough that a developer can begin implementing the recommended option without additional architectural research. Vague recommendations like "consider using a plugin" are insufficient. - -### NFR-004: Reversibility Awareness - -The evaluation MUST flag any recommended changes that are difficult to reverse, and provide mitigation strategies for those changes. - -## Key Entities - -| Entity | Description | -|--------|-------------| -| ArchitectureOption | One of the four evaluated approaches (A/B/C/D) | -| EvaluationDimension | A criterion for comparing options (ED-001 through ED-008) | -| ComparisonMatrix | Scored grid of options vs dimensions | -| MigrationPhase | A discrete step in transitioning from current to recommended architecture | -| ProofOfConcept | Minimal implementation to validate the highest-risk aspect of the recommendation | -| PainPoint | A specific, documented friction in the current architecture | - -## Edge Cases - -### EC-001: Zellij Plugin API Insufficient - -If the Zellij plugin API does not cover a critical kasmos capability (e.g., MCP server hosting), the evaluation must document the gap and assess whether a bridge/workaround is viable or whether it disqualifies the option. - -### EC-002: OpenCode Not Extensible - -If OpenCode has no plugin/extension model, Option B reduces to "fork OpenCode," which has different cost/benefit characteristics than extending it. The evaluation must distinguish between these sub-options. - -### EC-003: Multiple Options Score Similarly - -If two or more options score within margin of error on the comparison matrix, the evaluation must use tiebreaker criteria: migration cost, reversibility, and alignment with the Zellij ecosystem direction. - -### EC-004: Upstream Dependency Instability - -If a recommended architecture depends on an unstable upstream API (e.g., Zellij plugin API is pre-1.0), the evaluation must assess the risk of breaking changes and recommend mitigation (pinned versions, abstraction layers). - -### EC-005: Partial Migration Viability - -The evaluation must consider whether a partial migration is viable (e.g., move pane management to a plugin but keep MCP server as a binary), or whether the architecture must be all-or-nothing. - -## Success Criteria - -- SC-001: A comparison matrix exists with scores for all 4 options across all 8 evaluation dimensions -- SC-002: A single recommended architecture is identified with clear rationale -- SC-003: A PoC scope is defined with success/failure criteria -- SC-004: A migration roadmap exists with phase definitions and effort estimates -- SC-005: All claims about external APIs are backed by documentation references or source code citations -- SC-006: The evaluation is reviewed and the recommendation is accepted or a specific alternative is chosen with documented reasoning - -## Constraints - -- C-001: The evaluation must be completable in the plan phase (no implementation required for the evaluation itself) -- C-002: The PoC must be scoped to 1-2 work packages maximum -- C-003: The recommended architecture must support Linux as the primary platform (macOS best-effort, per constitution) -- C-004: The recommended architecture must not require users to modify their Zellij configuration beyond adding a plugin (if applicable) -- C-005: The recommended architecture must preserve the ability to orchestrate multiple AI agent roles (planner, coder, reviewer, release) concurrently diff --git a/kitty-specs/016-kasmos-agent-orchestrator/project-context.md b/kitty-specs/016-kasmos-agent-orchestrator/project-context.md new file mode 100644 index 0000000..c317ebf --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/project-context.md @@ -0,0 +1,300 @@ +# kasmos - Agent Orchestrator: Project Context + +> This document packages all architectural decisions, research, and design context for the kasmos rewrite. It is intended as the knowledge base for a Claude.ai project focused on TUI design. + +## What is kasmos? + +kasmos is a terminal-based orchestrator for managing concurrent AI coding agent sessions. A developer runs `kasmos`, sees a dashboard, and can spawn/monitor/kill multiple AI agent workers in parallel. Workers are OpenCode instances that execute coding tasks (planning, implementation, review, etc.). + +The developer drives orchestration directly through the TUI -- there is no "manager AI agent" consuming tokens for orchestration decisions. The TUI is deterministic and instant; only workers use AI. + +## Tech Stack (locked in) + +- **Language**: Go +- **TUI framework**: bubbletea (Elm architecture: Model/Update/View) +- **Styling**: lipgloss +- **Components**: bubbles (table, spinner, text input, viewport, list, paginator) +- **Worker harness**: OpenCode (`opencode run` for headless workers) +- **Distribution**: Single Go binary +- **Daemon mode**: bubbletea's `WithoutRenderer()` for headless/CI operation +- **Future SSH access**: charmbracelet/wish + +## Why this architecture? + +kasmos was previously a Rust binary that orchestrated AI agents inside Zellij (a terminal multiplexer). After extensive evaluation (see Architecture Evaluation below), we determined: + +1. **Zellij lacks programmatic introspection.** No `list-panes`, no pane event hooks, no way to know if a pane crashed. kasmos maintained an in-memory registry that went stale constantly. + +2. **A manager AI agent is unnecessary.** The manager was translating human instructions into tool calls -- "spawn a planner" becomes `spawn_worker(role=planner)`. That's what a TUI button does, instantly and for free. + +3. **Workers don't need terminal panes.** Workers run `opencode run` (headless) -- they take a prompt, execute, and exit. Their output is captured via Go pipes and displayed in the TUI. No terminal multiplexer needed. + +4. **Session continuation replaces interactivity.** When a reviewer says "verified with suggestions", the developer reads the output in the dashboard and spawns a continuation: `opencode run --continue -s "Apply suggestions 1 and 3"`. Full context is preserved without needing to type into a running session. + +5. **Go/bubbletea is the best framework fit.** The Elm architecture maps naturally to kasmos's event loop (worker events -> update state -> render dashboard). Built-in daemon mode, charmbracelet ecosystem (wish for SSH, bubbles for components, lipgloss for styling). + +## Architecture Overview + +``` +kasmos binary (Go) + | + +-- bubbletea TUI (interactive mode) + | | + | +-- Worker Dashboard (bubbles/table) + | +-- Output Viewport (bubbles/viewport) + | +-- Spawn Dialog (bubbles/textinput + huh) + | +-- Task Source Panel (spec-kitty / GSD / ad-hoc) + | +-- Status Bar (lipgloss styled) + | + +-- bubbletea daemon (headless mode, -d flag) + | +-- Same Model/Update loop, no View rendering + | +-- Structured stdout logging + | + +-- Worker Manager + | +-- WorkerBackend interface + | | +-- SubprocessBackend (MVP: os/exec) + | | +-- TmuxBackend (future: optional) + | +-- Output capture via Go pipes + | +-- Process lifecycle tracking + | +-- Session ID tracking for continuations + | + +-- Task Source adapters + | +-- SpecKittySource (reads plan.md -> WPs) + | +-- GsdSource (reads tasks.md -> tasks) + | +-- AdHocSource (empty, manual prompts) + | + +-- Session persistence (JSON to disk) + +-- kasmos setup (scaffolds .opencode/agents/) +``` + +## Worker Lifecycle + +``` +[pending] -- user selects task, hasn't spawned yet + | + v +[spawning] -- os/exec.Command starting + | + v +[running] -- process alive, stdout streaming into buffer + | + +---> [exited(0)] -- success, output captured + +---> [exited(N)] -- failure, output captured, N = exit code + +---> [killed] -- user terminated via TUI + +From exited/killed: + +---> [continue] -- user spawns follow-up with --continue -s + +---> [restart] -- user spawns fresh with edited prompt +``` + +## Worker Continuation Flow + +This is the key interaction pattern for iterative workflows: + +1. Worker (e.g., reviewer) runs and completes +2. kasmos captures output + OpenCode session ID +3. User reads output in the TUI viewport +4. User presses `c` (continue) on the completed worker +5. TUI shows input for follow-up message +6. User types: "Apply suggestions 1 and 3. Skip suggestion 2." +7. kasmos spawns: `opencode run --continue -s "Apply suggestions 1 and 3..."` +8. New worker appears in dashboard linked to parent, runs with full context + +## Workflow Modes + +### spec-kitty (formal planning pipeline) +``` +kasmos kitty-specs/015-auth-overhaul/ +``` +Reads `plan.md`, extracts work packages with descriptions, dependencies, and suggested agent roles. Dashboard pre-populates with WPs. User selects and spawns. + +### GSD (lightweight task tracking) +``` +kasmos tasks.md +``` +Reads a markdown task file (checkbox format). Dashboard shows tasks. User assigns agent roles and spawns. + +### Ad-hoc (no planning artifacts) +``` +kasmos +``` +Empty dashboard. User manually types agent role + prompt for each worker. + +## Agent Roles (shipped with kasmos setup) + +kasmos scaffolds these as OpenCode custom agents in `.opencode/agents/`: + +| Role | Mode | Description | +|----------|----------|-------------------------------------------------| +| planner | subagent | Research and planning, read-only filesystem | +| coder | subagent | Implementation, full tool access | +| reviewer | subagent | Code review, read-only + test execution | +| release | subagent | Merge, finalization, cleanup operations | + +Each agent has a markdown file defining its system prompt, model, tools, and permissions. + +## On-Demand AI Helpers + +These are NOT persistent agents -- they're one-shot `opencode run` calls triggered by TUI keybinds: + +- **Prompt generation** (`g` key): "Given this task description and the project's plan.md, generate a focused prompt for a coder agent." Returns suggested prompt the user can edit before spawning. +- **Failure analysis** (`a` key): "This worker failed. Here's the last 50 lines of output. Explain what went wrong and suggest a revised prompt." Returns analysis displayed in the TUI. + +## Key Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Language | Go | bubbletea ecosystem, single binary, goroutines for concurrency | +| TUI framework | bubbletea | Elm architecture maps to event-driven orchestration | +| Worker execution | Headless subprocess (opencode run) | Simplest, captures output via pipes | +| Worker interaction | Session continuation (--continue -s) | Preserves full context without PTY/tmux | +| Worker backend | Pluggable interface | SubprocessBackend MVP, TmuxBackend future option | +| Terminal multiplexer | None for MVP | Workers are subprocesses, TUI handles display | +| Manager agent | Eliminated | TUI is deterministic, instant, zero token cost | +| Daemon mode | bubbletea WithoutRenderer() | Same logic, no TUI -- for CI/scripting | +| Remote access | wish (SSH TUI server, future) | Built into charmbracelet ecosystem | +| Task sources | Pluggable adapters | spec-kitty, GSD, ad-hoc | + +## OpenCode CLI Reference (relevant flags) + +``` +# Headless execution (worker mode) +opencode run [message..] + --agent # Agent to use (planner, coder, reviewer, release) + --model # Override model + --file # Attach file(s) to message + --continue, -c # Continue last session + --session, -s # Specific session to continue + --fork # Fork session when continuing + --format # Output format: default or json + --attach # Attach to running opencode server + +# Headless server (for SDK-driven control, future) +opencode serve + --port + --hostname + +# Agent management +opencode agent list +opencode agent create +``` + +## OpenCode Custom Agent Format + +```markdown +# .opencode/agents/coder.md +--- +description: Implementation agent for writing and modifying code +model: anthropic/claude-sonnet-4-20250514 +tools: + write: true + edit: true + bash: true + glob: true + grep: true + fetch: true +permission: + bash: + "git push --force*": deny + "rm -rf /*": deny +--- + +You are a coder agent for the kasmos project. +Implement the work package described in your prompt. +Run tests after changes. Follow the project's AGENTS.md conventions. +``` + +## TUI Design Requirements (for the research phase) + +The TUI design should address: + +1. **Dashboard layout**: Worker list (table), output viewport, status bar, help overlay +2. **Spawn dialog**: Agent role selector, prompt editor (multiline), file attachment, task association +3. **Worker states**: Visual indicators for running (spinner), done (checkmark), failed (X), killed (skull), pending (circle) +4. **Output viewing**: Split view (dashboard + output), full-screen output mode, output search/filter +5. **Task panel**: When a task source is loaded, show tasks with status (unassigned, in-progress, done) +6. **Continuation UI**: Show parent-child relationships between workers, pre-fill context +7. **Responsive layout**: Adapt to terminal size (minimum 80x24, graceful degradation) +8. **Color scheme**: Consistent with terminal conventions, works on light and dark backgrounds +9. **Keybinds**: vim-inspired navigation (j/k for list, enter to select, ? for help) +10. **Daemon output**: What structured logging looks like in headless mode + +### Reference TUI applications for design inspiration + +- **lazydocker**: Container management dashboard with split views +- **k9s**: Kubernetes cluster management with table + detail views +- **gitui**: Git operations with panel-based layout +- **opencode**: The OpenCode TUI itself (bubbletea-based) +- **charm apps**: gum, soft-serve, mods -- charmbracelet design language + +## bubbletea Architecture Primer + +bubbletea uses the Elm architecture: + +```go +// Model holds all application state +type Model struct { + workers []Worker + selected int + viewport viewport.Model + table table.Model + // ... +} + +// Init returns initial commands (start timers, etc.) +func (m Model) Init() tea.Cmd { ... } + +// Update handles messages and returns new model + commands +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case WorkerExitedMsg: + // update worker state + case tea.KeyMsg: + // handle keybinds + case tickMsg: + // periodic refresh + } +} + +// View renders the UI as a string +func (m Model) View() string { + return lipgloss.JoinVertical( + lipgloss.Top, + m.renderHeader(), + m.renderDashboard(), + m.renderStatusBar(), + ) +} +``` + +Key concepts: +- **Msgs**: Events that flow into Update (key presses, timer ticks, worker events, window resize) +- **Cmds**: Side effects returned from Update (spawn process, start timer, read file) +- **Sub-models**: bubbles components (table, viewport, textinput) have their own Update/View +- **tea.Batch**: Run multiple Cmds concurrently +- **tea.Program options**: WithoutRenderer() for daemon, WithAltScreen() for full-screen + +## Daemon Mode Pattern (from bubbletea examples) + +```go +func main() { + var daemonMode bool + flag.BoolVar(&daemonMode, "d", false, "run as daemon") + flag.Parse() + + var opts []tea.ProgramOption + if daemonMode || !isatty.IsTerminal(os.Stdout.Fd()) { + opts = []tea.ProgramOption{tea.WithoutRenderer()} + log.SetOutput(os.Stdout) // log to stdout in daemon mode + } else { + log.SetOutput(io.Discard) // silence logs in TUI mode + } + + p := tea.NewProgram(newModel(), opts...) + if _, err := p.Run(); err != nil { + os.Exit(1) + } +} +``` + +Same Model/Update loop, just no View() calls. Worker events still flow through Update, but instead of rendering a TUI, they log to stdout. diff --git a/kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md b/kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md new file mode 100644 index 0000000..f923dfe --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md @@ -0,0 +1,1308 @@ +# kasmos TUI — Technical Research Artifacts + +> Companion to the visual design artifacts in `design-artifacts/`. This document +> defines Go interfaces, message types, JSON schemas, and behavioral contracts +> that a coder agent can implement directly. + +## Table of Contents + +- [1. WorkerBackend Interface](#1-workerbackend-interface) +- [2. bubbletea Message Type Catalog](#2-bubbletea-message-type-catalog) +- [3. Core Domain Types](#3-core-domain-types) +- [4. Task Source Interface](#4-task-source-interface) +- [5. Session Persistence Schema](#5-session-persistence-schema) +- [6. Daemon Mode Output Format](#6-daemon-mode-output-format) +- [7. Output Buffer Design](#7-output-buffer-design) +- [8. Graceful Shutdown Protocol](#8-graceful-shutdown-protocol) +- [9. OpenCode Integration Contract](#9-opencode-integration-contract) +- [10. Package Structure](#10-package-structure) + +--- + +## 1. WorkerBackend Interface + +The `WorkerBackend` abstracts how worker processes are created, managed, and +observed. The MVP uses `SubprocessBackend` (os/exec). A future `TmuxBackend` +can be added without touching the TUI layer. + +### Interface Definition + +```go +package worker + +import ( + "context" + "io" +) + +// WorkerBackend abstracts the mechanism for running worker processes. +// The TUI and worker manager interact only through this interface. +type WorkerBackend interface { + // Spawn starts a new worker process. Returns a WorkerHandle for + // lifecycle management. The context controls cancellation. + // SpawnConfig contains all parameters needed to start the worker. + Spawn(ctx context.Context, cfg SpawnConfig) (WorkerHandle, error) + + // Name returns the backend identifier (e.g., "subprocess", "tmux"). + Name() string +} + +// SpawnConfig contains everything needed to start a worker. +type SpawnConfig struct { + // ID is the kasmos-assigned worker identifier (e.g., "w-001"). + ID string + + // Role is the agent role (planner, coder, reviewer, release). + Role string + + // Prompt is the task description sent to the agent. + Prompt string + + // Files is an optional list of file paths to attach via --file flags. + Files []string + + // ContinueSession, if non-empty, is the OpenCode session ID to continue. + // Triggers --continue -s flag. + ContinueSession string + + // Model overrides the default model for this worker (optional). + // Maps to --model flag. + Model string + + // WorkDir is the working directory for the worker process. + // Defaults to the project root if empty. + WorkDir string + + // Env is additional environment variables for the worker process. + Env map[string]string +} + +// WorkerHandle provides lifecycle control over a running worker. +type WorkerHandle interface { + // Stdout returns a reader for the worker's combined stdout/stderr stream. + // The reader is closed when the process exits. + Stdout() io.Reader + + // Wait blocks until the worker process exits. Returns the exit result. + Wait() ExitResult + + // Kill sends SIGTERM to the worker process. If the process doesn't exit + // within the grace period, sends SIGKILL. + Kill(gracePeriod time.Duration) error + + // PID returns the OS process ID, or 0 if not applicable (e.g., tmux). + PID() int +} + +// ExitResult contains the outcome of a completed worker process. +type ExitResult struct { + // Code is the process exit code. 0 = success, non-zero = failure. + Code int + + // Duration is how long the worker ran. + Duration time.Duration + + // SessionID is the OpenCode session ID extracted from worker output. + // Empty if not found (output parsing failed or worker crashed early). + SessionID string + + // Error is set if the process couldn't be started or was killed. + Error error +} +``` + +### SubprocessBackend (MVP Implementation) + +```go +package worker + +import ( + "context" + "io" + "os/exec" + "syscall" + "time" +) + +type SubprocessBackend struct { + // OpenCodeBin is the path to the opencode binary. + // Resolved once at startup via exec.LookPath. + OpenCodeBin string +} + +func NewSubprocessBackend() (*SubprocessBackend, error) { + bin, err := exec.LookPath("opencode") + if err != nil { + return nil, fmt.Errorf("opencode not found in PATH: %w", err) + } + return &SubprocessBackend{OpenCodeBin: bin}, nil +} + +func (b *SubprocessBackend) Name() string { return "subprocess" } + +func (b *SubprocessBackend) Spawn(ctx context.Context, cfg SpawnConfig) (WorkerHandle, error) { + args := b.buildArgs(cfg) + cmd := exec.CommandContext(ctx, b.OpenCodeBin, args...) + cmd.Dir = cfg.WorkDir + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // own process group + + // Merge stdout + stderr into a single pipe + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + cmd.Stderr = cmd.Stdout // merge stderr into stdout + + // Inject environment + cmd.Env = os.Environ() + for k, v := range cfg.Env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + startTime := time.Now() + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start opencode: %w", err) + } + + return &subprocessHandle{ + cmd: cmd, + stdout: stdout, + startTime: startTime, + cfg: cfg, + }, nil +} + +func (b *SubprocessBackend) buildArgs(cfg SpawnConfig) []string { + args := []string{"run"} + + if cfg.Role != "" { + args = append(args, "--agent", cfg.Role) + } + if cfg.ContinueSession != "" { + args = append(args, "--continue", "-s", cfg.ContinueSession) + } + if cfg.Model != "" { + args = append(args, "--model", cfg.Model) + } + for _, f := range cfg.Files { + args = append(args, "--file", f) + } + + // Prompt is always the last argument + if cfg.Prompt != "" { + args = append(args, cfg.Prompt) + } + + return args +} +``` + +### Future TmuxBackend (Interface Only) + +```go +// TmuxBackend would implement WorkerBackend by creating tmux sessions +// instead of bare subprocesses. This enables: +// - Scrollback persistence managed by tmux +// - Attaching to running workers interactively +// - Surviving kasmos process death (tmux keeps running) +// +// Not implemented in MVP. Listed here to validate the interface design. +type TmuxBackend struct { + SessionPrefix string // e.g., "kasmos-" +} +``` + +--- + +## 2. bubbletea Message Type Catalog + +All messages that flow through the `Update` loop. Organized by source. + +### Worker Lifecycle Messages + +```go +package tui + +import "time" + +// workerSpawnedMsg is sent when a worker process starts successfully. +type workerSpawnedMsg struct { + WorkerID string + PID int +} + +// workerOutputMsg carries a chunk of output from a running worker. +// Sent by the output reader goroutine via tea.Program.Send(). +type workerOutputMsg struct { + WorkerID string + Data string // may contain multiple lines +} + +// workerExitedMsg is sent when a worker process terminates. +type workerExitedMsg struct { + WorkerID string + ExitCode int + Duration time.Duration + SessionID string // OpenCode session ID (parsed from output) + Err error // non-nil if process failed to start or was killed +} + +// workerKilledMsg is sent when a user-initiated kill completes. +type workerKilledMsg struct { + WorkerID string + Err error // non-nil if kill failed +} +``` + +### Worker Command Messages (user-initiated → side effects) + +```go +// spawnWorkerCmd returns a tea.Cmd that spawns a worker and sends +// workerSpawnedMsg on success or workerExitedMsg on failure. +func spawnWorkerCmd(backend WorkerBackend, cfg SpawnConfig) tea.Cmd + +// killWorkerCmd returns a tea.Cmd that kills a worker and sends +// workerKilledMsg when done. +func killWorkerCmd(handle WorkerHandle, gracePeriod time.Duration) tea.Cmd + +// readOutputCmd returns a tea.Cmd that reads from a worker's stdout +// and sends workerOutputMsg chunks. Loops until EOF, then sends +// nothing (the workerExitedMsg comes from the Wait goroutine). +func readOutputCmd(workerID string, reader io.Reader) tea.Cmd +``` + +### UI State Messages + +```go +// tickMsg is sent by a periodic timer for duration updates. +// The TUI recalculates running worker durations on each tick. +type tickMsg time.Time + +// Tick interval: 1 second. Started in Init(), restarted after each tick. +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// spinnerTickMsg is the spinner animation tick. +// Handled by forwarding to spinner.Model.Update(). +// Uses spinner's built-in tick mechanism. + +// focusChangedMsg is sent internally when panel focus changes. +// Triggers key state recalculation and viewport title update. +type focusChangedMsg struct { + From panel + To panel +} + +// layoutChangedMsg is sent when a resize crosses a breakpoint boundary. +type layoutChangedMsg struct { + From layoutMode + To layoutMode +} +``` + +### Overlay/Dialog Messages + +```go +// spawnDialogSubmittedMsg carries the completed spawn form data. +type spawnDialogSubmittedMsg struct { + Role string + Prompt string + Files []string + TaskID string // empty for ad-hoc +} + +// spawnDialogCancelledMsg indicates the user dismissed the spawn dialog. +type spawnDialogCancelledMsg struct{} + +// continueDialogSubmittedMsg carries the follow-up message for continuation. +type continueDialogSubmittedMsg struct { + ParentWorkerID string + SessionID string + FollowUp string +} + +// continueDialogCancelledMsg indicates the user dismissed the continue dialog. +type continueDialogCancelledMsg struct{} + +// quitConfirmedMsg indicates the user confirmed quit with running workers. +type quitConfirmedMsg struct{} + +// quitCancelledMsg indicates the user cancelled the quit dialog. +type quitCancelledMsg struct{} +``` + +### AI Helper Messages + +```go +// analyzeStartedMsg indicates failure analysis has begun. +type analyzeStartedMsg struct { + WorkerID string +} + +// analyzeCompletedMsg carries the analysis result from the AI helper. +type analyzeCompletedMsg struct { + WorkerID string + RootCause string + SuggestedPrompt string + Err error +} + +// genPromptStartedMsg indicates prompt generation has begun. +type genPromptStartedMsg struct { + TaskID string +} + +// genPromptCompletedMsg carries the generated prompt. +type genPromptCompletedMsg struct { + TaskID string + Prompt string + Err error +} +``` + +### Task Source Messages + +```go +// tasksLoadedMsg carries parsed tasks from a task source. +type tasksLoadedMsg struct { + Source string // "spec-kitty", "gsd", "ad-hoc" + Path string + Tasks []Task + Err error +} + +// taskStateChangedMsg is sent when a task's state changes +// (e.g., unassigned → in-progress when a worker is spawned for it). +type taskStateChangedMsg struct { + TaskID string + NewState TaskState + WorkerID string // the worker that caused the change +} +``` + +### Session Persistence Messages + +```go +// sessionSavedMsg confirms session state was persisted to disk. +type sessionSavedMsg struct { + Path string + Err error +} + +// sessionLoadedMsg carries restored session state on startup/reattach. +type sessionLoadedMsg struct { + Session *SessionState + Err error +} +``` + +### Message Flow Diagram + +``` +User Input (tea.KeyMsg) + │ + ├── 's' key ──────────────────────→ Show spawn dialog + │ │ + │ ├── Submit ──→ spawnDialogSubmittedMsg + │ │ │ + │ │ └──→ spawnWorkerCmd() + │ │ │ + │ │ ├──→ workerSpawnedMsg + │ │ │ │ + │ │ │ └──→ readOutputCmd() + │ │ │ │ + │ │ │ └──→ workerOutputMsg (loop) + │ │ │ + │ │ └──→ waitCmd() ──→ workerExitedMsg + │ │ + │ └── Cancel ──→ spawnDialogCancelledMsg + │ + ├── 'x' key ──────────────────────→ killWorkerCmd() ──→ workerKilledMsg + │ + ├── 'c' key ──────────────────────→ Show continue dialog + │ │ + │ └── Submit ──→ continueDialogSubmittedMsg + │ │ + │ └──→ spawnWorkerCmd(cfg with ContinueSession) + │ + ├── 'a' key ──────────────────────→ analyzeCmd() ──→ analyzeStartedMsg + │ │ + │ └──→ analyzeCompletedMsg + │ + └── 'g' key ──────────────────────→ genPromptCmd() ──→ genPromptStartedMsg + │ + └──→ genPromptCompletedMsg + +Timer + └── tea.Tick(1s) ─────────────────→ tickMsg (duration refresh) + +Spinner + └── spinner.Tick ─────────────────→ spinner.TickMsg (animation frame) + +Window + └── terminal resize ──────────────→ tea.WindowSizeMsg ──→ recalculateLayout() +``` + +--- + +## 3. Core Domain Types + +```go +package worker + +import "time" + +// Worker represents a managed agent session. +type Worker struct { + // Identity + ID string // "w-001", "w-002", etc. Auto-incrementing. + Role string // "planner", "coder", "reviewer", "release" + Prompt string // The task prompt sent to the agent. + Files []string // Attached file paths. + + // Lifecycle + State WorkerState + ExitCode int + SpawnedAt time.Time + ExitedAt time.Time // zero if still running + + // OpenCode integration + SessionID string // OpenCode session ID, parsed from output. + + // Relationships + ParentID string // ID of parent worker (for continuations). Empty if root. + TaskID string // Associated task ID from task source. Empty for ad-hoc. + + // Runtime (not persisted) + Handle WorkerHandle // nil after process exit + Output *OutputBuffer // ring buffer of captured output +} + +// Duration returns how long the worker has been running (or ran). +func (w *Worker) Duration() time.Duration { + if w.ExitedAt.IsZero() { + if w.SpawnedAt.IsZero() { + return 0 + } + return time.Since(w.SpawnedAt) + } + return w.ExitedAt.Sub(w.SpawnedAt) +} + +// FormatDuration returns a human-readable duration string. +// Examples: "4m 12s", "0m 34s", "1h 2m", "—" (for pending). +func (w *Worker) FormatDuration() string { + if w.State == StatePending || w.State == StateSpawning { + return " — " + } + d := w.Duration() + if d < time.Hour { + return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) +} + +// Children returns IDs of workers that continued from this worker. +func (w *Worker) Children(all []*Worker) []string { + var ids []string + for _, other := range all { + if other.ParentID == w.ID { + ids = append(ids, other.ID) + } + } + return ids +} +``` + +### Worker State Machine + +``` +StatePending ──→ StateSpawning ──→ StateRunning ──┬──→ StateExited (code 0) + ├──→ StateFailed (code != 0) + └──→ StateKilled (user kill) + +From StateExited/StateFailed/StateKilled: + ├──→ Continue (spawns new worker with ParentID set) + └──→ Restart (spawns new worker with same role/prompt, no ParentID) +``` + +### Worker ID Generation + +```go +package worker + +import ( + "fmt" + "sync/atomic" +) + +var workerCounter atomic.Int64 + +// NextWorkerID generates the next sequential worker ID. +// Format: "w-NNN" where NNN is zero-padded to 3 digits. +// Thread-safe via atomic increment. +func NextWorkerID() string { + n := workerCounter.Add(1) + return fmt.Sprintf("w-%03d", n) +} + +// ResetWorkerCounter sets the counter to a value (for session restore). +func ResetWorkerCounter(n int64) { + workerCounter.Store(n) +} +``` + +--- + +## 4. Task Source Interface + +```go +package task + +// Source is a pluggable adapter that reads work items from external files. +type Source interface { + // Type returns the source kind: "spec-kitty", "gsd", or "ad-hoc". + Type() string + + // Path returns the file/directory path this source reads from. + // Empty for ad-hoc. + Path() string + + // Load reads and parses the task source. Returns parsed tasks. + // Called once at startup and can be called again to refresh. + Load() ([]Task, error) + + // Tasks returns the currently loaded tasks (cached from last Load). + Tasks() []Task +} + +// Task represents a single work item from a task source. +type Task struct { + // ID is the task identifier. Format depends on source: + // spec-kitty: "WP-001", GSD: "T-001", ad-hoc: empty. + ID string + + // Title is the short task name. + Title string + + // Description is the detailed task description. + // Used as the default prompt when spawning a worker for this task. + Description string + + // SuggestedRole is the recommended agent role for this task. + // Parsed from plan.md metadata or inferred from task content. + SuggestedRole string + + // Dependencies is a list of task IDs this task depends on. + // A task is "blocked" if any dependency is not in TaskDone state. + Dependencies []string + + // State tracks the task's current assignment status. + State TaskState + + // WorkerID is the ID of the worker assigned to this task. + // Empty if unassigned. + WorkerID string + + // Metadata carries source-specific extra data. + // spec-kitty: phase, lane, subtasks + // GSD: checkbox state, line number + Metadata map[string]string +} +``` + +### SpecKittySource Implementation Notes + +```go +package task + +// SpecKittySource reads a spec-kitty feature directory. +// It parses plan.md (markdown) and tasks/WP*.md (YAML frontmatter + markdown). +// +// Feature directory structure: +// kitty-specs/-/ +// spec.md - feature specification (not parsed for tasks) +// plan.md - implementation plan (parsed for WP summaries) +// tasks/ +// WP-001.md - work package with YAML frontmatter +// WP-002.md - work package with YAML frontmatter +// +// WP frontmatter format (from the old kasmos Rust parser): +// --- +// work_package_id: WP-001 +// title: Auth middleware +// dependencies: [] +// lane: planned # planned | doing | for_review | done +// subtasks: [] +// phase: implementation +// --- +// +// +// Task.ID = work_package_id +// Task.Title = title +// Task.Description = markdown body +// Task.Dependencies = dependencies +// Task.SuggestedRole = inferred from phase: +// - "spec" / "clarifying" → "planner" +// - "implementation" → "coder" +// - "reviewing" → "reviewer" +// - "releasing" → "release" +// Task.State = mapped from lane: +// - "planned" → TaskUnassigned +// - "doing" → TaskInProgress +// - "for_review" → TaskInProgress +// - "done" → TaskDone +type SpecKittySource struct { + Dir string // feature directory path +} +``` + +### GsdSource Implementation Notes + +```go +// GsdSource reads a simple markdown task file. +// Format: a markdown file with checkboxes. +// +// Example tasks.md: +// # Sprint 12 Tasks +// +// - [ ] Implement auth middleware +// - [ ] Fix login flow +// - [x] Review PR #42 +// - [ ] Plan DB schema migration +// +// Task.ID = "T-NNN" (sequential from line order) +// Task.Title = checkbox text +// Task.Description = same as title (no separate description) +// Task.SuggestedRole = "" (user selects at spawn time) +// Task.Dependencies = [] (GSD doesn't track dependencies) +// Task.State = TaskDone if [x], TaskUnassigned if [ ] +type GsdSource struct { + FilePath string +} +``` + +### AdHocSource Implementation Notes + +```go +// AdHocSource is the zero-value source for manual orchestration. +// It has no file, no tasks. Workers are spawned with manual prompts. +// +// Source.Type() = "ad-hoc" +// Source.Path() = "" +// Source.Load() = nil, nil +// Source.Tasks() = [] +type AdHocSource struct{} +``` + +--- + +## 5. Session Persistence Schema + +Session state is persisted to `.kasmos/session.json` in the project root. +Written after every state change (debounced to at most once per second). +Read at startup for `kasmos --attach` reattach. + +### JSON Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2024-12/schema", + "title": "kasmos Session State", + "type": "object", + "required": ["version", "session_id", "started_at", "workers"], + "properties": { + "version": { + "type": "integer", + "const": 1, + "description": "Schema version for forward compatibility." + }, + "session_id": { + "type": "string", + "description": "Unique session identifier. Format: ks--.", + "pattern": "^ks-[0-9]+-[a-z0-9]{4}$", + "examples": ["ks-1739808000-a3f8"] + }, + "started_at": { + "type": "string", + "format": "date-time", + "description": "When the session was created (RFC3339)." + }, + "task_source": { + "type": ["object", "null"], + "description": "The task source configuration. Null for ad-hoc mode.", + "properties": { + "type": { + "type": "string", + "enum": ["spec-kitty", "gsd", "ad-hoc"] + }, + "path": { + "type": "string", + "description": "Absolute path to the task source file/directory." + } + }, + "required": ["type", "path"] + }, + "workers": { + "type": "array", + "description": "All workers in this session (active and historical).", + "items": { + "$ref": "#/$defs/worker" + } + }, + "next_worker_num": { + "type": "integer", + "minimum": 1, + "description": "Next worker ID number to assign. Ensures IDs never collide across restarts." + }, + "pid": { + "type": "integer", + "description": "PID of the kasmos process that owns this session. Used for reattach detection." + } + }, + "$defs": { + "worker": { + "type": "object", + "required": ["id", "role", "prompt", "state", "spawned_at"], + "properties": { + "id": { + "type": "string", + "pattern": "^w-[0-9]{3,}$", + "examples": ["w-001", "w-042"] + }, + "role": { + "type": "string", + "enum": ["planner", "coder", "reviewer", "release"] + }, + "prompt": { + "type": "string", + "description": "The prompt sent to the agent." + }, + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "File paths attached to the agent invocation." + }, + "state": { + "type": "string", + "enum": ["pending", "spawning", "running", "exited", "failed", "killed"] + }, + "exit_code": { + "type": ["integer", "null"], + "description": "Process exit code. Null if not yet exited." + }, + "spawned_at": { + "type": "string", + "format": "date-time" + }, + "exited_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "When the worker exited. Null if still running." + }, + "duration_ms": { + "type": ["integer", "null"], + "description": "Worker duration in milliseconds. Null if still running." + }, + "session_id": { + "type": "string", + "description": "OpenCode session ID. Empty if not yet captured." + }, + "parent_id": { + "type": "string", + "description": "ID of the parent worker for continuations. Empty if root." + }, + "task_id": { + "type": "string", + "description": "Associated task ID from task source. Empty for ad-hoc." + }, + "pid": { + "type": ["integer", "null"], + "description": "OS process ID. Null if not running." + }, + "output_tail": { + "type": "string", + "description": "Last N lines of output (for display on reattach). Truncated to 200 lines max.", + "maxLength": 50000 + } + } + } + } +} +``` + +### Example Session File + +```json +{ + "version": 1, + "session_id": "ks-1739808000-a3f8", + "started_at": "2026-02-17T14:00:00Z", + "task_source": { + "type": "spec-kitty", + "path": "/home/kas/dev/project/kitty-specs/015-auth-overhaul/" + }, + "workers": [ + { + "id": "w-001", + "role": "coder", + "prompt": "Implement the auth middleware as described in WP-001...", + "files": [], + "state": "exited", + "exit_code": 0, + "spawned_at": "2026-02-17T14:01:15Z", + "exited_at": "2026-02-17T14:05:27Z", + "duration_ms": 252000, + "session_id": "ses_j4m9x1", + "parent_id": "", + "task_id": "WP-001", + "pid": null, + "output_tail": "[14:05:27] Done. Auth middleware implemented and tested.\n" + }, + { + "id": "w-002", + "role": "reviewer", + "prompt": "Review the auth middleware implementation...", + "files": [], + "state": "running", + "exit_code": null, + "spawned_at": "2026-02-17T14:06:00Z", + "exited_at": null, + "duration_ms": null, + "session_id": "", + "parent_id": "", + "task_id": "WP-001", + "pid": 48291, + "output_tail": "" + } + ], + "next_worker_num": 3, + "pid": 47100 +} +``` + +### Persistence Behavior + +```go +// SessionPersister manages session state serialization. +type SessionPersister struct { + Path string // ".kasmos/session.json" + Debounce time.Duration // 1 second +} + +// Behavior: +// - Save() is called after every state-mutating message in Update(). +// - Writes are debounced: at most one write per second. +// - Uses atomic write: write to .tmp, then rename. +// - On startup: if session.json exists and pid is alive → reattach mode. +// - On startup: if session.json exists and pid is dead → restore state, +// mark running workers as "killed" (orphaned), assign new PID. +// - On clean exit: update all worker states, write final session.json. +``` + +--- + +## 6. Daemon Mode Output Format + +Two output modes: `--format default` (human-readable) and `--format json` (machine-parseable). + +### JSON Format (NDJSON) + +One JSON object per line. All events share a common envelope. + +```json +{ + "$schema": "https://json-schema.org/draft/2024-12/schema", + "title": "kasmos Daemon Event", + "type": "object", + "required": ["ts", "event"], + "properties": { + "ts": { + "type": "string", + "format": "date-time", + "description": "Event timestamp (RFC3339)." + }, + "event": { + "type": "string", + "enum": [ + "session_start", + "worker_spawn", + "worker_output", + "worker_exit", + "worker_kill", + "analysis_complete", + "session_end" + ] + } + }, + "allOf": [ + { + "if": { "properties": { "event": { "const": "session_start" } } }, + "then": { + "properties": { + "session_id": { "type": "string" }, + "mode": { "type": "string", "enum": ["spec-kitty", "gsd", "ad-hoc"] }, + "source": { "type": "string" }, + "tasks": { "type": "integer" } + }, + "required": ["session_id", "mode"] + } + }, + { + "if": { "properties": { "event": { "const": "worker_spawn" } } }, + "then": { + "properties": { + "id": { "type": "string" }, + "role": { "type": "string" }, + "task": { "type": "string" }, + "parent": { "type": "string" } + }, + "required": ["id", "role"] + } + }, + { + "if": { "properties": { "event": { "const": "worker_exit" } } }, + "then": { + "properties": { + "id": { "type": "string" }, + "code": { "type": "integer" }, + "duration": { "type": "string" }, + "session": { "type": "string" } + }, + "required": ["id", "code", "duration"] + } + }, + { + "if": { "properties": { "event": { "const": "session_end" } } }, + "then": { + "properties": { + "total": { "type": "integer" }, + "passed": { "type": "integer" }, + "failed": { "type": "integer" }, + "duration": { "type": "string" }, + "exit_code": { "type": "integer" } + }, + "required": ["total", "passed", "failed", "exit_code"] + } + } + ] +} +``` + +### Human-Readable Format (default) + +``` +[14:28:00] session started (gsd, tasks.md, 4 tasks) +[14:28:01] w-001 spawned coder "Implement auth" +[14:28:01] w-002 spawned coder "Fix login flow" +[14:28:01] w-003 spawned reviewer "Review PR #42" +[14:28:01] w-004 spawned planner "Plan DB schema" +[14:30:12] w-003 exited(0) reviewer 2m 11s ses_k2m9 +[14:32:14] w-001 exited(0) coder 4m 13s ses_j4m9 +[14:33:01] w-004 exited(0) planner 5m 00s ses_m7x2 +[14:34:02] w-002 exited(1) coder 6m 01s ses_p1q3 ← FAILED +[14:34:02] session ended: 3 passed, 1 failed (6m 02s) exit=1 +``` + +### Implementation Notes + +```go +// In daemon mode, the View() function returns "" (empty). +// State changes that would update the TUI instead emit log lines: + +func (m Model) logEvent(event DaemonEvent) { + if m.format == "json" { + b, _ := json.Marshal(event) + fmt.Println(string(b)) + } else { + fmt.Println(event.HumanString()) + } +} + +// The Update() loop is identical in TUI and daemon mode. +// Only the output side differs: View() vs logEvent(). +``` + +--- + +## 7. Output Buffer Design + +Each worker has an `OutputBuffer` that accumulates stdout/stderr data. The buffer +has a configurable max line count to prevent unbounded memory growth. + +```go +package worker + +import "sync" + +// OutputBuffer is a thread-safe ring buffer of output lines. +// It preserves the last MaxLines lines and discards older ones. +type OutputBuffer struct { + mu sync.RWMutex + lines []string + maxLines int + total int // total lines ever added (for "N lines truncated" display) +} + +// DefaultMaxLines is the default output buffer size per worker. +const DefaultMaxLines = 5000 + +func NewOutputBuffer(maxLines int) *OutputBuffer { + if maxLines <= 0 { + maxLines = DefaultMaxLines + } + return &OutputBuffer{ + lines: make([]string, 0, min(maxLines, 1024)), + maxLines: maxLines, + } +} + +// Append adds raw data to the buffer. Data may contain multiple lines +// (split on \n). Non-UTF8 bytes are replaced with U+FFFD. +func (b *OutputBuffer) Append(data string) + +// Lines returns all buffered lines (oldest first). +func (b *OutputBuffer) Lines() []string + +// Content returns all buffered lines joined with \n. +// This is what gets set on viewport.SetContent(). +func (b *OutputBuffer) Content() string + +// Tail returns the last n lines. Used for session persistence (output_tail). +func (b *OutputBuffer) Tail(n int) string + +// LineCount returns the number of buffered lines. +func (b *OutputBuffer) LineCount() int + +// TotalLines returns the total lines ever received (including truncated). +func (b *OutputBuffer) TotalLines() int + +// Truncated returns the number of lines that were discarded. +func (b *OutputBuffer) Truncated() int +``` + +--- + +## 8. Graceful Shutdown Protocol + +Triggered by `q` key (with confirmation if workers running), `ctrl+c`, or OS signals. + +```go +// gracefulShutdown returns a tea.Cmd that orchestrates the shutdown sequence. +func (m Model) gracefulShutdown() tea.Cmd { + return func() tea.Msg { + // 1. Persist current session state + m.persister.SaveSync() + + // 2. Send SIGTERM to all running workers + for _, w := range m.runningWorkers() { + w.Handle.Kill(3 * time.Second) // 3s grace, then SIGKILL + } + + // 3. Wait for all workers to exit (up to 5s total) + deadline := time.After(5 * time.Second) + for _, w := range m.runningWorkers() { + select { + case <-w.done: // channel closed when worker exits + case <-deadline: + break + } + } + + // 4. Persist final state (all workers now exited/killed) + m.persister.SaveSync() + + // 5. Exit + return tea.Quit() + } +} +``` + +### Signal Handling + +```go +// In main(), set up OS signal handling: +ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) +defer stop() + +// Pass ctx to tea.Program. When signal arrives: +// - SIGINT (ctrl+c from outside TUI): triggers graceful shutdown +// - SIGTERM: triggers graceful shutdown +// Inside TUI, ctrl+c is caught by bubbletea as tea.KeyMsg before it becomes a signal. +``` + +--- + +## 9. OpenCode Integration Contract + +### Session ID Extraction + +The OpenCode session ID must be extracted from worker output to enable continuation. +OpenCode prints session info at the start of a run. + +```go +// extractSessionID scans output lines for the OpenCode session identifier. +// Expected format in output: "[agent_name] session: ses_" +// or JSON format: {"session_id": "ses_..."} +// +// Returns empty string if not found. +func extractSessionID(output string) string { + // Pattern 1: text format + // [reviewer] session: ses_a8f3k2 + re := regexp.MustCompile(`session:\s+(ses_[a-zA-Z0-9]+)`) + if m := re.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + + // Pattern 2: JSON format (--format json output) + // {"session_id": "ses_a8f3k2", ...} + re2 := regexp.MustCompile(`"session_id"\s*:\s*"(ses_[a-zA-Z0-9]+)"`) + if m := re2.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + + return "" +} +``` + +### OpenCode CLI Invocation Patterns + +``` +# Basic worker spawn +opencode run --agent coder "Implement the auth middleware" + +# Worker with file attachments +opencode run --agent coder --file spec.md --file plan.md "Implement WP-001" + +# Session continuation +opencode run --continue -s ses_a8f3k2 --agent coder "Apply suggestions 1 and 3" + +# Model override +opencode run --agent planner --model anthropic/claude-sonnet-4-20250514 "Plan the DB migration" + +# JSON output format (useful for structured parsing) +opencode run --agent reviewer --format json "Review the auth changes" +``` + +### Dependency Validation + +```go +// ValidateDependencies checks that required external tools are available. +// Called by `kasmos setup` and at TUI startup. +func ValidateDependencies() []DependencyCheck { + return []DependencyCheck{ + {Name: "opencode", Check: func() error { + _, err := exec.LookPath("opencode") + return err + }, Required: true, InstallHint: "go install github.com/anomalyco/opencode@latest"}, + + {Name: "git", Check: func() error { + _, err := exec.LookPath("git") + return err + }, Required: true, InstallHint: "install via system package manager"}, + } +} + +type DependencyCheck struct { + Name string + Check func() error + Required bool + InstallHint string +} +``` + +--- + +## 10. Package Structure + +Recommended Go package layout for the kasmos binary: + +``` +kasmos/ +├── main.go # Entry point, flag parsing, tea.Program setup +├── cmd/ +│ ├── root.go # Root command (TUI launch) +│ └── setup.go # `kasmos setup` subcommand +├── internal/ +│ ├── tui/ +│ │ ├── model.go # Main Model struct, Init(), Update(), View() +│ │ ├── keys.go # keyMap, defaultKeyMap(), ShortHelp(), FullHelp() +│ │ ├── styles.go # All lipgloss styles, colors, indicators +│ │ ├── messages.go # All tea.Msg types +│ │ ├── commands.go # All tea.Cmd constructors (spawn, kill, read, etc.) +│ │ ├── layout.go # Layout calculation, breakpoints, recalculateLayout() +│ │ ├── panels.go # Panel rendering (table, viewport, tasks, status bar) +│ │ ├── overlays.go # Overlay rendering (spawn dialog, help, quit confirm) +│ │ ├── daemon.go # Daemon mode event logging +│ │ └── update.go # Update dispatch (updateTableKeys, updateViewportKeys, etc.) +│ ├── worker/ +│ │ ├── backend.go # WorkerBackend interface +│ │ ├── subprocess.go # SubprocessBackend implementation +│ │ ├── worker.go # Worker struct, WorkerState, lifecycle +│ │ ├── output.go # OutputBuffer +│ │ ├── manager.go # WorkerManager (orchestrates spawns, tracks workers) +│ │ └── session.go # Session ID extraction from output +│ ├── task/ +│ │ ├── source.go # Source interface, Task struct, TaskState +│ │ ├── speckitty.go # SpecKittySource implementation +│ │ ├── gsd.go # GsdSource implementation +│ │ └── adhoc.go # AdHocSource implementation +│ ├── persist/ +│ │ ├── session.go # SessionPersister, save/load, atomic write +│ │ └── schema.go # SessionState struct (maps to JSON schema) +│ └── setup/ +│ ├── setup.go # Setup orchestration (validate deps, scaffold agents) +│ ├── agents.go # Agent definition templates +│ └── deps.go # Dependency validation +├── go.mod +├── go.sum +└── .goreleaser.yml # Build configuration +``` + +### Key Dependencies (go.mod) + +``` +module github.com/user/kasmos + +go 1.23 + +require ( + github.com/charmbracelet/bubbletea v2.0.0 + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/lipgloss v2.0.0 + github.com/charmbracelet/huh v0.6.0 + github.com/muesli/gamut v0.3.1 + github.com/mattn/go-isatty v0.0.20 + github.com/spf13/cobra v1.8.0 + gopkg.in/yaml.v3 v3.0.1 +) +``` + +### Build Tags + +```go +//go:build !test + +// The daemon mode detection uses isatty, which needs a real terminal. +// Tests should mock the terminal detection. +``` + +--- + +## Cross-Reference: Design Artifacts ↔ Technical Artifacts + +| Design Artifact (design-artifacts/) | Technical Contract (this document) | +|--------------------------------------|-------------------------------------| +| tui-layout-spec.md § Panel Specs | §10 Package Structure (panels.go) | +| tui-layout-spec.md § Focus System | §2 focusChangedMsg, §3 panel enum | +| tui-mockups.md § V2 Spawn Dialog | §2 spawnDialogSubmittedMsg | +| tui-mockups.md § V5 Continue Dialog | §2 continueDialogSubmittedMsg | +| tui-mockups.md § V7 Worker Chains | §3 Worker.ParentID, Children() | +| tui-mockups.md § V9 AI Analysis | §2 analyzeCompletedMsg | +| tui-mockups.md § V10 Daemon Mode | §6 Daemon Mode Output Format | +| tui-keybinds.md § keys.go | §2 Message types triggered by keys | +| tui-styles.md § WorkerState | §3 Worker.State, WorkerState enum | +| tui-styles.md § TaskState | §4 Task.State, TaskState enum | From 5fef88705e9d74d65a472c05445b37c5cae113f7 Mon Sep 17 00:00:00 2001 From: kas Date: Tue, 17 Feb 2026 23:15:44 -0600 Subject: [PATCH 02/26] Add plan for feature 016-kasmos-agent-orchestrator --- .../016-kasmos-agent-orchestrator/plan.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/plan.md diff --git a/kitty-specs/016-kasmos-agent-orchestrator/plan.md b/kitty-specs/016-kasmos-agent-orchestrator/plan.md new file mode 100644 index 0000000..88c4df0 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/plan.md @@ -0,0 +1,108 @@ +# Implementation Plan: [FEATURE] +*Path: [templates/plan-template.md](templates/plan-template.md)* + + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] +**Input**: Feature specification from `/kitty-specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/spec-kitty.plan` command. See `src/specify_cli/missions/software-dev/command-templates/plan.md` for the execution workflow. + +The planner will not begin until all planning questions have been answered—capture those answers in this document before progressing to later phases. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] +**Project Type**: [single/web/mobile - determines source structure] +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +``` +kitty-specs/[###-feature]/ +├── plan.md # This file (/spec-kitty.plan command output) +├── research.md # Phase 0 output (/spec-kitty.plan command) +├── data-model.md # Phase 1 output (/spec-kitty.plan command) +├── quickstart.md # Phase 1 output (/spec-kitty.plan command) +├── contracts/ # Phase 1 output (/spec-kitty.plan command) +└── tasks.md # Phase 2 output (/spec-kitty.tasks command - NOT created by /spec-kitty.plan) +``` + +### Source Code (repository root) + + +``` +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Complexity Tracking + +*Fill ONLY if Constitution Check has violations that must be justified* + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | \ No newline at end of file From 440978340c1565d68f72295108921b7c53e8a9ac Mon Sep 17 00:00:00 2001 From: kas Date: Tue, 17 Feb 2026 23:20:17 -0600 Subject: [PATCH 03/26] plan(016): implementation plan for kasmos Go/bubbletea agent orchestrator Phase 0+1 planning artifacts: - plan.md: 3-wave implementation plan (core TUI, task sources, daemon/persistence) - research.md: consolidated research decisions (6 key decisions with rationale) - data-model.md: entity definitions, state machines, interface contracts - quickstart.md: development bootstrap and workflow guide Updated project memory for Go rewrite: - constitution.md v2: Go/bubbletea stack, opencode harness, testing standards - architecture.md: new package structure, worker lifecycle, message flow - AGENTS.md: Go build commands, updated repository layout --- .kittify/memory/architecture.md | 249 +++++++-------- .kittify/memory/constitution.md | 47 +-- AGENTS.md | 64 ++-- .../data-model.md | 143 +++++++++ .../016-kasmos-agent-orchestrator/plan.md | 300 +++++++++++++----- .../quickstart.md | 100 ++++++ .../016-kasmos-agent-orchestrator/research.md | 83 +++++ 7 files changed, 726 insertions(+), 260 deletions(-) create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/data-model.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/quickstart.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/research.md diff --git a/.kittify/memory/architecture.md b/.kittify/memory/architecture.md index 25a50f7..7e62221 100644 --- a/.kittify/memory/architecture.md +++ b/.kittify/memory/architecture.md @@ -2,164 +2,161 @@ > Codebase discoveries and architectural knowledge accumulated during development. > This file is the authority on how kasmos internals work and interact. -> Updated: 2026-02-16 +> Updated: 2026-02-17 ## System Overview -kasmos is an MCP-first orchestration CLI. It has three runtime modes: +kasmos is a Go/bubbletea TUI-based agent orchestrator. It manages concurrent AI coding +agent sessions (OpenCode workers) from a terminal dashboard. The human drives orchestration +directly -- no manager AI agent, zero token cost for orchestration. -1. **Bootstrap/launcher** (`kasmos [PREFIX]`) -- resolves a feature spec, runs preflight checks, acquires a feature lock, generates a KDL layout, and creates a Zellij session/tab with a manager agent pane, message-log pane, dashboard pane, and worker area. -2. **MCP server** (`kasmos serve`) -- stdio transport server providing tools for worker lifecycle, message reading, workflow status, WP lane transitions, and feature lock management. Spawned as a subprocess by the manager agent (not as a separate pane). -3. **Utilities** (`kasmos setup`, `kasmos list`, `kasmos status`) -- environment validation, feature listing, and progress reporting. +### Runtime Modes -Legacy FIFO command handling, TUI modules, and the old wave-engine orchestration path were removed from the crate. kasmos now runs only through launch + MCP server flows. +1. **TUI mode** (`kasmos [path]`) - Interactive terminal dashboard with worker table, + output viewport, task panel. Responsive layout at 4 breakpoints. +2. **Daemon mode** (`kasmos -d`) - Same Model/Update loop, no View rendering + (`WithoutRenderer()`). Status events logged to stdout as NDJSON or human-readable text. +3. **Setup** (`kasmos setup`) - Scaffolds `.opencode/agents/*.md` definitions and validates + dependencies (opencode, git). +4. **Reattach** (`kasmos --attach`) - Reconnects TUI to a running daemon session, + restoring worker states from `.kasmos/session.json`. -## Worktree Structure - -kasmos uses git worktrees for WP isolation during orchestration. - -- Location: `/.worktrees/-/` -- Each worktree is a full repo checkout on its own branch. -- The worktree contains its own copy of `kitty-specs/` files. -- The `.kittify/memory/` directory inside worktrees is a **symlink** back to the main repo's `.kittify/memory/`, so constitution and memory are shared. - -### Worktree vs main repo file paths - -This is a critical distinction that affects multiple subsystems: - -- **Main repo** `kitty-specs//tasks/WPxx.md` -- the canonical task files, versioned in git. -- **Worktree** `.worktrees/-/kitty-specs//tasks/WPxx.md` -- the agent's working copy. - -When an agent modifies a task file (e.g., moving its lane from `doing` to `for_review`), it modifies the **worktree copy**, not the main repo copy. Any subsystem that inspects task changes must read from the worktree path, not the main repo path, when worktrees are in use. - -## Zellij Integration - -### Session architecture (MCP era) - -- `kasmos [PREFIX]` creates or attaches to a Zellij session named per `config.session.session_name` (default: `kasmos`). -- If already inside Zellij, it creates a new tab instead of a new session (`launch/session.rs`). -- The layout has a fixed top row (22% height) with three panes: **manager** (left), **msg-log** (center), **dashboard** (right). Below is the **worker-area** where worker panes are dynamically spawned. -- `swap_tiled_layout` rules handle reflowing as workers are added/removed (up to `max_parallel_workers + 3` panes). -- The session starts in Zellij `locked` mode to avoid accidental keybind interference. - -### Layout structure (from `launch/layout.rs`) +## Package Architecture ``` -+---manager(60%)---+--msg-log(20%)--+--dashboard(20%)--+ <- 22% height -| | -| worker-area | <- remaining -| | -+-------------------------------------------------------+ +cmd/kasmos/main.go Entry point, cobra commands, tea.Program setup +internal/tui/ bubbletea TUI (Elm architecture) + model.go Main Model struct, Init(), Update(), View() + update.go Update dispatch per panel/overlay + keys.go keyMap with context-dependent activation + styles.go lipgloss palette, component styles, indicators + messages.go All tea.Msg types (worker, UI, overlay, task, persist) + commands.go All tea.Cmd constructors + layout.go Responsive breakpoints, dimension math + panels.go Panel rendering (table, viewport, tasks, status bar) + overlays.go Overlays (spawn dialog, continue, help, quit confirm) + daemon.go Daemon mode event logging +internal/worker/ Worker process management + backend.go WorkerBackend interface + subprocess.go SubprocessBackend (os/exec) + worker.go Worker struct, WorkerState state machine + output.go OutputBuffer (thread-safe ring buffer) + session.go OpenCode session ID extraction + manager.go WorkerManager (ID generation, worker tracking) +internal/task/ Task source adapters + source.go Source interface, Task struct, TaskState + speckitty.go SpecKittySource (plan.md + tasks/WP*.md frontmatter) + gsd.go GsdSource (checkbox markdown) + adhoc.go AdHocSource (empty, manual prompts) +internal/persist/ Session persistence + session.go SessionPersister (debounced atomic JSON writes) + schema.go SessionState struct +internal/setup/ Setup command + setup.go Orchestration (deps + scaffolding) + agents.go Agent definition templates + deps.go Dependency validation ``` -Width percentages are configurable via `session.manager_width_pct` and `session.message_log_width_pct`. Dashboard gets the remainder. - -### Manager pane command +## Worker Lifecycle -The manager pane runs `ocx oc [-p ] -- --agent manager --prompt `. The prompt is built by `RolePromptBuilder` with a phase hint (specify/plan/tasks/implement) derived from which artifacts exist in the feature directory. - -`kasmos serve` is NOT a pane command -- it runs as an MCP stdio subprocess owned by the manager agent's OpenCode/Claude Code profile config. - -### Zellij CLI limitations (v0.41+) +``` +StatePending -> StateSpawning -> StateRunning --+--> StateExited (code 0) + +--> StateFailed (code != 0) + +--> StateKilled (user kill) +``` -- There is **no** `list-panes` or `focus-pane-by-name` CLI command. -- Inside a Zellij session, use `zellij action ` directly (no `--session` flag needed). -- From outside a session, use `zellij --session action `. -- Worker panes are tracked by the MCP server's `WorkerRegistry` (in-memory HashMap keyed by `wp_id:role`), not by Zellij introspection. +Workers are spawned as child processes via `opencode run --agent "prompt"`. +Stdout and stderr are merged into a single pipe, read by a goroutine that sends +`workerOutputMsg` through `tea.Program.Send()`. When the process exits, a +`workerExitedMsg` is sent with the exit code and parsed session ID. -## MCP Server Architecture +Continuation: `opencode run --continue -s "follow-up message"` spawns +a NEW worker (new ID, new process) that preserves the parent session's full context. +The child worker's `ParentID` field links it to its parent for tree display. -### Server (`serve/mod.rs`) +## Key Interfaces -`KasmosServer` is an `rmcp` server with stdio transport. State: -- `config: Config` -- loaded from `kasmos.toml` + env -- `registry: Arc>` -- tracks spawned workers -- `message_cursor: Arc>` -- tracks read position in message log -- `feature_slug: Option` -- inferred from `specs_root` path -- `audit: Arc>>` -- per-feature audit log +### WorkerBackend -### MCP tools (9 registered) +```go +type WorkerBackend interface { + Spawn(ctx context.Context, cfg SpawnConfig) (WorkerHandle, error) + Name() string +} +``` -| Tool | Purpose | -|------|---------| -| `spawn_worker` | Create a planner/coder/reviewer/release worker pane | -| `despawn_worker` | Close a worker pane and remove from registry | -| `list_workers` | List tracked workers with status filter | -| `read_messages` | Parse message-log pane events | -| `wait_for_event` | Block until matching event or timeout | -| `workflow_status` | Return feature phase, wave status, lock metadata | -| `transition_wp` | Validate and apply WP lane transitions in task files | -| `list_features` | List known specs and artifact availability | -| `infer_feature` | Resolve feature slug from arg, branch, or cwd | +MVP: `SubprocessBackend` (os/exec). Future: `TmuxBackend`. -### Agent roles +### Source (Task) -Defined in `serve/registry.rs`. Worker roles: `planner`, `coder`, `reviewer`, `release`. The `Manager` role exists in `prompt.rs` but is never a registry worker -- it's the orchestrator agent that calls MCP tools. +```go +type Source interface { + Type() string // "spec-kitty", "gsd", "ad-hoc" + Path() string // file/directory path + Load() ([]Task, error) + Tasks() []Task +} +``` -### Context boundaries (`prompt.rs`) +### bubbletea Message Flow -Each role gets different context injected into its prompt: +``` +User input (tea.KeyMsg) -> Update() -> tea.Cmd (side effect) -> tea.Msg (result) -> Update() -> View() +Worker event flow: spawnWorkerCmd -> workerSpawnedMsg -> readOutputCmd -> workerOutputMsg(loop) -> workerExitedMsg +Timer: tea.Tick(1s) -> tickMsg (duration refresh) +Spinner: spinner.Tick -> spinner.TickMsg (animation) +``` -| Context | Manager | Planner | Coder | Reviewer | Release | -|---------|---------|---------|-------|----------|---------| -| spec.md | yes | yes | no | no | no | -| plan.md | yes | yes | no | no | no | -| all tasks | yes | no | no | no | yes | -| architecture memory | yes | yes | yes | yes | no | -| workflow intelligence | yes | yes | no | no | no | -| constitution | yes | yes | yes | yes | yes | -| project structure | yes | yes | no | no | yes | -| WP task file | no | no | yes | yes | no | -| coding standards | no | no | yes | yes | no | +## Task Source Patterns -## Configuration +### spec-kitty (SpecKittySource) -Config is loaded from `kasmos.toml` (discovered by walking up from CWD) with `KASMOS_*` env var overrides. Key sections: +Reads `kitty-specs//` directory: +- `plan.md` for WP summaries (markdown, not structured) +- `tasks/WP*.md` for work packages with YAML frontmatter -| Section | Key fields | Location | -|---------|-----------|----------| -| `[agent]` | `max_parallel_workers`, `opencode_binary`, `opencode_profile`, `review_rejection_cap` | `config.rs:51` | -| `[communication]` | `poll_interval_secs`, `event_timeout_secs` | `config.rs:64` | -| `[paths]` | `zellij_binary`, `spec_kitty_binary`, `specs_root` | `config.rs:73` | -| `[session]` | `session_name`, `manager_width_pct`, `message_log_width_pct`, `max_workers_per_row` | `config.rs:84` | -| `[audit]` | `metadata_only`, `debug_full_payload`, `max_bytes`, `max_age_days` | `config.rs:97` | -| `[lock]` | `stale_timeout_minutes` | `config.rs:110` | +Frontmatter fields: `work_package_id`, `title`, `dependencies`, `lane`, `subtasks`, `phase` +Lane mapping: planned -> TaskUnassigned, doing -> TaskInProgress, for_review -> TaskInProgress, done -> TaskDone +Role inference from phase: spec/clarifying -> planner, implementation -> coder, reviewing -> reviewer, releasing -> release -Legacy flat keys (`max_agent_panes`, `controller_width_pct`, etc.) are still accepted for backward compatibility and synced into the sectioned fields. +### GSD (GsdSource) -## Key Type Definitions +Reads a markdown file with checkboxes: +``` +- [ ] Implement auth -> Task{ID: "T-001", State: TaskUnassigned} +- [x] Review PR #42 -> Task{ID: "T-002", State: TaskDone} +``` -| Type | Location | Notes | -|------|----------|-------| -| `KasmosServer` | `serve/mod.rs` | MCP server with tool router, registry, audit | -| `WorkerRegistry` | `serve/registry.rs` | In-memory worker tracking, keyed by `wp_id:role` | -| `WorkerEntry` | `serve/registry.rs` | Worker metadata: role, pane_name, status, events | -| `AgentRole` (worker) | `serve/registry.rs` | Planner, Coder, Reviewer, Release | -| `AgentRole` (prompt) | `prompt.rs` | Adds Manager variant for orchestrator prompts | -| `OrchestrationLayout` | `launch/layout.rs` | KDL layout builder with swap-tiled reflow | -| `ManagerCommand` | `launch/layout.rs` | Manager pane command (binary, profile, prompt) | -| `RolePromptBuilder` | `prompt.rs` | Context-boundary-aware prompt construction | -| `FeatureLockManager` | `serve/lock.rs` | Per-feature lock with heartbeat and stale detection | -| `Config` | `config.rs` | Sectioned TOML config with env overrides | -| `WorkPackage` | `types.rs` | Has `pane_id: Option`, `worktree_path`, `pane_name` | -| `FeatureDetection` | `launch/detect.rs` | Feature resolution result from arg/branch/cwd | -| `FeatureSource` | `launch/detect.rs` | Source enum for feature detection priority | +### Ad-hoc (AdHocSource) -## Agent Permissions and External Directories +No file. Empty task list. Workers spawned with manual prompts only. -### Problem discovered (2026-02-14) +## Session Persistence -Agents running in worktrees (e.g., `.worktrees/011-...-WP02/`) need read access to paths outside their CWD -- specifically the main repo's `kitty-specs/` directory (which is gitignored and doesn't exist in worktrees) and `/tmp/` (where spec-kitty writes review prompts). +State persisted to `.kasmos/session.json`: +- Schema version 1 +- Session ID (ks-timestamp-random) +- All workers (active + historical) with output tails +- Task source configuration +- PID for reattach detection -OpenCode's `external_directory` permission config does **not** expand `~` to the home directory. Paths like `"~/dev/kasmos/**": "allow"` silently fail to match absolute paths like `/home/kas/dev/kasmos/kitty-specs/...`, causing `auto-rejecting` when the agent runs non-interactively (e.g., `ocx oc -- run`). +Write behavior: debounced to 1 write/second, atomic (write .tmp then rename). +Reattach: if session.json exists and PID is alive, connect to running session. +Orphan recovery: if PID is dead, mark running workers as killed, assign new PID. -**Fix**: Always use fully-qualified absolute paths in `external_directory` rules. +## Design Reference -### Paths agents commonly need +Visual design defined in `design-artifacts/`: +- `tui-layout-spec.md` - 4 responsive breakpoints, dimension math, focus system +- `tui-mockups.md` - 12 ASCII mockups covering all view states +- `tui-keybinds.md` - Full keybind map with implementation code +- `tui-styles.md` - Charm bubblegum palette, component styles, status indicators -| Path | Who needs it | Why | -|------|-------------|-----| -| `/home/kas/dev/kasmos/**` (main repo) | All agents | `kitty-specs/`, `.kittify/memory/`, docs | -| `/tmp/*`, `/tmp/**` | All agents | spec-kitty review prompts, temp files | -| `~/.config/opencode/**` | All agents | Self-reference for config | -| `~/.config/zellij/**` | Manager, release | Layout management | +Technical contracts in `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: +- Go interface definitions (WorkerBackend, Source, WorkerHandle) +- Complete tea.Msg type catalog (20+ types) +- Session persistence JSON schema +- Daemon mode NDJSON event schema +- Output buffer ring design +- Graceful shutdown protocol +- Package structure diff --git a/.kittify/memory/constitution.md b/.kittify/memory/constitution.md index 6c4276a..4938994 100644 --- a/.kittify/memory/constitution.md +++ b/.kittify/memory/constitution.md @@ -1,46 +1,57 @@ # kasmos Constitution -> Auto-generated by spec-kitty constitution command -> Created: 2026-02-10 -> Version: 1.0.0 +> Updated: 2026-02-17 +> Version: 2.0.0 ## Purpose This constitution captures the technical standards and governance rules for kasmos, -a Zellij-based agent orchestrator for managing concurrent AI coding sessions. +a TUI-based agent orchestrator for managing concurrent AI coding sessions. All features and pull requests should align with these principles. ## Technical Standards ### Languages and Frameworks -- **Rust** (latest stable, 2024 edition) -- **tokio** for async runtime -- **ratatui** for terminal UI -- **Zellij** as the terminal multiplexer substrate -- **just** command runner for task automation -- **OpenCode** as the primary AI coding agent (optionally **Claude Code**) +- **Go** (1.23+) +- **bubbletea** v2 for TUI (Elm architecture: Model/Update/View) +- **lipgloss** v2 for terminal styling +- **bubbles** for TUI components (table, viewport, textinput, list, spinner, help) +- **huh** for form dialogs +- **cobra** for CLI command structure +- **OpenCode** as the sole AI agent harness (`opencode run` for headless workers) ### Testing Requirements -- Use `cargo test` for all testing +- Use `go test ./...` for all testing - All features must have corresponding tests +- Standard library `testing` package; table-driven tests for parsers and state machines +- Mock `WorkerBackend` for TUI tests (no real subprocess spawning in unit tests) +- Integration tests gated behind `KASMOS_INTEGRATION=1` env var - No hard coverage target, but untested features are not considered complete -- Unit tests for core logic, integration tests where feasible ### Performance and Scale -- TUI must remain responsive at all times — never block the render loop -- Support orchestrating many concurrent agent panes without degradation -- Async operations must not starve the event loop -- Minimize unnecessary allocations in hot paths +- TUI must remain responsive at all times - never block the Update loop +- Support orchestrating many concurrent workers without degradation +- Worker output reading must be async (goroutines + channels, surfaced as tea.Msg) +- Minimize unnecessary allocations in hot paths (output buffer ring, not unbounded slices) + +### Architecture Principles + +- **No manager AI agent** - the TUI is the orchestrator. Zero token cost for orchestration. +- **Workers are headless subprocesses** - spawned via `opencode run`, output captured via Go pipes. +- **Session continuation over interactivity** - `opencode run --continue -s ` preserves context without PTY allocation. +- **Pluggable WorkerBackend interface** - SubprocessBackend (MVP), TmuxBackend (future). +- **Three task source adapters** - spec-kitty (plan.md/WP frontmatter), GSD (checkbox markdown), ad-hoc (manual prompts). +- **Daemon mode** - same Model/Update loop, no View rendering (`WithoutRenderer()`). ### Deployment and Constraints - **Linux**: Primary platform (full support) - **macOS**: Secondary platform (best-effort support) -- **Runtime dependencies**: Zellij and OpenCode must be installed and in PATH -- Distributed as a single binary (standard `cargo install` workflow) +- **Runtime dependencies**: OpenCode and git must be installed and in PATH +- Distributed as a single binary (standard `go install` or goreleaser workflow) ## Governance diff --git a/AGENTS.md b/AGENTS.md index c7434ec..861d1ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,61 +4,53 @@ - Read `README.md` for project overview. - Read `.kittify/memory/` for project constitution, architecture knowledge, and workflow intelligence. - Check `kitty-specs/` for feature specifications. -- This is a Rust workspace -- crates live in `crates/`. -- Primary binary: `crates/kasmos/` -- the Zellij orchestrator. +- This is a Go module. Source lives at the repository root. +- Primary binary: `cmd/kasmos/` - the TUI agent orchestrator. ## Repository layout -- `crates/kasmos/`: Main orchestrator binary +- `cmd/kasmos/`: Entry point (main.go) +- `internal/tui/`: bubbletea TUI (model, update, view, styles, keys) +- `internal/worker/`: Worker backend interface, subprocess backend, output buffer +- `internal/task/`: Task source adapters (spec-kitty, GSD, ad-hoc) +- `internal/persist/`: Session persistence (JSON) +- `internal/setup/`: `kasmos setup` subcommand (agent scaffolding, dep validation) - `kitty-specs/`: Feature specifications (spec-kitty) +- `design-artifacts/`: TUI visual design (mockups, layout, styles, keybinds) - `.kittify/memory/`: Persistent project memory (constitution, architecture, workflow learnings) - `.kittify/`: spec-kitty project configuration, scripts, missions -- `docs/`: Documentation ## Build / run commands -- Build: `cargo build` -- Run: `cargo run -p kasmos` -- Test: `cargo test` - -## Code style (Rust) -- Use Rust 2024 edition conventions -- Prefer explicit error handling with `thiserror` / `anyhow` -- Use `tokio` for async runtime -- Follow standard Rust naming: `snake_case` functions, `PascalCase` types -- Keep modules small and focused +- Build: `go build ./cmd/kasmos` +- Run: `go run ./cmd/kasmos` +- Test: `go test ./...` +- Test (integration): `KASMOS_INTEGRATION=1 go test ./...` +- Lint: `golangci-lint run` + +## Code style (Go) +- Follow standard Go conventions (gofmt, go vet) +- Use `internal/` for non-exported packages +- Prefer explicit error handling with `fmt.Errorf` wrapping +- Use table-driven tests +- Follow standard Go naming: camelCase unexported, PascalCase exported +- Keep packages small and focused ## External tools -- `zellij`: Terminal multiplexer (must be in PATH) +- `opencode`: AI coding agent harness (workers spawned via `opencode run`) - `spec-kitty`: Feature specification tool -- `opencode`: AI coding agent harness (launched in Zellij panes) - `git`: Version control ## Agent harness: OpenCode only kasmos uses **OpenCode** as the sole agent harness for spawning worker agents. This is a hard rule: -- Worker panes are launched via `opencode [-p ] -- --agent --prompt `. -- The `opencode_binary` and `opencode_profile` are configured in `kasmos.toml` under `[agent]`. -- **Never invoke a model-specific CLI** (e.g., `claude`, `gemini`, `aider`) directly. OpenCode is the abstraction layer -- it handles model selection, permissions, and session management. -- kasmos is **model-agnostic**. The model running behind OpenCode is configured in OpenCode's own config, not in kasmos. Do not assume or hardcode any specific model provider. -- When spawning workers programmatically, always go through kasmos MCP tools (`spawn_worker`) or `opencode`. Never shell out to a bare model CLI. -- If you are the manager agent and need to delegate work to a new pane, use `kasmos serve`'s `spawn_worker` MCP tool, which handles the OpenCode invocation internally. - -## Worktree awareness - -kasmos uses git worktrees at `.worktrees/-/` for WP isolation. When modifying code that deals with file paths -- especially task file watching, file scanning, or agent CWD setup -- always consider whether the path should point to the main repo or the worktree. See `.kittify/memory/architecture.md` for the full explanation and known issues. - -Key rule: agents work in worktrees, so any file they modify is the worktree copy. Watchers/detectors that need to see agent changes must watch the worktree path, not the main repo path. - -## Zellij constraints - -- There is no `list-panes` or `focus-pane-by-name` CLI command (as of Zellij 0.41+). -- Inside a Zellij session, use `zellij action ` directly (no `--session` flag). -- Pane tracking is internal via `SessionManager` HashMap -- do not assume Zellij provides pane discovery. -- See `.kittify/memory/architecture.md` for session layout and pane naming conventions. +- Workers are spawned via `opencode run --agent "prompt"`. +- **Never invoke a model-specific CLI** (e.g., `claude`, `gemini`, `aider`) directly. OpenCode is the abstraction layer. +- kasmos is **model-agnostic**. The model running behind OpenCode is configured in OpenCode's own config, not in kasmos. +- Session continuation uses `opencode run --continue -s "follow-up"`. ## Persistent memory -When you discover something significant about the codebase architecture, runtime behavior, or integration quirks, record it in `.kittify/memory/`. This directory is symlinked into worktrees so all sessions share it. +When you discover something significant about the codebase architecture, runtime behavior, or integration quirks, record it in `.kittify/memory/`. - `constitution.md`: Project technical standards and governance (do not modify without discussion). - `architecture.md`: Codebase structure, type locations, subsystem interactions, known issues. diff --git a/kitty-specs/016-kasmos-agent-orchestrator/data-model.md b/kitty-specs/016-kasmos-agent-orchestrator/data-model.md new file mode 100644 index 0000000..b600311 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/data-model.md @@ -0,0 +1,143 @@ +# Data Model: kasmos Agent Orchestrator + +**Feature**: 016-kasmos-agent-orchestrator +**Date**: 2026-02-17 + +## Entity Relationship Diagram + +``` +Session 1---* Worker +Worker 0..1---1 Worker (parent -> child continuation) +Worker 0..1---1 Task +Source 1---* Task + +Session --> SessionPersister --> .kasmos/session.json +Worker --> WorkerBackend --> os/exec (SubprocessBackend) +Worker --> OutputBuffer (ring buffer, 5000 lines default) +Source <|-- SpecKittySource | GsdSource | AdHocSource +``` + +## Entities + +### Worker + +The central entity. Represents a managed OpenCode agent process. + +| Field | Type | Description | Validation | +|--------------|---------------|----------------------------------------------------|-----------------------------------------| +| ID | string | Unique worker identifier | Format: `w-NNN`, auto-incremented | +| Role | string | Agent role | One of: planner, coder, reviewer, release | +| Prompt | string | Task prompt sent to agent | Non-empty | +| Files | []string | Attached file paths | Valid paths (not validated at model layer) | +| State | WorkerState | Current lifecycle state | Enum, see state machine below | +| ExitCode | int | Process exit code | Set only when State is Exited/Failed | +| SpawnedAt | time.Time | When worker was spawned | Set on spawn | +| ExitedAt | time.Time | When worker exited | Zero if still running | +| SessionID | string | OpenCode session ID | Extracted from output via regex | +| ParentID | string | Parent worker ID (continuations) | Empty if root worker | +| TaskID | string | Associated task from source | Empty for ad-hoc workers | + +**State Machine**: + +``` +StatePending --> StateSpawning --> StateRunning --+--> StateExited (code 0) + +--> StateFailed (code != 0) + +--> StateKilled (user kill) +``` + +Transitions: +- Pending -> Spawning: User confirms spawn dialog +- Spawning -> Running: Process started successfully (workerSpawnedMsg) +- Running -> Exited/Failed/Killed: Process exits or user kills (workerExitedMsg/workerKilledMsg) +- Exited/Failed/Killed -> (new Worker): Continue or Restart creates a NEW worker, not a state change + +### Task + +A work item from an external task source. + +| Field | Type | Description | Validation | +|----------------|---------------|----------------------------------------------|-----------------------------------| +| ID | string | Task identifier | Source-dependent format | +| Title | string | Short task name | Non-empty | +| Description | string | Detailed description (used as default prompt)| May be empty for GSD | +| SuggestedRole | string | Recommended agent role | Empty if not inferrable | +| Dependencies | []string | Task IDs this depends on | Empty for GSD/ad-hoc | +| State | TaskState | Assignment status | Enum: Unassigned/Blocked/InProgress/Done/Failed | +| WorkerID | string | Assigned worker ID | Empty if unassigned | +| Metadata | map[string]string | Source-specific extra data | spec-kitty: phase, lane, subtasks | + +**State Machine**: + +``` +TaskUnassigned --+--> TaskInProgress (worker spawned for this task) + +--> TaskBlocked (dependency not met) +TaskBlocked ----> TaskUnassigned (dependency resolved) +TaskInProgress --+--> TaskDone (worker exited successfully) + +--> TaskFailed (worker failed) +TaskFailed ----> TaskInProgress (worker restarted) +``` + +### SessionState (persistence) + +Serialized to `.kasmos/session.json`. See `research/tui-technical.md` Section 5 for full JSON schema. + +| Field | Type | Description | +|-----------------|---------------|----------------------------------------------| +| Version | int | Schema version (currently 1) | +| SessionID | string | Unique session identifier (ks-timestamp-rand)| +| StartedAt | time.Time | Session creation time | +| TaskSource | *TaskSourceConfig | Source type + path (null for ad-hoc) | +| Workers | []WorkerSnapshot | All workers (active + historical) | +| NextWorkerNum | int | Next worker ID number | +| PID | int | kasmos process PID (for reattach detection) | + +### SpawnConfig (runtime only, not persisted) + +Parameters for creating a worker via `WorkerBackend.Spawn()`. + +| Field | Type | Description | +|------------------|---------------|-------------------------------------------| +| ID | string | Pre-assigned worker ID | +| Role | string | Agent role for --agent flag | +| Prompt | string | Task prompt (final argument) | +| Files | []string | Paths for --file flags | +| ContinueSession | string | Session ID for --continue -s flag | +| Model | string | Model override for --model flag | +| WorkDir | string | Working directory (defaults to project root)| +| Env | map[string]string | Additional environment variables | + +## Interfaces + +### WorkerBackend + +```go +type WorkerBackend interface { + Spawn(ctx context.Context, cfg SpawnConfig) (WorkerHandle, error) + Name() string +} +``` + +### WorkerHandle + +```go +type WorkerHandle interface { + Stdout() io.Reader + Wait() ExitResult + Kill(gracePeriod time.Duration) error + PID() int +} +``` + +### Source (Task Source) + +```go +type Source interface { + Type() string + Path() string + Load() ([]Task, error) + Tasks() []Task +} +``` + +Full interface definitions with implementation details are in +`kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`. diff --git a/kitty-specs/016-kasmos-agent-orchestrator/plan.md b/kitty-specs/016-kasmos-agent-orchestrator/plan.md index 88c4df0..352ebab 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/plan.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/plan.md @@ -1,108 +1,248 @@ -# Implementation Plan: [FEATURE] -*Path: [templates/plan-template.md](templates/plan-template.md)* +# Implementation Plan: kasmos - Agent Orchestrator +**Branch**: `charm` | **Date**: 2026-02-17 | **Spec**: `kitty-specs/016-kasmos-agent-orchestrator/spec.md` +**Input**: Feature specification from `kitty-specs/016-kasmos-agent-orchestrator/spec.md` -**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] -**Input**: Feature specification from `/kitty-specs/[###-feature-name]/spec.md` - -**Note**: This template is filled in by the `/spec-kitty.plan` command. See `src/specify_cli/missions/software-dev/command-templates/plan.md` for the execution workflow. +## Summary -The planner will not begin until all planning questions have been answered—capture those answers in this document before progressing to later phases. +kasmos is a complete rewrite of the Zellij/Rust agent orchestrator as a Go/bubbletea +terminal application. It provides a TUI dashboard for spawning, monitoring, killing, +and continuing concurrent AI coding agent sessions (OpenCode workers). The TUI is the +orchestrator - no manager AI agent, zero token cost for orchestration, deterministic +and instant. -## Summary +**Technical approach**: bubbletea Elm architecture drives the event loop. Workers are +headless `opencode run` subprocesses with stdout/stderr captured via Go pipes. Session +continuation (`--continue -s `) replaces interactive terminal access. A pluggable +`WorkerBackend` interface allows future backends (tmux) without TUI changes. Three task +source adapters (spec-kitty, GSD, ad-hoc) connect orchestration to planning pipelines. -[Extract from feature spec: primary requirement + technical approach from research] +**Design artifacts**: Four TUI design documents define the visual system +(`design-artifacts/tui-layout-spec.md`, `tui-mockups.md`, `tui-keybinds.md`, +`tui-styles.md`). A technical research document defines all Go interfaces, message +types, JSON schemas, and package structure +(`kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`). ## Technical Context - - -**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] -**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] -**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] -**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] -**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] -**Project Type**: [single/web/mobile - determines source structure] -**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] -**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] -**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] +**Language/Version**: Go 1.23+ +**Primary Dependencies**: bubbletea v2, lipgloss v2, bubbles, huh, cobra, gamut, go-isatty, yaml.v3 +**Storage**: File-based JSON (`.kasmos/session.json` for session persistence) +**Testing**: `go test ./...`, standard library `testing`, table-driven tests, mock WorkerBackend for unit tests, integration tests gated by `KASMOS_INTEGRATION=1` +**Target Platform**: Linux (primary), macOS (best-effort) +**Project Type**: Single Go binary (CLI + TUI) +**Performance Goals**: TUI responsive at all times; worker status updates within 1s; support 10+ concurrent workers +**Constraints**: Zero token cost for orchestration; single binary with no runtime deps beyond opencode + git +**Scale/Scope**: MVP covers P1+P2 user stories (6 of 9). P3 stories (daemon, persistence, SSH) are future waves. ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -[Gates determined based on constitution file] +| Principle | Status | Notes | +|-----------|--------|-------| +| Go 1.23+ | PASS | Locked in. go.mod will target 1.23. | +| bubbletea/lipgloss/bubbles | PASS | All dependencies from charmbracelet ecosystem. | +| OpenCode sole agent harness | PASS | Workers spawned via `opencode run`. No model-specific CLIs. | +| `go test ./...` for testing | PASS | Standard library testing, table-driven, mock backend. | +| TUI never blocks Update loop | PASS | All I/O via tea.Cmd. Worker output via goroutines + tea.Msg. | +| Pluggable WorkerBackend | PASS | Interface defined in research. SubprocessBackend MVP. | +| Linux primary, macOS secondary | PASS | No Linux-only syscalls in core. `Setpgid` has macOS equivalent. | +| Single binary distribution | PASS | `go build ./cmd/kasmos` produces one binary. | +| No secrets in persistence | PASS | session.json stores worker metadata, not credentials. | + +No violations. No complexity tracking needed. ## Project Structure ### Documentation (this feature) ``` -kitty-specs/[###-feature]/ -├── plan.md # This file (/spec-kitty.plan command output) -├── research.md # Phase 0 output (/spec-kitty.plan command) -├── data-model.md # Phase 1 output (/spec-kitty.plan command) -├── quickstart.md # Phase 1 output (/spec-kitty.plan command) -├── contracts/ # Phase 1 output (/spec-kitty.plan command) -└── tasks.md # Phase 2 output (/spec-kitty.tasks command - NOT created by /spec-kitty.plan) +kitty-specs/016-kasmos-agent-orchestrator/ + plan.md # This file + spec.md # Feature specification (9 user stories, 15 FRs, 8 SCs) + project-context.md # Architecture decisions, OpenCode reference + research/ + tui-technical.md # Go interfaces, Msg types, JSON schemas, package layout + checklists/ + requirements.md # Spec quality checklist + tasks/ # WP files (generated by /spec-kitty.tasks) +``` + +### Design Artifacts + +``` +design-artifacts/ + tui-layout-spec.md # Responsive layout system, breakpoints, dimension math + tui-mockups.md # 12 ASCII mockups (dashboard, dialogs, overlays, daemon) + tui-keybinds.md # Full keybind map, keys.go implementation, routing + tui-styles.md # Color palette, component styles, status indicators ``` -### Source Code (repository root) - +### Source Code ``` -# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) -src/ -├── models/ -├── services/ -├── cli/ -└── lib/ - -tests/ -├── contract/ -├── integration/ -└── unit/ - -# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) -backend/ -├── src/ -│ ├── models/ -│ ├── services/ -│ └── api/ -└── tests/ - -frontend/ -├── src/ -│ ├── components/ -│ ├── pages/ -│ └── services/ -└── tests/ - -# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) -api/ -└── [same as backend above] - -ios/ or android/ -└── [platform-specific structure: feature modules, UI flows, platform tests] +cmd/ + kasmos/ + main.go # Entry point, flag parsing, tea.Program setup + +internal/ + tui/ + model.go # Main Model struct, Init(), top-level Update(), View() + update.go # Update dispatch (table, viewport, task, fullscreen) + keys.go # keyMap, defaultKeyMap(), ShortHelp(), FullHelp() + styles.go # lipgloss styles, colors, indicators, gradient + messages.go # All tea.Msg type definitions + commands.go # All tea.Cmd constructors (spawn, kill, read output) + layout.go # Layout calculation, breakpoints, recalculateLayout() + panels.go # Panel rendering (table, viewport, tasks, status bar) + overlays.go # Overlay rendering (spawn dialog, continue, help, quit) + daemon.go # Daemon mode event logging (NDJSON + human-readable) + worker/ + backend.go # WorkerBackend interface, SpawnConfig, WorkerHandle + subprocess.go # SubprocessBackend (os/exec MVP implementation) + worker.go # Worker struct, WorkerState enum, lifecycle + output.go # OutputBuffer (thread-safe ring buffer) + session.go # OpenCode session ID extraction from output + manager.go # WorkerManager (tracks workers, generates IDs) + task/ + source.go # Source interface, Task struct, TaskState enum + speckitty.go # SpecKittySource (reads plan.md + tasks/WP*.md) + gsd.go # GsdSource (reads checkbox markdown) + adhoc.go # AdHocSource (empty, manual prompts) + persist/ + session.go # SessionPersister (save/load, atomic write, debounce) + schema.go # SessionState struct (maps to JSON schema) + setup/ + setup.go # Setup orchestration (validate deps, scaffold agents) + agents.go # Agent definition templates (.opencode/agents/*.md) + deps.go # Dependency validation (opencode, git) + +go.mod +go.sum ``` -**Structure Decision**: [Document the selected structure and reference the real -directories captured above] +**Structure Decision**: Single Go binary with `internal/` packages for encapsulation. +No monorepo, no separate frontend/backend. The TUI and worker management are in the +same process. Package boundaries follow the architecture diagram: `tui/` owns the +display, `worker/` owns process management, `task/` owns external data adapters, +`persist/` owns serialization, `setup/` owns scaffolding. Cross-package communication +is via Go interfaces and bubbletea messages. + +## Implementation Waves + +### Wave 1: Core TUI Shell + Worker Lifecycle (P1) + +Foundation: the TUI renders, workers spawn and produce visible output, sessions continue. +This wave delivers User Stories 1, 2, and 3 (the core value proposition). + +**Dependencies**: None (greenfield) + +**Deliverables**: +- `cmd/kasmos/main.go` - binary entry point with cobra root command +- `internal/worker/` - full package: backend interface, subprocess impl, output buffer, worker types +- `internal/tui/` - model, layout, styles, keys, panels (table + viewport), spawn dialog, continue dialog +- `internal/tui/messages.go` + `commands.go` - worker lifecycle messages and commands + +**Acceptance**: Run `kasmos`, press `s` to spawn a worker, see output streaming in viewport, +press `c` on a completed worker to continue its session. All 3 P1 user stories pass. + +### Wave 2: Task Sources + Worker Management (P2) + +Connects the orchestrator to planning pipelines and adds error recovery controls. +This wave delivers User Stories 4, 5, and 6. + +**Dependencies**: Wave 1 (TUI shell + worker lifecycle must exist) + +**Deliverables**: +- `internal/task/` - full package: source interface, spec-kitty adapter, GSD adapter, ad-hoc adapter +- `internal/setup/` - full package: dep validation, agent scaffolding +- TUI additions: task panel (wide mode), kill/restart actions, batch spawn, AI helpers (analyze, gen-prompt) + +**Acceptance**: Run `kasmos kitty-specs/016-kasmos-agent-orchestrator/`, see WPs in task panel, +spawn from task. Kill a running worker, restart a failed one. Run `kasmos setup`, verify +agent definitions created. + +### Wave 3: Daemon Mode + Persistence (P3) + +Production hardening: headless operation for CI, session survival across disconnects. +This wave delivers User Stories 7 and 8. + +**Dependencies**: Wave 2 (task sources and full worker management) + +**Deliverables**: +- `internal/tui/daemon.go` - daemon mode event logging +- `internal/persist/` - full package: session state persistence, reattach +- `cmd/kasmos/main.go` additions: `-d` flag, `--attach` flag, `--format` flag +- Graceful shutdown protocol (SIGTERM -> grace -> SIGKILL -> persist) + +**Acceptance**: Run `kasmos -d --format json`, see NDJSON events on stdout. +Kill TUI, run `kasmos --attach`, see restored worker states. + +### Future (not in this plan) + +- **User Story 9**: SSH access via charmbracelet/wish +- **TmuxBackend**: Alternative WorkerBackend for persistent panes +- **Plugin system**: User-defined task source adapters + +## Key Design Decisions + +### 1. Worker output is captured via merged stdout+stderr pipe + +Workers produce a single output stream. We merge stderr into stdout at spawn time +(`cmd.Stderr = cmd.Stdout`) so the OutputBuffer and viewport show everything in order. +This avoids the complexity of interleaving two streams. + +### 2. Session ID is extracted by regex from output + +OpenCode prints session info at run start. We parse it with a regex +(`session:\s+(ses_[a-zA-Z0-9]+)`) from the output stream. If extraction fails +(output format changes, worker crashes early), continuation is unavailable for +that worker - the TUI shows the Continue action as disabled. + +### 3. Spawn dialog uses huh forms, not raw textinput + +The spawn dialog has multiple fields (role selector, multiline prompt, file paths). +huh provides a form abstraction with tab navigation, validation, and the ThemeCharm() +aesthetic. This is more maintainable than wiring up individual bubbles components. + +### 4. Table uses bubbles/table, not bubbles/list + +The worker list is tabular data (columns: ID, status, role, duration, task). bubbles/table +provides column-aware rendering, header styling, and width management. bubbles/list is +used only for the task panel where items are multi-line (title + description + deps + status). + +### 5. Layout is computed, not hardcoded + +`recalculateLayout()` runs on every `tea.WindowSizeMsg`. It picks a layout mode +(narrow/standard/wide) based on terminal width, computes panel dimensions with explicit +math (see `design-artifacts/tui-layout-spec.md`), and applies them to sub-models. +No magic constants scattered through View(). + +### 6. Worker tree rendering for continuation chains + +Continuation workers display as tree children in the table: `w-002` -> `+-w-005` -> `| +-w-006`. +This is rendered by preprocessing the worker list into a flat display order with tree glyphs +prepended to the ID column. The table itself is still flat - tree structure is visual only. + +## Risk Register -## Complexity Tracking +| Risk | Impact | Mitigation | +|------|--------|------------| +| OpenCode CLI changes `run` flags | Workers fail to spawn | Pin to OpenCode version range. Validate flags at startup in `kasmos setup`. | +| OpenCode session ID format changes | Continuation breaks | Regex extraction with fallback. Warn user if extraction fails. | +| bubbletea v2 breaking changes (still beta) | TUI breaks on upgrade | Pin exact version in go.mod. Upgrade deliberately. | +| Large worker output causes OOM | TUI crashes | OutputBuffer ring buffer with configurable max (default 5000 lines). | +| Concurrent goroutine leaks from workers | Memory grows | Context cancellation on worker kill. Shutdown protocol force-kills after timeout. | +| huh forms block TUI rendering | Dashboard freezes | huh runs as a sub-model within the bubbletea Update loop, not blocking. | -*Fill ONLY if Constitution Check has violations that must be justified* +## Reference Documents -| Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | \ No newline at end of file +- **Spec**: `kitty-specs/016-kasmos-agent-orchestrator/spec.md` +- **Architecture context**: `kitty-specs/016-kasmos-agent-orchestrator/project-context.md` +- **Technical contracts**: `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md` +- **Layout specification**: `design-artifacts/tui-layout-spec.md` +- **View mockups**: `design-artifacts/tui-mockups.md` +- **Keybinding specification**: `design-artifacts/tui-keybinds.md` +- **Style specification**: `design-artifacts/tui-styles.md` +- **Constitution**: `.kittify/memory/constitution.md` diff --git a/kitty-specs/016-kasmos-agent-orchestrator/quickstart.md b/kitty-specs/016-kasmos-agent-orchestrator/quickstart.md new file mode 100644 index 0000000..eafc7f9 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/quickstart.md @@ -0,0 +1,100 @@ +# Quickstart: kasmos Development + +## Prerequisites + +- Go 1.23+ +- OpenCode (`opencode` in PATH) +- git + +## Bootstrap + +```bash +# Initialize Go module +go mod init github.com/user/kasmos + +# Install core dependencies +go get github.com/charmbracelet/bubbletea@v2 +go get github.com/charmbracelet/lipgloss@v2 +go get github.com/charmbracelet/bubbles +go get github.com/charmbracelet/huh +go get github.com/spf13/cobra +go get github.com/muesli/gamut +go get github.com/mattn/go-isatty +go get gopkg.in/yaml.v3 + +# Create directory structure +mkdir -p cmd/kasmos +mkdir -p internal/{tui,worker,task,persist,setup} +``` + +## Build and Run + +```bash +# Build +go build ./cmd/kasmos + +# Run (interactive TUI) +./kasmos + +# Run with task source +./kasmos kitty-specs/016-kasmos-agent-orchestrator/ + +# Run in daemon mode +./kasmos -d --format json + +# Setup agent definitions +./kasmos setup +``` + +## Test + +```bash +# All tests +go test ./... + +# Verbose with race detection +go test -v -race ./... + +# Integration tests (requires opencode in PATH) +KASMOS_INTEGRATION=1 go test ./... + +# Specific package +go test ./internal/worker/... +go test ./internal/tui/... +``` + +## Development Workflow + +1. **Worker package first**: Implement `WorkerBackend` interface + `SubprocessBackend` + before TUI. This can be tested independently. + +2. **TUI skeleton second**: Minimal bubbletea app that renders the layout with mock data. + Verify responsive breakpoints work. + +3. **Wire together**: Connect worker events to TUI messages. Spawn real workers. + +4. **Task sources third**: Add spec-kitty/GSD adapters after core worker+TUI works. + +5. **Polish last**: Overlays, help, AI helpers, persistence, daemon mode. + +## Key Files to Read First + +| File | Why | +|------|-----| +| `design-artifacts/tui-mockups.md` | See what you're building (12 views) | +| `design-artifacts/tui-layout-spec.md` | Layout math and breakpoints | +| `research/tui-technical.md` Section 1 | WorkerBackend interface contract | +| `research/tui-technical.md` Section 2 | All bubbletea Msg types | +| `research/tui-technical.md` Section 10 | Package structure | +| `design-artifacts/tui-styles.md` | Copy-paste styles.go code | +| `design-artifacts/tui-keybinds.md` | Copy-paste keys.go code | + +## Architecture Rules + +- **Never block Update()**: All I/O (spawn, read output, persist) goes through `tea.Cmd` +- **Worker -> TUI via messages**: `workerSpawnedMsg`, `workerOutputMsg`, `workerExitedMsg` +- **TUI -> Worker via commands**: `spawnWorkerCmd()`, `killWorkerCmd()`, `readOutputCmd()` +- **One spinner for all running workers**: Single `spinner.Model` in the main Model, + its `View()` output is reused in every running worker's status cell +- **Layout recalculation on resize only**: `recalculateLayout()` runs in `tea.WindowSizeMsg`, + not in `View()` diff --git a/kitty-specs/016-kasmos-agent-orchestrator/research.md b/kitty-specs/016-kasmos-agent-orchestrator/research.md new file mode 100644 index 0000000..4e29e41 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/research.md @@ -0,0 +1,83 @@ +# Research: kasmos Agent Orchestrator + +**Feature**: 016-kasmos-agent-orchestrator +**Date**: 2026-02-17 + +## Research Summary + +Research was conducted across multiple sessions covering architecture evaluation, +framework selection, TUI design, and technical contract definition. All NEEDS +CLARIFICATION items from the spec have been resolved. + +## Decisions + +### 1. Framework: Go / bubbletea + +**Decision**: Go with bubbletea v2, lipgloss v2, bubbles, huh +**Rationale**: Elm architecture maps naturally to event-driven orchestration. Single binary +distribution. Goroutines for concurrent worker management. charmbracelet ecosystem provides +SSH (wish), forms (huh), and consistent styling out of the box. +**Alternatives considered**: +- Rust / ratatui: More performant but ecosystem friction (no form library equivalent to huh, + no SSH server equivalent to wish). Async Rust complexity for subprocess management. +- Python / textual: Rich ecosystem but distribution challenges (Python runtime dependency), + GIL concerns for concurrent worker I/O. + +### 2. Worker Execution: Headless subprocesses + +**Decision**: Workers spawned via `os/exec.Command("opencode", "run", ...)` with stdout/stderr +piped to Go readers. +**Rationale**: Simplest possible backend. No terminal multiplexer dependency. Output capture +is native Go. Process lifecycle managed by OS. Clean kill via process groups (`Setpgid`). +**Alternatives considered**: +- tmux sessions: More features (scrollback, attach) but adds runtime dependency and complexity. + Deferred to future TmuxBackend via pluggable interface. +- PTY allocation: Preserves ANSI formatting but adds pty package dependency and complicates + output parsing. Not needed since workers are non-interactive. + +### 3. Session Continuation: --continue -s flag + +**Decision**: Use `opencode run --continue -s ` for follow-up workers. +**Rationale**: Preserves full agent context (files read, decisions made, conversation history) +without requiring interactive access to a running session. The session ID is extracted from +worker output via regex. +**Alternatives considered**: +- Interactive PTY: Would require terminal multiplexer and complex I/O management. + Overkill for the "review -> fix" workflow. +- Fresh workers with context dump: Loses implicit context. Token-expensive to re-establish. + +### 4. Task Sources: Pluggable adapters + +**Decision**: Three adapters behind a `Source` interface: SpecKittySource, GsdSource, AdHocSource. +**Rationale**: kasmos serves different planning maturity levels. Formal projects use spec-kitty +(plan.md with WPs). Lightweight projects use GSD (checkbox markdown). Quick tasks use ad-hoc +(manual prompts). The interface is simple (Load, Tasks, Type, Path) so new adapters are trivial. +**Alternatives considered**: +- Single format: Would force all projects into one planning style. +- Plugin-based adapters: Overengineered for 3 built-in sources. Can add later if needed. + +### 5. TUI Design: Charm bubblegum aesthetic + +**Decision**: Purple primary accent, hot pink headers, rounded borders, gradient title, +context-dependent keybinds, responsive 4-mode layout (too-small/narrow/standard/wide). +**Rationale**: Follows charmbracelet design language (consistent with gum, soft-serve, mods). +The responsive layout ensures usability from 80-column SSH sessions to wide local terminals. +**Research artifacts**: Full design in `design-artifacts/` (4 files, 12 view mockups). + +### 6. Testing: Standard library + mock backend + +**Decision**: `go test ./...` with table-driven tests. Mock `WorkerBackend` for TUI tests. +Integration tests gated behind `KASMOS_INTEGRATION=1`. +**Rationale**: Standard Go testing is sufficient. testify adds a dependency for marginal +ergonomic gain. Mock backend prevents tests from spawning real opencode processes (which +require API keys and take minutes to run). +**Alternatives considered**: +- testify/assert: Popular but not necessary for this project scope. +- Container-based integration: Overkill for a CLI tool. + +## Detailed Research Artifacts + +All technical contracts (Go interfaces, message types, JSON schemas, package layout) +are in `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`. + +All visual design (layout, mockups, keybinds, styles) are in `design-artifacts/`. From c45875c6a03c4ef3c5b7b0df7b328867a486358a Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 00:09:00 -0600 Subject: [PATCH 04/26] tasks(016): generate 13 work packages across 3 waves Wave 1 (WP01-06): Core TUI + worker lifecycle Wave 2 (WP07-11): Task sources + worker management Wave 3 (WP12-13): Daemon mode + persistence Each WP references specific tui-technical.md sections and has clear scope boundaries for independent coder agent sessions. --- .../016-kasmos-agent-orchestrator/plan.md | 68 +++++ .../tasks/.gitkeep | 0 .../tasks/WP01-project-bootstrap.md | 115 +++++++++ .../tasks/WP02-worker-backend.md | 149 +++++++++++ .../tasks/WP03-tui-foundation.md | 196 +++++++++++++++ .../tasks/WP04-worker-tui-integration.md | 206 +++++++++++++++ .../tasks/WP05-spawn-continue-dialogs.md | 176 +++++++++++++ .../tasks/WP06-output-viewport.md | 221 ++++++++++++++++ .../tasks/WP07-kill-restart.md | 127 ++++++++++ .../tasks/WP08-task-sources.md | 212 ++++++++++++++++ .../tasks/WP09-task-panel-batch.md | 200 +++++++++++++++ .../tasks/WP10-setup-command.md | 211 ++++++++++++++++ .../tasks/WP11-ai-helpers.md | 194 ++++++++++++++ .../tasks/WP12-daemon-mode.md | 208 +++++++++++++++ .../tasks/WP13-session-persistence.md | 237 ++++++++++++++++++ 15 files changed, 2520 insertions(+) delete mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/.gitkeep create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP01-project-bootstrap.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP02-worker-backend.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md diff --git a/kitty-specs/016-kasmos-agent-orchestrator/plan.md b/kitty-specs/016-kasmos-agent-orchestrator/plan.md index 352ebab..ff699b6 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/plan.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/plan.md @@ -236,6 +236,74 @@ prepended to the ID column. The table itself is still flat - tree structure is v | Concurrent goroutine leaks from workers | Memory grows | Context cancellation on worker kill. Shutdown protocol force-kills after timeout. | | huh forms block TUI rendering | Dashboard freezes | huh runs as a sub-model within the bubbletea Update loop, not blocking. | +## Work Package Decomposition + +13 WPs across 3 waves. Each WP is independently implementable by a coder agent +in a single session. WP files are in `kitty-specs/016-kasmos-agent-orchestrator/tasks/`. + +### Wave 1: Core TUI + Worker Lifecycle (6 WPs) + +| WP | Title | Dependencies | User Stories | Key Deliverables | +|----|-------|-------------|--------------|------------------| +| WP01 | Project Bootstrap + CLI Entry | none | -- | go.mod, cmd/kasmos/main.go, cobra, minimal tea.Program | +| WP02 | Worker Backend Package | none | -- | internal/worker/ (interface, subprocess, output buffer, session ID) | +| WP03 | TUI Foundation (Layout, Styles, Keys) | WP01 | -- | internal/tui/ skeleton (model, layout, styles, keys, empty panels) | +| WP04 | Worker-TUI Integration | WP02, WP03 | US1, US2 | commands.go, spawn dialog, table+viewport with live workers | +| WP05 | Continue Dialog + Overlays + Chains | WP04 | US3 | Continue dialog, quit confirm, worker tree glyphs | +| WP06 | Output Viewport + Fullscreen + Shutdown | WP04, WP05 | US2 | Fullscreen, auto-follow, 4-phase key routing, graceful shutdown | + +**Wave 1 acceptance**: Run `kasmos`, press `s` to spawn, see output streaming, +press `c` on completed worker to continue session. All 3 P1 user stories pass. + +### Wave 2: Task Sources + Worker Management (5 WPs) + +| WP | Title | Dependencies | User Stories | Key Deliverables | +|----|-------|-------------|--------------|------------------| +| WP07 | Kill + Restart Workers | WP04 | US4 | Kill (x key), restart (r key) with pre-filled dialog | +| WP08 | Task Source Framework + Adapters | WP02 | US5 | internal/task/ (source interface, spec-kitty, GSD, ad-hoc) | +| WP09 | Task Panel UI + Batch Spawn | WP03, WP04, WP08 | US5 | Task list panel (wide mode), spawn from task, batch spawn | +| WP10 | Setup Command + Agent Scaffolding | WP01 | US6 | `kasmos setup`, dep validation, agent definitions | +| WP11 | AI Helpers (Analyze + Gen Prompt) | WP04, WP08 | FR-012 | Failure analysis (a key), prompt generation (g key) | + +**Wave 2 acceptance**: Run `kasmos kitty-specs/.../`, see WPs in task panel, +spawn from task. Kill/restart workers. Run `kasmos setup`. + +### Wave 3: Daemon Mode + Persistence (2 WPs) + +| WP | Title | Dependencies | User Stories | Key Deliverables | +|----|-------|-------------|--------------|------------------| +| WP12 | Daemon Mode (Headless Operation) | WP04 | US7 | -d flag, NDJSON/human output, --spawn-all, TTY detection | +| WP13 | Session Persistence + Reattach | WP04 | US8 | .kasmos/session.json, --attach, atomic write, orphan recovery | + +**Wave 3 acceptance**: Run `kasmos -d --format json`, see NDJSON events. +Kill TUI, run `kasmos --attach`, see restored worker states. + +### Dependency Graph + +``` +WP01 ─────────────────────────────────────────────┬──→ WP10 + │ │ + └──→ WP03 ─────────────────┐ │ + │ │ +WP02 ──────────┬──→ WP08 ────┤ │ + │ │ │ + └──→ WP04 ────┼──→ WP07 │ + │ │ │ │ + │ │ └──→ WP09 │ + │ │ │ + │ └──→ WP11 │ + │ │ + └──→ WP05 ──→ WP06 │ + │ │ + ├──→ WP12 │ + └──→ WP13 │ +``` + +**Parallelism opportunities**: +- WP01 and WP02 can run in parallel (no dependency) +- WP07, WP08, WP10 can run in parallel after their deps complete +- WP12 and WP13 can run in parallel + ## Reference Documents - **Spec**: `kitty-specs/016-kasmos-agent-orchestrator/spec.md` diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/.gitkeep b/kitty-specs/016-kasmos-agent-orchestrator/tasks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP01-project-bootstrap.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP01-project-bootstrap.md new file mode 100644 index 0000000..b97e3cc --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP01-project-bootstrap.md @@ -0,0 +1,115 @@ +--- +work_package_id: WP01 +title: Project Bootstrap + CLI Entry Point +lane: done +dependencies: [] +subtasks: +- go.mod with all dependencies +- cmd/kasmos/main.go with cobra root command +- Minimal tea.Program that renders placeholder +- go build ./cmd/kasmos compiles +phase: Wave 1 - Core TUI + Worker Lifecycle +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' +history: +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T05:39:40.145695064+00:00' + lane: doing + actor: manager + shell_pid: '14003' + action: 'transition active (Launching Wave 1 parallel pair: WP01 (bootstrap) + WP02 (worker backend))' +- timestamp: '2026-02-18T06:08:46.604948124+00:00' + lane: done + actor: manager + shell_pid: '401658' + action: 'transition done (Verified: 18/18 PASS. Build, vet, runtime all clean.)' +--- + +# Work Package Prompt: WP01 - Project Bootstrap + CLI Entry Point + +## Mission + +Create the kasmos Go project from scratch: go.mod with all dependencies, the cobra +CLI entry point, and a minimal bubbletea program that compiles and runs. This is +the foundation every other WP builds on. + +## Scope + +### Files to Create + +``` +go.mod # Module declaration + all dependencies +cmd/kasmos/main.go # Entry point: cobra root command + tea.Program setup +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md` Section 10 + (Package Structure, Key Dependencies) +- `.kittify/memory/constitution.md` (Go 1.23+, bubbletea v2, lipgloss v2) + +## Implementation + +### go.mod + +``` +module github.com/user/kasmos + +go 1.23 +``` + +Dependencies (from tui-technical.md Section 10): +- `github.com/charmbracelet/bubbletea` v2 (the TUI framework) +- `github.com/charmbracelet/bubbles` (table, viewport, spinner, help, textinput, list) +- `github.com/charmbracelet/lipgloss` v2 (styling) +- `github.com/charmbracelet/huh` (form dialogs) +- `github.com/muesli/gamut` (gradient colors) +- `github.com/mattn/go-isatty` (terminal detection for daemon mode) +- `github.com/spf13/cobra` (CLI command structure) +- `gopkg.in/yaml.v3` (WP frontmatter parsing) + +Run `go mod tidy` after creating go.mod to resolve exact versions. + +### cmd/kasmos/main.go + +Set up cobra with a root command that: +1. Parses flags (placeholder for now: `--version`) +2. Creates a minimal bubbletea Model (just stores width/height) +3. Initializes `tea.NewProgram(model, tea.WithAltScreen())` +4. Runs the program +5. Exits cleanly on quit + +The minimal Model should: +- Handle `tea.WindowSizeMsg` (store dimensions) +- Handle `tea.KeyMsg` for `q` and `ctrl+c` (quit) +- Render a centered placeholder: "kasmos v0.1.0 - press q to quit" +- Return `tea.Quit` on quit keys + +Signal handling setup (from tui-technical.md Section 8): +```go +ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) +defer stop() +``` + +Pass the context to `tea.NewProgram` via `tea.WithContext(ctx)`. + +### What NOT to Do + +- Do NOT implement any TUI styling, layout, or panels (that is WP03) +- Do NOT implement the worker backend (that is WP02) +- Do NOT add the `setup` subcommand yet (that is WP10) +- Keep the model minimal -- just enough to prove the scaffold works + +## Acceptance Criteria + +1. `go build ./cmd/kasmos` produces a binary without errors +2. Running `./kasmos` shows a terminal UI with the placeholder text +3. Pressing `q` or `ctrl+c` exits cleanly +4. `go vet ./...` reports no issues +5. Terminal resize updates dimensions (no crash) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP02-worker-backend.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP02-worker-backend.md new file mode 100644 index 0000000..75c55b9 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP02-worker-backend.md @@ -0,0 +1,149 @@ +--- +work_package_id: WP02 +title: Worker Backend Package +lane: done +dependencies: [] +subtasks: +- internal/worker/backend.go - WorkerBackend interface + types +- internal/worker/subprocess.go - SubprocessBackend (os/exec) +- internal/worker/worker.go - Worker struct + state machine +- internal/worker/output.go - OutputBuffer (ring buffer) +- internal/worker/session.go - Session ID extraction +- internal/worker/manager.go - WorkerManager + ID generation +- Unit tests for all files +phase: Wave 1 - Core TUI + Worker Lifecycle +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' +history: +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T05:39:41.254819519+00:00' + lane: doing + actor: manager + shell_pid: '14003' + action: 'transition active (Launching Wave 1 parallel pair: WP01 (bootstrap) + WP02 (worker backend))' +- timestamp: '2026-02-18T06:08:48.088165679+00:00' + lane: done + actor: manager + shell_pid: '401658' + action: 'transition done (Verified: 18 PASS, 1 WARN (acceptable). Tests, race, vet all clean.)' +--- + +# Work Package Prompt: WP02 - Worker Backend Package + +## Mission + +Implement the complete `internal/worker/` package: the WorkerBackend interface, +SubprocessBackend (os/exec), Worker domain type with state machine, OutputBuffer +ring buffer, session ID extraction, and WorkerManager. This package has zero TUI +dependency -- it is the pure process-management layer. + +## Scope + +### Files to Create + +``` +internal/worker/backend.go # WorkerBackend interface, SpawnConfig, WorkerHandle, ExitResult +internal/worker/subprocess.go # SubprocessBackend (os/exec MVP) +internal/worker/worker.go # Worker struct, WorkerState enum, Duration(), FormatDuration() +internal/worker/output.go # OutputBuffer (thread-safe ring buffer) +internal/worker/session.go # extractSessionID regex patterns +internal/worker/manager.go # WorkerManager, NextWorkerID(), atomic counter +internal/worker/backend_test.go +internal/worker/output_test.go +internal/worker/session_test.go +internal/worker/worker_test.go +internal/worker/manager_test.go +``` + +### Technical References (copy-paste ready) + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 1**: WorkerBackend interface, SpawnConfig, WorkerHandle, ExitResult, + SubprocessBackend implementation (lines 30-197) + - **Section 3**: Worker struct, WorkerState enum, Duration(), FormatDuration(), + Children(), WorkerID generation (lines 452-556) + - **Section 7**: OutputBuffer design (lines 1037-1084) + - **Section 9**: extractSessionID regex, OpenCode CLI patterns (lines 1139-1215) +- `kitty-specs/016-kasmos-agent-orchestrator/data-model.md`: + - Worker entity fields and state machine (lines 22-52) + - WorkerBackend and WorkerHandle interfaces (lines 109-140) + +## Implementation + +### backend.go + +Implement exactly as defined in tui-technical.md Section 1: +- `WorkerBackend` interface: `Spawn(ctx, cfg) (WorkerHandle, error)`, `Name() string` +- `SpawnConfig` struct: ID, Role, Prompt, Files, ContinueSession, Model, WorkDir, Env +- `WorkerHandle` interface: `Stdout() io.Reader`, `Wait() ExitResult`, `Kill(grace)`, `PID()` +- `ExitResult` struct: Code, Duration, SessionID, Error + +### subprocess.go + +Implement `SubprocessBackend` and `subprocessHandle` from tui-technical.md Section 1: +- `NewSubprocessBackend()` resolves `opencode` binary via `exec.LookPath` +- `Spawn()` builds args from SpawnConfig, sets `Setpgid: true` for process group isolation +- Merges stderr into stdout: `cmd.Stderr = cmd.Stdout` +- `buildArgs()` constructs the `opencode run` command line +- `subprocessHandle.Kill()`: SIGTERM with grace period, escalate to SIGKILL +- `subprocessHandle.Wait()`: blocks until exit, returns ExitResult with duration + +### worker.go + +Implement Worker struct and state machine from tui-technical.md Section 3: +- `WorkerState` enum: StatePending, StateSpawning, StateRunning, StateExited, StateFailed, StateKilled +- `Worker` struct with all fields from data-model.md +- `Duration()`, `FormatDuration()`, `Children()` methods + +### output.go + +Implement OutputBuffer from tui-technical.md Section 7: +- Thread-safe ring buffer with `sync.RWMutex` +- `DefaultMaxLines = 5000` +- `Append(data string)`: split on `\n`, replace non-UTF8 with U+FFFD +- `Lines()`, `Content()`, `Tail(n)`, `LineCount()`, `TotalLines()`, `Truncated()` +- Ring buffer behavior: when exceeding maxLines, discard oldest lines + +### session.go + +Implement session ID extraction from tui-technical.md Section 9: +- Pattern 1: `session:\s+(ses_[a-zA-Z0-9]+)` (text format) +- Pattern 2: `"session_id"\s*:\s*"(ses_[a-zA-Z0-9]+)"` (JSON format) +- Returns empty string if not found + +### manager.go + +Implement WorkerManager: +- Holds `[]*Worker` slice, tracks all workers +- `NextWorkerID()` with `atomic.Int64` counter, format `w-NNN` (zero-padded 3 digits) +- `ResetWorkerCounter(n)` for session restore +- `Add(w)`, `Get(id)`, `All()`, `Running()` methods + +### Testing + +Use table-driven tests (constitution requirement): +- **output_test.go**: Test Append with multi-line data, ring buffer overflow, Content() joining, + Tail(), Truncated() count, concurrent Append safety +- **session_test.go**: Test both regex patterns, no match, partial matches +- **worker_test.go**: Test Duration() for running/exited/pending, FormatDuration() output +- **manager_test.go**: Test ID generation sequence, ResetWorkerCounter, Add/Get/Running +- **backend_test.go**: Test buildArgs() for various SpawnConfig combinations + +Do NOT spawn real `opencode` processes in unit tests. Integration tests (gated by +`KASMOS_INTEGRATION=1`) can test real subprocess spawning with a simple command +like `echo hello`. + +## Acceptance Criteria + +1. `go test ./internal/worker/...` passes with all tests green +2. `go vet ./internal/worker/...` reports no issues +3. WorkerBackend interface is satisfied by SubprocessBackend (compile check) +4. OutputBuffer handles concurrent writes without data races (`go test -race`) +5. Session ID extraction works for both text and JSON patterns +6. Worker state machine transitions are validated (no invalid transitions) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md new file mode 100644 index 0000000..004e75c --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md @@ -0,0 +1,196 @@ +--- +work_package_id: "WP03" +title: "TUI Foundation (Layout, Styles, Keys)" +lane: "planned" +dependencies: + - "WP01" +subtasks: + - "internal/tui/model.go - Main Model struct, Init(), View()" + - "internal/tui/layout.go - recalculateLayout(), breakpoints, dimension math" + - "internal/tui/styles.go - Full color palette, all style definitions" + - "internal/tui/keys.go - keyMap, defaultKeyMap(), ShortHelp(), FullHelp()" + - "internal/tui/messages.go - All tea.Msg type definitions" + - "internal/tui/panels.go - Empty panel rendering (table, viewport, status bar, help)" +phase: "Wave 1 - Core TUI + Worker Lifecycle" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP03 - TUI Foundation (Layout, Styles, Keys) + +## Mission + +Build the complete TUI skeleton: Model struct, responsive layout system with +breakpoints, the full lipgloss style palette, keybind definitions, message types, +and empty-state panel rendering. After this WP, `kasmos` launches a beautiful +empty dashboard matching the V11 mockup (empty dashboard) with working resize, +focus cycling, and help overlay. + +## Scope + +### Files to Create + +``` +internal/tui/model.go # Main Model struct, Init(), top-level Update(), View() +internal/tui/layout.go # recalculateLayout(), breakpoint detection, dimension math +internal/tui/styles.go # Full Charm bubblegum palette, all component styles +internal/tui/keys.go # keyMap struct, defaultKeyMap(), ShortHelp(), FullHelp() +internal/tui/messages.go # All tea.Msg type definitions (stubs for command msgs) +internal/tui/panels.go # Panel rendering functions (empty states for now) +``` + +### Files to Modify + +``` +cmd/kasmos/main.go # Replace placeholder model with real tui.Model +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 2**: Full message type catalog (lines 218-401) -- define all types now + - **Section 3**: panel enum (panelTable, panelViewport, panelTasks) + - **Section 10**: Package structure (lines 1219-1263) +- `design-artifacts/tui-layout-spec.md`: Entire document + - Vertical dimension math (lines 26-40) + - Breakpoint summary table (lines 50-56) + - Narrow/Standard/Wide dimension formulas (lines 58-206) + - Panel specs: header, table, viewport, status bar, help bar (lines 220-388) + - Focus system: panel enum, cycling, visual indicator (lines 422-458) + - Overlay layout with centering (lines 392-419) + - Resize handling (lines 478-524) +- `design-artifacts/tui-styles.md`: Entire document + - Core color palette (lines 12-31) + - Panel styles (lines 90-111) + - Header + gradient (lines 115-161) + - Table styles (lines 166-186) + - Status bar, help bar styles (lines 190-228) + - Status indicators: WorkerState, TaskState, role badges (lines 316-402) + - Panel title rendering (lines 462-511) + - Overlay backdrop (lines 519-530) +- `design-artifacts/tui-keybinds.md`: + - keys.go implementation (lines 92-239) + - ShortHelp/FullHelp (lines 247-269) +- `design-artifacts/tui-mockups.md`: + - **V11**: Empty dashboard mockup (lines 437-470) + - **V6**: Help overlay mockup (lines 254-290) + +## Implementation + +### model.go + +The Model is the central state container. For this WP, include: +- `width`, `height` int (terminal dimensions) +- `ready` bool (set on first WindowSizeMsg) +- `focused` panel (current focus: panelTable or panelViewport) +- `layoutMode` (narrow/standard/wide/tooSmall) +- `showHelp` bool +- `keys` keyMap +- `help` help.Model +- `table` table.Model (empty, configured with styles) +- `viewport` viewport.Model (welcome message content) +- `spinner` spinner.Model +- `statusBar` string (rendered each frame) +- Layout dimension fields: tableInnerWidth, tableInnerHeight, viewportInnerWidth, etc. + +`Init()`: return `tea.Batch(tickCmd(), m.spinner.Tick)` + +`Update()`: handle tea.WindowSizeMsg (call recalculateLayout), tea.KeyMsg for +global keys (q, ctrl+c, ?, tab, shift+tab), tickMsg, spinner.TickMsg. +For now, delegate to focused panel for j/k navigation. + +`View()`: compose header + content area + status bar + help bar. +If `layoutMode == layoutTooSmall`, render centered resize warning. +If `showHelp`, render help overlay with backdrop. + +### layout.go + +Implement `recalculateLayout()` exactly as specified in tui-layout-spec.md: +- layoutMode enum: `layoutTooSmall`, `layoutNarrow`, `layoutStandard`, `layoutWide` +- Breakpoints: <80 too small, 80-99 narrow, 100-159 standard, >=160 wide +- Vertical: `contentHeight = height - chromeTotal` (chromeTotal = 4 or 5) +- Narrow: stacked, 45%/55% vertical split +- Standard: side-by-side, 40%/60% horizontal split with 1-cell gap +- Wide: three-col, 25%/35%/40% (only with task source) +- Apply dimensions to sub-models (table.SetWidth, viewport.Width, etc.) + +### styles.go + +Copy the complete style definitions from tui-styles.md: +- Color palette (colorPurple through colorLightGray) +- Adaptive colors (subtleColor, highlightColor, specialColor) +- Semantic state colors (colorRunning, colorDone, colorFailed, etc.) +- Panel styles (focusedPanelStyle, unfocusedPanelStyle, panelStyle helper) +- Header styles (gradient, dim subtitle, version) +- `renderGradientTitle()` using gamut.Blends +- `workerTableStyles()` (header, selected, cell) +- statusBarStyle +- `styledHelp()` helper +- Dialog styles (dialogStyle, alertDialogStyle, buttons) +- `styledSpinner()`, `styledTextInput()`, `styledTextArea()` +- Status indicator functions: `statusIndicator()`, `taskStatusBadge()`, `roleBadge()` +- `renderWithBackdrop()` using lipgloss.Place with the backdrop pattern + +### keys.go + +Copy the keyMap struct and defaultKeyMap() from tui-keybinds.md (lines 92-239). +Implement ShortHelp() and FullHelp() from tui-keybinds.md (lines 247-269). +Include `updateKeyStates()` stub that disables all worker-dependent keys (no workers yet). + +### messages.go + +Define ALL message types from tui-technical.md Section 2: +- Worker lifecycle: workerSpawnedMsg, workerOutputMsg, workerExitedMsg, workerKilledMsg +- UI state: tickMsg, focusChangedMsg, layoutChangedMsg +- Overlay/dialog: spawnDialogSubmittedMsg, spawnDialogCancelledMsg, + continueDialogSubmittedMsg, continueDialogCancelledMsg, quitConfirmedMsg, quitCancelledMsg +- AI helpers: analyzeStartedMsg, analyzeCompletedMsg, genPromptStartedMsg, genPromptCompletedMsg +- Task source: tasksLoadedMsg, taskStateChangedMsg +- Session persistence: sessionSavedMsg, sessionLoadedMsg +- tickCmd() function + +Define the types now; handlers will be added in WP04+. + +### panels.go + +Render functions for each panel: +- `renderHeader()`: gradient title + version + optional source subtitle +- `renderWorkerTable()`: table inside titled panel border (empty state: centered + "No workers yet / Press s to spawn your first worker") +- `renderViewport()`: viewport inside titled panel border (welcome message from V11) +- `renderStatusBar()`: purple bar with worker counts + mode + scroll percentage +- `renderHelpBar()`: bubbles/help short mode with styled keys + +### cmd/kasmos/main.go Update + +Replace the placeholder model with `tui.NewModel()` (or `tui.Model` constructor). +Pass it to `tea.NewProgram` with `tea.WithAltScreen()`. + +## What NOT to Do + +- Do NOT implement worker spawning, output reading, or commands.go (WP04) +- Do NOT implement overlays (spawn/continue/quit dialogs) (WP05) +- Do NOT implement task panel or list component (WP09) +- Do NOT implement daemon mode (WP12) +- The table should be configured but EMPTY (no worker data yet) +- The viewport shows a static welcome message, not worker output + +## Acceptance Criteria + +1. `kasmos` launches and displays the empty dashboard matching V11 mockup +2. Terminal resize switches between narrow/standard layouts correctly +3. Tab/Shift-Tab cycles focus between table and viewport (border color changes) +4. `?` toggles help overlay with backdrop matching V6 mockup +5. `q` exits cleanly, `ctrl+c` force-quits +6. Status bar shows "0 workers" and "mode: ad-hoc" +7. Header shows gradient "kasmos" title with version +8. Terminals <80 cols show "Terminal too small" warning +9. `go build ./cmd/kasmos` compiles, `go vet ./...` clean diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md new file mode 100644 index 0000000..6456a45 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md @@ -0,0 +1,206 @@ +--- +work_package_id: "WP04" +title: "Worker-TUI Integration (Spawn + Output + Lifecycle)" +lane: "planned" +dependencies: + - "WP02" + - "WP03" +subtasks: + - "internal/tui/commands.go - spawnWorkerCmd, readOutputCmd, waitCmd, killWorkerCmd" + - "Update model.go - Add WorkerManager + WorkerBackend fields" + - "Update update.go - Worker lifecycle message handlers" + - "Update panels.go - Table rows from worker data, viewport from output buffer" + - "Spawn dialog (huh form) in overlays.go" + - "Timer tick for duration updates" + - "Spinner for running worker status cells" +phase: "Wave 1 - Core TUI + Worker Lifecycle" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP04 - Worker-TUI Integration (Spawn + Output + Lifecycle) + +## Mission + +Connect the worker backend (WP02) to the TUI (WP03). This is the core integration +WP that makes kasmos functional: press `s` to open a spawn dialog, confirm to +start a real opencode worker, see output stream into the viewport, watch the +worker exit and update its status. After this WP, the MVP spawn-monitor-exit loop +works end-to-end. + +## Scope + +### Files to Create + +``` +internal/tui/commands.go # All tea.Cmd constructors +internal/tui/overlays.go # Spawn dialog (other overlays added in WP05) +``` + +### Files to Modify + +``` +internal/tui/model.go # Add worker manager, backend, overlay state fields +internal/tui/update.go # Add worker lifecycle message handlers + key handlers +internal/tui/panels.go # Table rows from live worker data, viewport from output +internal/tui/keys.go # Enable/disable spawn key +cmd/kasmos/main.go # Initialize SubprocessBackend, pass to Model +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 1**: SpawnConfig fields (lines 50-78) -- used by spawn dialog + - **Section 2**: Worker lifecycle messages + command signatures (lines 218-272) + - **Section 2**: Message flow diagram (lines 402-447) -- the full spawn flow + - **Section 2**: Overlay/dialog messages (lines 308-335) + - **Section 3**: Worker struct runtime fields (Handle, Output) (lines 458-482) +- `design-artifacts/tui-mockups.md`: + - **V1**: Main dashboard with workers (lines 25-56) -- target rendering + - **V2**: Spawn worker dialog (lines 68-116) -- huh form layout +- `design-artifacts/tui-keybinds.md`: + - Dashboard table keys: s (spawn), j/k (navigate) (lines 19-32) + - Key routing in Update (lines 309-366) +- `design-artifacts/tui-layout-spec.md`: + - Worker table column widths (lines 268-291) + - Status bar content (lines 340-365) + +## Implementation + +### commands.go + +Implement the tea.Cmd constructors from tui-technical.md Section 2: + +**spawnWorkerCmd(backend, cfg)**: Returns a tea.Cmd that: +1. Calls `backend.Spawn(ctx, cfg)` +2. On success: sends `workerSpawnedMsg{WorkerID, PID}` +3. On failure: sends `workerExitedMsg{WorkerID, Err: err}` + +**readOutputCmd(workerID, reader, program)**: Returns a tea.Cmd that: +1. Reads from the worker's stdout in a loop (bufio.Scanner or fixed-size reads) +2. For each chunk: calls `program.Send(workerOutputMsg{WorkerID, Data})` +3. Loops until EOF (reader closes when process exits) +4. This runs in a goroutine -- use `p.Send()` not return values + +**Important**: readOutputCmd needs access to `tea.Program` to call `.Send()` for +streaming. Pass `*tea.Program` to the Model at init, or use a channel-based +approach where a goroutine writes to a channel and a tea.Cmd reads from it. + +Recommended approach: Start the output reader goroutine in the spawnWorkerCmd +handler (when workerSpawnedMsg is received). The goroutine reads from +`handle.Stdout()` and calls `p.Send(workerOutputMsg{...})`. Store `*tea.Program` +on the Model. + +**waitWorkerCmd(workerID, handle)**: Returns a tea.Cmd that: +1. Calls `handle.Wait()` (blocks until exit) +2. Returns `workerExitedMsg{WorkerID, ExitCode, Duration, SessionID}` + +**killWorkerCmd(workerID, handle, grace)**: Returns a tea.Cmd that: +1. Calls `handle.Kill(grace)` +2. Returns `workerKilledMsg{WorkerID, Err}` + +### overlays.go (Spawn Dialog Only) + +Implement the spawn dialog using `huh` forms (tui-mockups.md V2): +- Role selector: `huh.NewSelect()` with options planner/coder/reviewer/release, + each with a description string +- Prompt textarea: `huh.NewText()` for multi-line input +- Files input: `huh.NewInput()` for comma-separated file paths +- Use `huh.ThemeCharm()` for styling (tui-styles.md line 539) + +The form runs as a sub-model within the bubbletea Update loop: +- Model gets a `spawnForm *huh.Form` field and `showSpawnDialog bool` +- When `s` is pressed: create the form, set showSpawnDialog = true +- In Update: if showSpawnDialog, forward messages to spawnForm.Update() +- Check `spawnForm.State == huh.StateCompleted` to extract values +- On completion: emit spawnDialogSubmittedMsg with Role, Prompt, Files +- On abort (Esc): emit spawnDialogCancelledMsg + +Render the form inside a centered dialog with hot pink RoundedBorder +and the backdrop pattern from tui-styles.md. + +### model.go Updates + +Add to Model struct: +- `backend worker.WorkerBackend` +- `manager *worker.WorkerManager` (or embed workers directly as `[]*worker.Worker`) +- `program *tea.Program` (for p.Send() in output goroutines) +- `showSpawnDialog bool` +- `spawnForm *huh.Form` +- `selectedWorkerID string` (tracks which worker's output to show) + +Add a `NewModel(backend)` constructor that initializes everything. + +### update.go Updates + +Add handlers for: +- `spawnDialogSubmittedMsg`: Create SpawnConfig, call spawnWorkerCmd, add worker to manager +- `spawnDialogCancelledMsg`: Close dialog, return to dashboard +- `workerSpawnedMsg`: Update worker state to Running, start output reader goroutine, + start waitWorkerCmd +- `workerOutputMsg`: Append data to worker's OutputBuffer, update viewport if this + is the selected worker. Auto-follow: if viewport was at bottom before append, + call GotoBottom() after setting content. +- `workerExitedMsg`: Update worker state (Exited/Failed based on exit code), set + ExitedAt, extract SessionID from output +- `tickMsg`: Return tickCmd() to restart the timer. The table re-renders with + updated durations on each View() call. +- `spinner.TickMsg`: Forward to spinner.Update() + +Key handling additions: +- `s` key (table focused): open spawn dialog +- `j/k` keys (table focused): navigate table rows, update selectedWorkerID, + update viewport content to show selected worker's output + +### panels.go Updates + +**Table rows**: Build `[]table.Row` from worker list: +- Column values: ID, statusIndicator(state), roleBadge(role), FormatDuration(), TaskID +- For running workers: use `m.spinner.View() + " running"` in the status cell +- Selected row styling via bubbles/table built-in selection + +**Viewport content**: When a worker is selected, set viewport content to +`worker.Output.Content()`. When no worker selected, show welcome message. + +**Status bar**: Show actual worker counts from manager. +Format: ` [spinner] N running [check] N done [x] N failed ... mode: ad-hoc scroll: N%` + +### main.go Updates + +1. Create SubprocessBackend: `backend, err := worker.NewSubprocessBackend()` +2. Handle error (opencode not found) +3. Create Model: `model := tui.NewModel(backend)` +4. Create Program: `p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx))` +5. Set program on model: `model.SetProgram(p)` (needed for p.Send in goroutines) +6. Run: `if _, err := p.Run(); err != nil { ... }` + +## What NOT to Do + +- Do NOT implement the continue dialog (WP05) +- Do NOT implement the quit confirmation dialog (WP05) +- Do NOT implement the help overlay rendering (already in WP03 as toggle) +- Do NOT implement kill/restart key handlers (WP07) +- Do NOT implement fullscreen viewport mode (WP06) +- Do NOT implement task panel or task source integration (WP08/WP09) +- The spawn dialog does NOT need task pre-fill (that comes with WP09) +- Focus on the spawn -> monitor -> exit flow only + +## Acceptance Criteria + +1. Press `s`, fill out the spawn dialog, confirm -- a worker appears in the table +2. Worker status shows spinner + "running" while active +3. Select the running worker -- its output streams into the viewport in real time +4. When the worker exits, status updates to "done" or "failed(N)" with duration +5. Multiple workers can run concurrently (spawn 2-3, all show in table) +6. Table navigation (j/k) switches the viewport to the selected worker's output +7. Status bar shows accurate worker counts +8. Duration column updates every second for running workers +9. `go test ./...` passes, `go vet ./...` clean diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md new file mode 100644 index 0000000..8c59dc4 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md @@ -0,0 +1,176 @@ +--- +work_package_id: "WP05" +title: "Continue Dialog + Overlays + Worker Chains" +lane: "planned" +dependencies: + - "WP04" +subtasks: + - "Continue session dialog (huh form + parent info)" + - "Quit confirmation dialog" + - "Worker continuation chains (ParentID, tree glyphs)" + - "Viewport title shows chain reference" + - "Update key handlers for c (continue)" +phase: "Wave 1 - Core TUI + Worker Lifecycle" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP05 - Continue Dialog + Overlays + Worker Chains + +## Mission + +Implement session continuation: the continue dialog, quit confirmation, and +worker chain visualization. After this WP, a user can complete a worker, press +`c` to continue its session with a follow-up message, and see the parent-child +relationship rendered as a tree in the worker table. This delivers User Story 3 +(Continue a Worker Session). + +## Scope + +### Files to Modify + +``` +internal/tui/overlays.go # Add continue dialog, quit confirmation dialog +internal/tui/update.go # Add continue/quit message handlers +internal/tui/panels.go # Worker tree rendering in table, chain info in viewport title +internal/tui/model.go # Add continue dialog state, quit confirm state +internal/tui/keys.go # Enable continue key, updateKeyStates refinement +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 2**: continueDialogSubmittedMsg, continueDialogCancelledMsg, + quitConfirmedMsg, quitCancelledMsg (lines 320-334) + - **Section 1**: SpawnConfig.ContinueSession field (lines 64-66) + - **Section 3**: Worker.ParentID, Children() method (lines 476-517) +- `design-artifacts/tui-mockups.md`: + - **V5**: Continue session dialog (lines 216-250) -- exact layout + - **V7**: Worker continuation chains with tree glyphs (lines 292-320) + - **V12**: Quit confirmation dialog (lines 474-503) +- `design-artifacts/tui-keybinds.md`: + - `c` key: continue session, enabled when selected worker is exited/done (line 25) + - Overlay key handling: esc dismisses, ctrl+c force quits (lines 75-86) +- `kitty-specs/016-kasmos-agent-orchestrator/data-model.md`: + - Worker.ParentID relationship (line 37) + - "Continue creates a NEW worker, not a state change" (line 52) + +## Implementation + +### Continue Dialog (overlays.go) + +Build a huh form matching the V5 mockup: + +**Read-only parent info section** (rendered as styled text above the form, NOT as form fields): +- Worker ID, role (with badge color), status indicator +- Session ID (in lightBlue) +- Last line of output or a summary (truncated) + +**Form fields**: +- Follow-up message: `huh.NewText()` multiline textarea + - Placeholder: "Describe what to do next..." + - Pre-fill with empty (user writes the follow-up) + +**Buttons**: "Continue" (active, purple) + "Cancel" (inactive, darkGray) + +**Behavior**: +- On confirm: emit `continueDialogSubmittedMsg{ParentWorkerID, SessionID, FollowUp}` +- On cancel/esc: emit `continueDialogCancelledMsg{}` + +### Quit Confirmation Dialog (overlays.go) + +Build a dialog matching the V12 mockup: +- ThickBorder in orange (the ONLY view using ThickBorder) +- Header: warning emoji + "Quit kasmos?" in orange bold +- Body: "N workers are still running. They will be terminated." +- Buttons: "Force Quit" (orange bg) + "Cancel" (darkGray bg) +- Width: 36 chars +- On confirm: emit `quitConfirmedMsg{}` +- On cancel/esc: emit `quitCancelledMsg{}` + +### Worker Continuation Chains (panels.go) + +When rendering table rows, preprocess the worker list to add tree glyphs: + +1. Build a flat display order: root workers in spawn order, with children + indented underneath their parent +2. For each child, prepend tree glyphs to the ID column: + - Last child of parent: `+-{id}` (or Unicode: `+-`) + - Non-last child: `+-{id}` + - Depth connector: `| ` for each ancestor level +3. Tree glyphs rendered in midGray/faint + +Example from V7 mockup: +``` +w-002 done reviewer 3m 20s ++-w-005 done coder 1m 45s +| +-w-006 done reviewer 2m 10s ++--w-007 running coder 0m 52s +``` + +The ID column width should expand to accommodate tree depth (max depth likely 3-4). + +**Viewport title for chained workers**: Show `Output: {id} {role} <- {parentID}` +when the selected worker has a parent. First line of viewport content for +continuation workers: `<- continued from {parentID} ({parentRole})` in lightBlue. + +### update.go Handlers + +**continueDialogSubmittedMsg**: +1. Create new worker with `ParentID = msg.ParentWorkerID` +2. Build SpawnConfig with `ContinueSession = msg.SessionID`, `Prompt = msg.FollowUp` +3. Use same role as parent worker +4. Dispatch spawnWorkerCmd +5. Close dialog + +**quitConfirmedMsg**: +1. Trigger graceful shutdown sequence +2. Return `tea.Quit` (detailed shutdown in WP06) + +**quitCancelledMsg**: +1. Set `showQuitConfirm = false` +2. Return to dashboard + +### keys.go / updateKeyStates + +Refine `updateKeyStates()`: +- `m.keys.Continue.SetEnabled(selected != nil && (selected.State == StateExited || selected.State == StateFailed))` +- Continue requires a valid SessionID on the parent worker (if empty, disable and + the user sees it greyed out in help bar) + +### model.go Updates + +Add fields: +- `showContinueDialog bool` +- `continueForm *huh.Form` +- `continueParentID string` (which worker we're continuing) +- `showQuitConfirm bool` + +## What NOT to Do + +- Do NOT implement kill or restart (WP07) +- Do NOT implement fullscreen viewport mode (WP06) +- Do NOT implement AI analysis or gen-prompt (WP11) +- Do NOT implement graceful shutdown protocol (WP06 handles the full shutdown sequence) +- Keep quit confirmation simple: confirm -> tea.Quit for now + +## Acceptance Criteria + +1. Select a completed worker, press `c` -- continue dialog appears with parent info +2. Type a follow-up, confirm -- new worker spawns with `--continue -s ` +3. New worker appears in table as child of parent with tree glyphs +4. Viewport title shows chain reference (`<- w-002`) +5. `c` key is disabled (greyed in help) when selected worker is running or has no session ID +6. Press `q` with running workers -- quit confirmation appears with worker count +7. Press cancel in quit dialog -- returns to dashboard +8. Press "Force Quit" -- exits +9. Multi-level chains display correctly (parent -> child -> grandchild) +10. `go test ./...` passes diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md new file mode 100644 index 0000000..6c3c367 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md @@ -0,0 +1,221 @@ +--- +work_package_id: "WP06" +title: "Output Viewport + Fullscreen + Update Dispatch + Shutdown" +lane: "planned" +dependencies: + - "WP04" + - "WP05" +subtasks: + - "Full-screen viewport mode (f key toggle)" + - "Auto-follow logic (track AtBottom, GotoBottom on new content)" + - "Viewport scroll controls (d/u half-page, G bottom, g top)" + - "internal/tui/update.go - Complete 4-phase key routing" + - "Context-dependent key activation (updateKeyStates)" + - "Graceful shutdown protocol (SIGTERM -> SIGKILL -> persist)" + - "Signal handling refinement in main.go" +phase: "Wave 1 - Core TUI + Worker Lifecycle" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP06 - Output Viewport + Fullscreen + Update Dispatch + Shutdown + +## Mission + +Polish the output viewport into a first-class feature (fullscreen mode, auto-follow, +vim-style scroll controls), finalize the Update dispatch with correct 4-phase key +routing, implement context-dependent key activation, and add the graceful shutdown +protocol. This WP completes Wave 1 -- after it, all P1 user stories (1, 2, 3) are +fully delivered. + +## Scope + +### Files to Modify + +``` +internal/tui/update.go # Complete 4-phase key routing +internal/tui/model.go # Fullscreen state, auto-follow tracking +internal/tui/panels.go # Fullscreen viewport rendering, status bar in fullscreen +internal/tui/keys.go # updateKeyStates() full implementation, key conflict resolution +cmd/kasmos/main.go # Signal handling refinement +``` + +### Technical References + +- `design-artifacts/tui-mockups.md`: + - **V3**: Full-screen output viewport (lines 119-163) -- exact layout + status bar +- `design-artifacts/tui-layout-spec.md`: + - Full-screen dimensions (lines 466-475) + - Viewport auto-follow note (line 311) +- `design-artifacts/tui-keybinds.md`: + - Full-screen output keys: esc, j/k, d/u, G, g, /, c, r (lines 62-73) + - Key routing in Update: 4-phase dispatch (lines 309-366) + - Context-dependent key activation (lines 276-299) + - Key conflict resolution: g = gen-prompt vs goto-top (lines 370-395) +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 8**: Graceful shutdown protocol (lines 1089-1135) + - **Section 2**: tickMsg, focusChangedMsg (lines 276-303) + +## Implementation + +### Full-Screen Viewport Mode + +Add to Model: +- `fullScreen bool` -- toggled by `f` key +- `autoFollow bool` -- true when viewport is tracking the bottom + +**Enter fullscreen** (`f` key or `enter` on selected worker): +1. Set `fullScreen = true` +2. Recalculate viewport dimensions: full terminal width minus borders/padding, + full content height minus borders/padding +3. Viewport always gets purple border in fullscreen (always focused) + +**Exit fullscreen** (`esc` key): +1. Set `fullScreen = false` +2. Recalculate layout back to split mode + +**Fullscreen rendering** (V3 mockup): +- Header: same gradient title +- Viewport: fills entire content area, purple RoundedBorder +- Viewport title: `Output: {id} {role} - {prompt truncated}` (include prompt) +- Status bar (fullscreen variant): `{id} {role} {status} exit({code}) duration: {dur} session: {sessID} parent: {parentID or "-"} scroll: N%` +- Help bar: fullscreen-specific bindings (esc back, c continue, r restart, j/k, G, g, /) + +### Auto-Follow Logic + +When the viewport is at the bottom and new output arrives, automatically scroll to +show the new content. When the user scrolls up, disable auto-follow. + +```go +// Before setting new content: +wasAtBottom := m.viewport.AtBottom() + +// Set content: +m.viewport.SetContent(worker.Output.Content()) + +// After: +if wasAtBottom || m.autoFollow { + m.viewport.GotoBottom() + m.autoFollow = true +} +``` + +When user presses `k` (scroll up) or `u` (half page up): set `autoFollow = false`. +When user presses `G` (goto bottom): set `autoFollow = true`. + +### Viewport Scroll Controls + +In updateViewportKeys and updateFullScreen, handle: +- `j`/down: `m.viewport.LineDown(1)` (auto-follow = false if not at bottom) +- `k`/up: `m.viewport.LineUp(1)`, auto-follow = false +- `d`: `m.viewport.HalfViewDown()`, check bottom +- `u`: `m.viewport.HalfViewUp()`, auto-follow = false +- `G`: `m.viewport.GotoBottom()`, auto-follow = true +- `g`: `m.viewport.GotoTop()`, auto-follow = false + +### 4-Phase Key Routing (update.go) + +Implement the complete dispatch from tui-keybinds.md lines 309-366: + +``` +Phase 0: Overlay intercept -- if any overlay visible, ALL input goes to overlay +Phase 1: Global keys -- ctrl+c (force quit), ? (help), tab/S-tab (focus cycle), q (quit) +Phase 2: Fullscreen keys -- if fullScreen, handle esc/scroll/continue/restart +Phase 3: Panel-specific -- switch on m.focused: panelTable / panelViewport / panelTasks +``` + +Create dedicated handler functions: +- `updateOverlay(msg)` -- routes to spawn form, continue form, or quit dialog +- `updateFullScreen(msg)` -- fullscreen viewport keys +- `updateTableKeys(msg)` -- table navigation + worker action keys +- `updateViewportKeys(msg)` -- viewport scroll keys + +### Key Conflict Resolution + +Handle the `g` key dual binding (tui-keybinds.md lines 370-395): +- When viewport focused or fullscreen: `g` = goto top +- When table focused: `g` = gen-prompt (enabled only when task source loaded, Wave 2) +- Use separate key.Binding objects (GotoTop vs GenPrompt) and toggle enabled state + +In `updateKeyStates()`: +```go +if m.focused == panelViewport || m.fullScreen { + m.keys.GotoTop.SetEnabled(true) + m.keys.GenPrompt.SetEnabled(false) +} else { + m.keys.GotoTop.SetEnabled(false) + m.keys.GenPrompt.SetEnabled(m.hasTaskSource()) +} +``` + +### Context-Dependent Key Activation + +Implement full `updateKeyStates()` from tui-keybinds.md lines 279-299: +- Kill: selected worker is running +- Continue: selected worker is exited or failed AND has SessionID +- Restart: selected worker is failed or killed +- Analyze: selected worker is failed +- Fullscreen: any worker selected +- GenPrompt: task source loaded (disabled for now, Wave 2) +- Batch: task source loaded with unassigned tasks (disabled for now, Wave 2) +- Filter: task panel visible and focused (disabled for now, Wave 2) + +Call `updateKeyStates()` at the end of every Update() cycle. + +### Graceful Shutdown Protocol + +Implement from tui-technical.md Section 8: + +```go +func (m Model) gracefulShutdown() tea.Cmd { + return func() tea.Msg { + // 1. Send SIGTERM to all running workers + for _, w := range m.runningWorkers() { + w.Handle.Kill(3 * time.Second) + } + // 2. Wait for all workers to exit (up to 5s total) + // 3. Return tea.Quit + } +} +``` + +Wire this into: +- `quitConfirmedMsg` handler (from WP05) +- `q` key when no workers running (direct quit) +- Signal handling (SIGINT/SIGTERM from outside) + +### Signal Handling (main.go) + +Ensure `tea.WithContext(ctx)` is used where ctx comes from `signal.NotifyContext`. +When signal arrives, the context cancels, and bubbletea exits. The model's +shutdown logic should trigger on context cancellation. + +## What NOT to Do + +- Do NOT implement search/filter in viewport (future enhancement) +- Do NOT implement output line styling/colorization (future enhancement) +- Do NOT implement task panel keys (WP09) +- Do NOT implement AI helper keys (WP11) + +## Acceptance Criteria + +1. Press `f` on a selected worker -- viewport expands to full screen matching V3 mockup +2. Press `esc` in fullscreen -- returns to split dashboard +3. Auto-follow: viewport tracks new output at bottom; scrolling up disables auto-follow +4. `G` re-enables auto-follow, `g` goes to top +5. `d`/`u` half-page scroll works in both split and fullscreen +6. Status bar shows scroll percentage in viewport (not "100%" when scrolled up) +7. Quit with running workers shows confirmation; quit without workers exits immediately +8. Kill kasmos process (SIGTERM) -- workers receive SIGTERM, kasmos exits cleanly +9. All key routing phases work: overlays block input, global keys work everywhere +10. Disabled keys don't appear in help bar and don't trigger actions +11. `g` means "goto top" in viewport, "gen prompt" in table (gen prompt disabled until Wave 2) +12. `go test ./...` passes, `go vet ./...` clean diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md new file mode 100644 index 0000000..d3b1214 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md @@ -0,0 +1,127 @@ +--- +work_package_id: "WP07" +title: "Kill + Restart Workers" +lane: "planned" +dependencies: + - "WP04" +subtasks: + - "Kill worker handler (x key -> SIGTERM -> workerKilledMsg)" + - "Restart worker handler (r key -> spawn dialog pre-filled)" + - "Worker state transitions for killed/restarted" + - "Kill confirmation for running workers" +phase: "Wave 2 - Task Sources + Worker Management" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP07 - Kill + Restart Workers + +## Mission + +Implement kill and restart actions for workers: press `x` to terminate a running +worker, press `r` to restart a failed/killed worker with its original role and +prompt pre-filled in the spawn dialog for editing. This delivers User Story 4 +(Kill and Restart Workers). + +## Scope + +### Files to Modify + +``` +internal/tui/update.go # Kill and restart key handlers + message handlers +internal/tui/overlays.go # Spawn dialog pre-fill for restart +internal/tui/keys.go # Enable kill/restart in updateKeyStates +internal/tui/panels.go # Killed state rendering in table +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 2**: workerKilledMsg (lines 252-254), killWorkerCmd (lines 265-267) + - **Section 1**: WorkerHandle.Kill() with grace period (lines 89-92) + - **Section 3**: StateKilled in state machine (lines 523-529) +- `design-artifacts/tui-keybinds.md`: + - `x` kill: enabled when selected worker is running (line 21) + - `r` restart: enabled when selected worker is failed/killed (line 23) +- `design-artifacts/tui-styles.md`: + - StateKilled indicator: hot pink skull icon (line 341) +- `kitty-specs/016-kasmos-agent-orchestrator/spec.md`: + - User Story 4 acceptance scenarios (lines 59-71) + +## Implementation + +### Kill Worker (`x` key) + +When `x` is pressed with a running worker selected: +1. Call `killWorkerCmd(worker.ID, worker.Handle, 3*time.Second)` +2. Optimistically update worker state to `StateKilled` (visual feedback) +3. The kill command sends SIGTERM, waits grace period, escalates to SIGKILL +4. On `workerKilledMsg`: + - If Err is nil: confirm killed state, set ExitedAt + - If Err is non-nil: log error, worker may still be running (revert state?) +5. The `waitWorkerCmd` goroutine (started in WP04) will also fire `workerExitedMsg` + -- handle the race: if worker is already StateKilled, the exitedMsg just + confirms the exit code + +Grace period: 3 seconds (matches tui-technical.md Section 1 line 91). + +### Restart Worker (`r` key) + +When `r` is pressed with a failed or killed worker selected: +1. Open the spawn dialog (same huh form from WP04) +2. Pre-fill the form fields with the original worker's data: + - Role selector: set to original worker's role + - Prompt textarea: set to original worker's prompt + - Files input: set to original worker's files (comma-joined) +3. User can edit any field before confirming +4. On confirm: spawn a NEW worker (not a state change on the old one) +5. The new worker does NOT have ParentID set (restart is not continuation) +6. The new worker gets a fresh ID from the counter + +To pre-fill the huh form, create the form with `huh.NewSelect().Value(&role)` where +role is pre-set. For the textarea, use `.Value(&prompt)` with the original prompt. + +### updateKeyStates Refinement + +In `updateKeyStates()`: +```go +m.keys.Kill.SetEnabled(selected != nil && selected.State == StateRunning) +m.keys.Restart.SetEnabled(selected != nil && + (selected.State == StateFailed || selected.State == StateKilled)) +``` + +### Edge Cases + +- Killing an already-exited worker: no-op (Kill should check state first) +- Killing a worker that is still in StateSpawning: wait for it to reach + StateRunning first, or cancel the spawn +- Rapid kill-restart: ensure the old worker is fully dead before the restart + completes (the kill is async, but the restart creates a new independent worker) +- Kill returns an error (process already dead): handle gracefully, update state to + killed anyway since the process is gone + +## What NOT to Do + +- Do NOT implement batch kill (select multiple workers and kill all) +- Do NOT implement auto-restart on failure +- Do NOT implement AI-suggested restart prompts (that is WP11) +- Restart uses the standard spawn dialog, not a special restart dialog + +## Acceptance Criteria + +1. Select a running worker, press `x` -- worker shows as "killed" with skull icon +2. Worker process actually terminates (verify PID is gone) +3. Select a failed or killed worker, press `r` -- spawn dialog opens pre-filled +4. Edit the prompt and confirm -- new worker spawns with new ID +5. `x` key is disabled (not in help) when selected worker is not running +6. `r` key is disabled when selected worker is running or exited successfully +7. Killing a worker that has already exited is a no-op +8. `go test ./...` passes diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md new file mode 100644 index 0000000..ed347f7 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md @@ -0,0 +1,212 @@ +--- +work_package_id: "WP08" +title: "Task Source Framework + Adapters" +lane: "planned" +dependencies: + - "WP02" +subtasks: + - "internal/task/source.go - Source interface, Task struct, TaskState enum" + - "internal/task/speckitty.go - SpecKittySource (YAML frontmatter parser)" + - "internal/task/gsd.go - GsdSource (checkbox markdown parser)" + - "internal/task/adhoc.go - AdHocSource (empty/noop)" + - "CLI argument parsing: detect source type from path" + - "Unit tests for all adapters" +phase: "Wave 2 - Task Sources + Worker Management" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP08 - Task Source Framework + Adapters + +## Mission + +Implement the complete `internal/task/` package: the Source interface, Task domain +type, and all three task source adapters (spec-kitty, GSD, ad-hoc). Also add CLI +argument parsing to detect and load the appropriate source. This package has no +TUI dependency -- the UI integration happens in WP09. + +## Scope + +### Files to Create + +``` +internal/task/source.go # Source interface, Task struct, TaskState enum +internal/task/speckitty.go # SpecKittySource (reads plan.md + WP frontmatter) +internal/task/gsd.go # GsdSource (reads checkbox markdown) +internal/task/adhoc.go # AdHocSource (empty source) +internal/task/source_test.go +internal/task/speckitty_test.go +internal/task/gsd_test.go +``` + +### Files to Modify + +``` +cmd/kasmos/main.go # Accept positional arg for task source path +internal/tui/model.go # Add taskSource field (Source interface) +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 4**: Source interface, Task struct, TaskState enum (lines 560-614) + - **Section 4**: SpecKittySource implementation notes (lines 617-661) + - **Section 4**: GsdSource implementation notes (lines 663-686) + - **Section 4**: AdHocSource implementation notes (lines 688-699) + - **Section 2**: tasksLoadedMsg, taskStateChangedMsg (lines 368-384) +- `kitty-specs/016-kasmos-agent-orchestrator/data-model.md`: + - Task entity fields and state machine (lines 54-78) + - Source interface (lines 131-140) +- `kitty-specs/016-kasmos-agent-orchestrator/tasks/README.md`: + - WP file format with YAML frontmatter (lines 17-45) + - Valid lane values (lines 48-53) + +## Implementation + +### source.go + +Define the Source interface and Task types exactly from tui-technical.md Section 4: + +```go +type Source interface { + Type() string // "spec-kitty", "gsd", "ad-hoc" + Path() string // file/directory path (empty for ad-hoc) + Load() ([]Task, error) // parse and return tasks + Tasks() []Task // cached from last Load() +} +``` + +Task struct: ID, Title, Description, SuggestedRole, Dependencies, State, WorkerID, Metadata. + +TaskState enum: TaskUnassigned, TaskBlocked, TaskInProgress, TaskDone, TaskFailed. + +Add helper: `DetectSourceType(path string) (Source, error)` that: +- If path is empty: return AdHocSource +- If path is a directory containing plan.md or tasks/*.md: return SpecKittySource +- If path is a .md file with checkboxes: return GsdSource +- Otherwise: return error with helpful message + +### speckitty.go + +Parse a spec-kitty feature directory: + +1. Walk `{dir}/tasks/WP*.md` files +2. For each file, split on `---` frontmatter delimiters +3. Parse YAML frontmatter using `gopkg.in/yaml.v3` +4. Extract fields from frontmatter (matching tasks/README.md format): + - `work_package_id` -> Task.ID + - `title` -> Task.Title + - `dependencies` -> Task.Dependencies + - `lane` -> Task.State (planned->Unassigned, doing->InProgress, for_review->InProgress, done->Done) + - `phase` -> Task.Metadata["phase"] + - `subtasks` -> Task.Metadata["subtasks"] (comma-joined) +5. Body after frontmatter -> Task.Description +6. Infer Task.SuggestedRole from phase metadata: + - Phase contains "spec" or "clarifying" -> "planner" + - Phase contains "implementation" -> "coder" + - Phase contains "review" -> "reviewer" + - Phase contains "release" -> "release" + - Default: "" (user selects) + +YAML frontmatter struct: +```go +type wpFrontmatter struct { + WorkPackageID string `yaml:"work_package_id"` + Title string `yaml:"title"` + Lane string `yaml:"lane"` + Dependencies []string `yaml:"dependencies"` + Subtasks []string `yaml:"subtasks"` + Phase string `yaml:"phase"` +} +``` + +**Dependency resolution**: After loading all tasks, check Dependencies. If any +dependency's Task.State is not TaskDone, set this task to TaskBlocked. + +### gsd.go + +Parse a simple markdown file with checkboxes: + +1. Read the file line by line +2. Match lines against `^- \[( |x)\] (.+)$` regex +3. For each match: + - `[ ]` -> TaskUnassigned, `[x]` -> TaskDone + - Task.ID = `T-NNN` (sequential) + - Task.Title = checkbox text + - Task.Description = same as title + - Task.SuggestedRole = "" (user selects) + - Task.Dependencies = [] (GSD doesn't track deps) +4. Ignore non-checkbox lines + +### adhoc.go + +Zero-value source: +```go +type AdHocSource struct{} +func (s *AdHocSource) Type() string { return "ad-hoc" } +func (s *AdHocSource) Path() string { return "" } +func (s *AdHocSource) Load() ([]Task, error) { return nil, nil } +func (s *AdHocSource) Tasks() []Task { return nil } +``` + +### CLI Argument Parsing (main.go) + +Add to cobra root command: +```go +cmd.Args = cobra.MaximumNArgs(1) // optional: path to task source +``` + +In the run function: +1. If arg provided: `source, err := task.DetectSourceType(arg)` +2. If no arg: `source = &task.AdHocSource{}` +3. Call `source.Load()` to parse tasks +4. Pass source to `tui.NewModel(backend, source)` + +### Testing + +**speckitty_test.go**: Create testdata directory with sample WP files (matching +the frontmatter format from tasks/README.md). Test: +- Single WP file parsing +- Multiple WP files with dependencies +- Dependency resolution (blocked state) +- Role inference from phase +- Missing/malformed frontmatter handling +- Invalid YAML graceful error + +**gsd_test.go**: Test with sample markdown: +```markdown +- [ ] Implement auth +- [x] Review PR +- [ ] Deploy +``` +Test: correct count, state mapping, ID generation, non-checkbox line skipping. + +**source_test.go**: Test DetectSourceType with various paths. + +## What NOT to Do + +- Do NOT implement the task panel UI (WP09) +- Do NOT implement batch spawning (WP09) +- Do NOT implement task state updates from worker events (WP09) +- Do NOT implement tasksLoadedMsg handling in TUI (WP09) +- This WP is pure data: parse files, return Task structs + +## Acceptance Criteria + +1. `go test ./internal/task/...` passes with all tests green +2. SpecKittySource correctly parses WP files matching the frontmatter format +3. GsdSource correctly parses checkbox markdown +4. DetectSourceType correctly identifies source type from path +5. Dependency resolution marks blocked tasks correctly +6. CLI accepts optional positional arg: `kasmos path/to/source` +7. Running `kasmos kitty-specs/016-kasmos-agent-orchestrator/` loads WPs (even + though the task panel UI isn't built yet -- source loads without error) +8. `go vet ./...` clean, `go test -race ./internal/task/...` clean diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md new file mode 100644 index 0000000..1b9d7fd --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md @@ -0,0 +1,200 @@ +--- +work_package_id: "WP09" +title: "Task Panel UI + Worker-Task Association + Batch Spawn" +lane: "planned" +dependencies: + - "WP03" + - "WP04" + - "WP08" +subtasks: + - "Task list panel using bubbles/list (wide mode)" + - "Custom list.ItemDelegate for multi-line task items" + - "Task panel focus cycling (3-panel mode)" + - "Spawn from task: pre-fill dialog with task data" + - "Worker-task association: taskStateChangedMsg flow" + - "Batch spawn dialog" + - "Header subtitle with source info" +phase: "Wave 2 - Task Sources + Worker Management" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP09 - Task Panel UI + Worker-Task Association + Batch Spawn + +## Mission + +Build the task panel UI for wide mode (3-column layout), connect task sources to +the TUI with worker-task associations, and implement batch spawning. After this +WP, users can see their WPs in the dashboard, spawn workers directly from tasks +with pre-filled prompts, and batch-spawn multiple tasks. This delivers User Story 5 +(Load Tasks from External Sources). + +## Scope + +### Files to Modify + +``` +internal/tui/model.go # Add task list model, task source fields +internal/tui/panels.go # Task list panel rendering, header subtitle +internal/tui/overlays.go # Spawn dialog pre-fill from task, batch spawn dialog +internal/tui/update.go # Task-related message handlers, task panel key routing +internal/tui/layout.go # Wide mode activation with task source +internal/tui/keys.go # Enable task panel keys, batch spawn key +internal/tui/messages.go # Ensure tasksLoadedMsg, taskStateChangedMsg defined +``` + +### Technical References + +- `design-artifacts/tui-mockups.md`: + - **V4**: Task source panel, 3-column layout (lines 167-211) +- `design-artifacts/tui-layout-spec.md`: + - Wide mode dimensions: 25%/35%/40% (lines 170-206) + - Task list panel specs (lines 313-336) + - Focus system with 3 panels (lines 440-451) +- `design-artifacts/tui-keybinds.md`: + - Task list focused keys: j/k, /, enter, s, b (lines 49-58) +- `design-artifacts/tui-styles.md`: + - Task state badges: taskStatusBadge() (lines 359-384) + - Source subtitle style (line 129) +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 2**: tasksLoadedMsg, taskStateChangedMsg (lines 368-384) + - **Section 4**: Task struct, TaskState enum (lines 582-614) + +## Implementation + +### Task List Panel (panels.go) + +Use `bubbles/list` with a custom `list.ItemDelegate`: + +**List item structure** (4 lines per item + 1 blank separator): +``` +WP-001 Auth middleware <- Title line (bold) +JWT RS256 validation layer <- Description (truncated) +deps: none <- Dependencies (orange if blocking) +check done <- Task state badge +``` + +Custom delegate: +```go +type taskItemDelegate struct{} +func (d taskItemDelegate) Height() int { return 5 } // 4 lines + separator +func (d taskItemDelegate) Spacing() int { return 0 } +func (d taskItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d taskItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) +``` + +Each Task must implement `list.Item`: +```go +func (t Task) FilterValue() string { return t.Title } +func (t Task) Title() string { return t.ID + " " + t.Title } +func (t Task) Description() string { return t.Description } +``` + +The delegate renders: title line, description (truncated to width), deps line +(with dependency IDs), status badge using `taskStatusBadge()`. + +### Wide Mode Activation (layout.go) + +Modify `recalculateLayout()`: +- Wide mode (>=160 cols) only activates when `m.taskSource != nil && m.taskSource.Type() != "ad-hoc"` +- When active: calculate 3-column dimensions (25%/35%/40%) +- Set task list size: `m.taskList.SetSize(tasksInnerWidth, tasksInnerHeight)` + +### Focus Cycling Update + +Modify `cyclablePanels()`: +```go +func (m Model) cyclablePanels() []panel { + if m.hasTaskSource() && m.layoutMode == layoutWide { + return []panel{panelTasks, panelTable, panelViewport} + } + return []panel{panelTable, panelViewport} +} +``` + +### Spawn from Task + +When `enter` or `s` is pressed on a selected task in the task list: +1. Get the selected task from the list +2. Open spawn dialog with pre-filled values: + - Role: task.SuggestedRole (or first option if empty) + - Prompt: task.Description + - TaskID: task.ID (stored on the form for association) +3. User can edit before confirming + +On spawn confirm with a TaskID: +- Set task.WorkerID = newWorker.ID +- Set task.State = TaskInProgress +- Emit taskStateChangedMsg + +### Worker-Task Association + +When a worker exits: +- If worker has TaskID: update the associated task's state + - Exit code 0: task.State = TaskDone + - Exit code != 0: task.State = TaskFailed +- Emit taskStateChangedMsg to refresh the task list + +When a task's dependencies are all TaskDone: +- If task was TaskBlocked, transition to TaskUnassigned + +### Batch Spawn (`b` key) + +When `b` is pressed with a task source loaded: +1. Show a selection overlay listing all unassigned/unblocked tasks +2. User toggles tasks on/off (checkboxes) +3. For each selected task, auto-assign suggested role and use description as prompt +4. Confirm spawns all selected tasks as workers simultaneously +5. Each spawned worker gets its TaskID set + +Implementation: Use a `huh.NewMultiSelect()` with task titles as options. +On confirm, loop through selections and dispatch spawnWorkerCmd for each. + +### Header Subtitle + +When a task source is loaded, render the subtitle line in the header: +``` +spec-kitty: kitty-specs/016-kasmos-agent-orchestrator/ +``` +Or for GSD: +``` +gsd: tasks.md (6 tasks) +``` + +This adds 1 line to headerLines (chromeTotal becomes 5 instead of 4). + +### Status Bar Update + +When task source is loaded, show task counts on the left side: +``` +tasks: 1 done . 1 in-progress . 4 pending workers: 2 running . 1 done +``` + +## What NOT to Do + +- Do NOT implement task file modification (writing back lane changes to WP files) +- Do NOT implement task drag-and-drop or reordering +- Do NOT implement the AI gen-prompt feature (WP11) +- Task filtering uses bubbles/list built-in `/` search -- no custom filter UI + +## Acceptance Criteria + +1. Run `kasmos kitty-specs/016-kasmos-agent-orchestrator/` at >=160 cols -- 3-column layout with tasks +2. Task list shows WP items with title, description, deps, status badges +3. Tab cycles through all 3 panels (tasks, workers, output) +4. Select a task, press `enter` -- spawn dialog opens pre-filled with task data +5. Spawned worker shows task ID in the table's Task column +6. Worker exit updates associated task state (done/failed) +7. Blocked tasks show dependency info, become unassigned when deps resolve +8. Press `b` -- batch spawn dialog appears, select multiple tasks, all spawn +9. Header shows source subtitle when task source is loaded +10. At <160 cols, task panel hides and layout falls back to 2-column +11. `go test ./...` passes diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md new file mode 100644 index 0000000..ce834ec --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md @@ -0,0 +1,211 @@ +--- +work_package_id: "WP10" +title: "Setup Command + Agent Scaffolding" +lane: "planned" +dependencies: + - "WP01" +subtasks: + - "internal/setup/setup.go - Setup orchestration" + - "internal/setup/agents.go - Agent definition templates" + - "internal/setup/deps.go - Dependency validation" + - "cmd/kasmos setup.go - Cobra subcommand" + - "Unit tests" +phase: "Wave 2 - Task Sources + Worker Management" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP10 - Setup Command + Agent Scaffolding + +## Mission + +Implement `kasmos setup`: a CLI subcommand that validates dependencies (opencode, git), +scaffolds OpenCode agent definition files (planner, coder, reviewer, release), and +reports status. This is a self-contained feature with no TUI dependency. Delivers +User Story 6 (Setup and Agent Configuration). + +## Scope + +### Files to Create + +``` +internal/setup/setup.go # Setup orchestration (run all steps) +internal/setup/agents.go # Agent definition templates + write logic +internal/setup/deps.go # Dependency validation (opencode, git) +internal/setup/setup_test.go +internal/setup/deps_test.go +``` + +### Files to Modify + +``` +cmd/kasmos/main.go # Add cobra `setup` subcommand +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 9**: Dependency validation (lines 1191-1215) +- `kitty-specs/016-kasmos-agent-orchestrator/spec.md`: + - User Story 6 acceptance scenarios (lines 91-103) +- `.kittify/memory/constitution.md`: + - OpenCode as sole agent harness (line 22) + - Agent definitions: `.opencode/agents/*.md` (line 22) + +## Implementation + +### deps.go + +Implement dependency validation from tui-technical.md Section 9: + +```go +type DependencyCheck struct { + Name string + Check func() error + Required bool + InstallHint string +} + +func ValidateDependencies() []DependencyCheck { + return []DependencyCheck{ + { + Name: "opencode", + Check: func() error { + _, err := exec.LookPath("opencode") + return err + }, + Required: true, + InstallHint: "go install github.com/anomalyco/opencode@latest", + }, + { + Name: "git", + Check: func() error { + _, err := exec.LookPath("git") + return err + }, + Required: true, + InstallHint: "install via system package manager", + }, + } +} +``` + +Run each check, collect results. Format as a table: +``` +Checking dependencies... + opencode check found (/usr/bin/opencode) + git check found (/usr/bin/git) +``` +Or on failure: +``` + opencode x NOT FOUND + Install: go install github.com/anomalyco/opencode@latest +``` + +### agents.go + +Define 4 agent definition templates. Each produces a `.opencode/agents/{role}.md` +file. The file format follows OpenCode's custom agent spec (markdown with YAML +frontmatter for configuration). + +Agent templates: + +**planner.md**: +- Role: Research and planning, read-only filesystem +- System prompt: emphasis on analysis, plan generation, no code modification +- Tools: read-only (file read, grep, glob, web fetch) +- Model: default (user configures in OpenCode) + +**coder.md**: +- Role: Implementation, full tool access +- System prompt: emphasis on implementation, testing, code quality +- Tools: full access (file write, shell, all reads) + +**reviewer.md**: +- Role: Code review, read-only + test execution +- System prompt: emphasis on correctness, security, quality +- Tools: read-only + shell (for running tests) + +**release.md**: +- Role: Merge, finalization, cleanup +- System prompt: emphasis on merge operations, cleanup, documentation +- Tools: full access + +Write function: +```go +func WriteAgentDefinitions(dir string) error { + agentDir := filepath.Join(dir, ".opencode", "agents") + os.MkdirAll(agentDir, 0o755) + for _, agent := range agentDefinitions { + path := filepath.Join(agentDir, agent.Filename) + if _, err := os.Stat(path); err == nil { + // File exists -- skip (don't overwrite user customizations) + continue + } + os.WriteFile(path, []byte(agent.Content), 0o644) + } +} +``` + +Important: Do NOT overwrite existing agent files. Only create if missing. +Print what was created vs what was skipped. + +### setup.go + +Orchestrate the full setup: +1. Print "kasmos setup" header +2. Run dependency validation, print results +3. If any required dep is missing, print error and exit 1 +4. Determine project root (walk up from cwd looking for go.mod or .git) +5. Write agent definitions to project root +6. Print summary: "N agent definitions created, M skipped (already exist)" +7. Print "Setup complete!" or "Setup failed" with exit code + +### Cobra Subcommand (main.go) + +Add `kasmos setup` as a cobra subcommand: +```go +setupCmd := &cobra.Command{ + Use: "setup", + Short: "Validate dependencies and scaffold agent configurations", + RunE: func(cmd *cobra.Command, args []string) error { + return setup.Run() + }, +} +rootCmd.AddCommand(setupCmd) +``` + +### Testing + +**deps_test.go**: Test with mocked exec.LookPath (use a helper that accepts a +lookup function). Test found/not-found/error cases. + +**setup_test.go**: Test agent definition writing to a temp directory: +- Fresh directory: all 4 files created +- Existing files: not overwritten +- File contents match expected templates + +## What NOT to Do + +- Do NOT create actual OpenCode configuration files (.opencode/config.json etc.) +- Do NOT validate OpenCode version (just check it exists) +- Do NOT make agent templates configurable (fixed templates for MVP) +- Do NOT add this to the TUI startup flow (setup is a standalone command) + +## Acceptance Criteria + +1. `kasmos setup` runs and prints dependency check results +2. Agent definition files are created in `.opencode/agents/` +3. Existing agent files are NOT overwritten +4. Missing dependencies are reported with install hints +5. Exit code 1 if required deps missing, 0 on success +6. `go test ./internal/setup/...` passes +7. `kasmos setup` is idempotent (running twice produces same result) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md new file mode 100644 index 0000000..352eecb --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md @@ -0,0 +1,194 @@ +--- +work_package_id: "WP11" +title: "AI Helpers (Analyze Failure + Generate Prompt)" +lane: "planned" +dependencies: + - "WP04" + - "WP08" +subtasks: + - "Analyze failure: spawn headless worker to analyze output" + - "Generate prompt: spawn headless worker to generate prompt from task" + - "Analysis viewport rendering (V9 mockup)" + - "analyzeStartedMsg/analyzeCompletedMsg handlers" + - "genPromptStartedMsg/genPromptCompletedMsg handlers" + - "Restart with suggested prompt flow" +phase: "Wave 2 - Task Sources + Worker Management" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP11 - AI Helpers (Analyze Failure + Generate Prompt) + +## Mission + +Implement on-demand AI helpers: failure analysis (`a` key) and prompt generation +(`g` key). These are NOT automatic -- the user explicitly triggers them. Each +helper spawns a short-lived headless worker that analyzes content and returns a +structured result. This delivers the FR-012 requirement (on-demand AI helpers). + +## Scope + +### Files to Create + +``` +internal/tui/helpers.go # analyzeCmd, genPromptCmd implementations +``` + +### Files to Modify + +``` +internal/tui/update.go # Analyze and gen-prompt message handlers +internal/tui/panels.go # Analysis viewport rendering +internal/tui/keys.go # Enable analyze/genPrompt keys +internal/tui/model.go # Analysis state fields +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 2**: AI helper messages (lines 338-364) +- `design-artifacts/tui-mockups.md`: + - **V9**: AI failure analysis view (lines 365-402) +- `design-artifacts/tui-keybinds.md`: + - `a` analyze: enabled when selected worker is failed (line 29) + - `g` gen prompt: enabled when task source loaded (line 28) +- `design-artifacts/tui-styles.md`: + - Analysis view styles (lines 438-455) + +## Implementation + +### Failure Analysis (`a` key) + +When user presses `a` on a failed worker: + +1. Emit `analyzeStartedMsg{WorkerID}` +2. Show spinner in viewport: "Analyzing failure for w-NNN..." +3. Spawn a headless OpenCode worker with a structured analysis prompt: + ``` + Analyze this failed agent output and identify the root cause. + + Worker: {id} ({role}) + Exit code: {code} + Duration: {duration} + + Output (last 200 lines): + {output_tail} + + Respond in this exact format: + ROOT_CAUSE: + SUGGESTED_PROMPT: + ``` +4. The analysis worker runs headless (not tracked in the worker table) +5. When analysis worker exits, parse its output for ROOT_CAUSE and SUGGESTED_PROMPT +6. Emit `analyzeCompletedMsg{WorkerID, RootCause, SuggestedPrompt, Err}` + +**Analysis worker configuration**: +- Role: "reviewer" (read-only, analytical) +- The analysis worker itself is spawned via the same WorkerBackend +- It does NOT appear in the worker table (track separately in Model) +- Timeout: kill after 60 seconds if not done + +### Analysis Viewport (panels.go) + +When `analyzeCompletedMsg` is received, render the analysis view in the viewport +matching V9 mockup: + +``` +Analysis header (hot pink bold): "Analysis: w-004 coder" +Separator line +Root Cause label (orange bold): "Root Cause:" +Root cause text (normal) +Blank line +Suggested Fix label (green bold): "Suggested Fix:" +Suggested prompt text (normal) +Blank line +Hint (faint): "Press r to restart with suggested prompt" +``` + +Set the viewport title to "Analysis: {id} {role}" instead of "Output: ..." + +Add to Model: +- `analysisMode bool` -- viewport shows analysis instead of output +- `analysisResult *AnalysisResult` -- parsed root cause + suggested prompt +- `analysisWorkerID string` -- which worker is being analyzed + +Press `esc` to dismiss analysis and return to normal output view. + +### Restart with Suggested Prompt + +When analysis is showing and user presses `r`: +1. Open spawn dialog pre-filled with: + - Same role as the failed worker + - Prompt = the suggested prompt from analysis +2. This reuses the restart flow from WP07 + +### Prompt Generation (`g` key) + +When user presses `g` with table focused and task source loaded: + +1. Get the selected task from the task source +2. Emit `genPromptStartedMsg{TaskID}` +3. Show spinner in viewport: "Generating prompt for {taskID}..." +4. Spawn a headless OpenCode worker with a prompt generation request: + ``` + Generate an implementation prompt for this task. + + Task: {id} - {title} + Description: {description} + Dependencies: {deps} + Suggested role: {role} + + Context files in this project: + {list relevant files if available} + + Generate a detailed, actionable prompt suitable for an AI coding agent. + The prompt should be specific enough to implement without further clarification. + ``` +5. Parse the generated prompt from the helper's output +6. Emit `genPromptCompletedMsg{TaskID, Prompt, Err}` +7. Open spawn dialog pre-filled with the generated prompt + +### Parsing Analysis/Prompt Output + +Simple string parsing: +- Split output on "ROOT_CAUSE:" and "SUGGESTED_PROMPT:" markers +- Trim whitespace +- If markers not found, use the entire output as the result +- Handle partial results gracefully (root cause found but no suggestion) + +### Key Activation + +```go +m.keys.Analyze.SetEnabled(selected != nil && selected.State == StateFailed) +m.keys.GenPrompt.SetEnabled(m.hasTaskSource() && m.focused == panelTable) +``` + +Remember the `g` key conflict from WP06: GenPrompt is only active when table is +focused. When viewport is focused, `g` = GotoTop. + +## What NOT to Do + +- Do NOT auto-analyze on failure (user must press `a` explicitly) +- Do NOT auto-generate prompts (user must press `g` explicitly) +- Do NOT stream analysis output to the viewport (show spinner, then full result) +- Do NOT add analysis workers to the main worker table +- Do NOT implement retry logic for analysis (if it fails, show error) + +## Acceptance Criteria + +1. Select a failed worker, press `a` -- spinner shows, then analysis view appears +2. Analysis shows root cause + suggested prompt matching V9 layout +3. Press `r` in analysis mode -- spawn dialog opens with suggested prompt +4. Press `esc` -- returns to normal output view +5. Select a task in table, press `g` -- prompt is generated and spawn dialog opens +6. `a` disabled for non-failed workers, `g` disabled without task source +7. Analysis worker timeout (60s) works -- shows timeout error +8. `go test ./...` passes diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md new file mode 100644 index 0000000..8270e62 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md @@ -0,0 +1,208 @@ +--- +work_package_id: "WP12" +title: "Daemon Mode (Headless Operation)" +lane: "planned" +dependencies: + - "WP04" +subtasks: + - "internal/tui/daemon.go - Daemon event logging (NDJSON + human-readable)" + - "cmd/kasmos/main.go - -d flag, --format flag, TTY detection" + - "tea.WithoutRenderer() setup for daemon mode" + - "DaemonEvent types and formatting" + - "Exit code logic (0 if all pass, 1 if any fail)" + - "Integration with --spawn-all for batch execution" +phase: "Wave 3 - Daemon Mode + Persistence" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP12 - Daemon Mode (Headless Operation) + +## Mission + +Implement daemon mode: when kasmos runs with `-d` flag or in a non-interactive +terminal, it operates headless -- spawning workers and logging status to stdout +as structured events without rendering a TUI. Supports NDJSON and human-readable +output formats. This delivers User Story 7 (Daemon Mode for Headless Operation). + +## Scope + +### Files to Create + +``` +internal/tui/daemon.go # DaemonEvent types, formatters, logEvent() +``` + +### Files to Modify + +``` +cmd/kasmos/main.go # -d flag, --format flag, --spawn-all flag, TTY detection +internal/tui/model.go # Daemon mode state, conditional View() +internal/tui/update.go # Emit daemon events on state changes +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 6**: Daemon mode output format, NDJSON schema, human-readable format, + implementation notes (lines 908-1027) +- `design-artifacts/tui-mockups.md`: + - **V10**: Daemon mode output examples (lines 406-434) +- `kitty-specs/016-kasmos-agent-orchestrator/spec.md`: + - User Story 7 acceptance scenarios (lines 107-119) +- `.kittify/memory/constitution.md`: + - Daemon mode: same Model/Update loop, no View rendering (line 47) + +## Implementation + +### daemon.go + +Define DaemonEvent types and formatting: + +**Event types** (from tui-technical.md Section 6): +- `session_start`: session_id, mode, source path, task count +- `worker_spawn`: worker id, role, task ref, parent ref +- `worker_output`: worker id, data (optional, can be noisy) +- `worker_exit`: worker id, exit code, duration, session id +- `worker_kill`: worker id +- `analysis_complete`: worker id, root cause summary +- `session_end`: total workers, passed, failed, duration, exit code + +**JSON format** (NDJSON -- one JSON object per line): +```go +type DaemonEvent struct { + Timestamp time.Time `json:"ts"` + Event string `json:"event"` + Data map[string]interface{} `json:"-"` // merged into top level +} +``` + +Each event marshals as a flat JSON object: +```json +{"ts":"2026-02-17T14:28:01Z","event":"worker_spawn","id":"w-001","role":"coder","task":"Implement auth"} +``` + +**Human-readable format** (default): +``` +[14:28:01] w-001 spawned coder "Implement auth" +[14:30:12] w-003 exited(0) reviewer 2m 11s ses_k2m9 +[14:34:02] session ended: 3 passed, 1 failed (6m 02s) exit=1 +``` + +**logEvent function**: +```go +func (m Model) logEvent(event DaemonEvent) { + if m.daemonFormat == "json" { + b, _ := json.Marshal(event) + fmt.Println(string(b)) + } else { + fmt.Println(event.HumanString()) + } +} +``` + +### Daemon Mode View + +In daemon mode, `View()` returns empty string: +```go +func (m Model) View() string { + if m.daemon { + return "" // no rendering + } + // ... normal TUI rendering +} +``` + +The Update loop is IDENTICAL in TUI and daemon mode. State changes that would +update the TUI instead call `logEvent()` in the Update handlers. + +Add `logEvent()` calls to existing Update handlers: +- `workerSpawnedMsg` -> log worker_spawn event +- `workerExitedMsg` -> log worker_exit event +- `workerKilledMsg` -> log worker_kill event +- Session start (Init) -> log session_start event + +### Session End Logic + +Track when all workers have completed. In daemon mode, when no workers are +running and no more tasks to spawn: +1. Calculate aggregate stats: total, passed (exit 0), failed (exit != 0) +2. Calculate total session duration +3. Log session_end event +4. Return tea.Quit with exit code: 0 if all passed, 1 if any failed + +For `--spawn-all` mode: exit after all spawned workers complete. +Without `--spawn-all`: exit after all workers complete (user must spawn via +task source or have pre-spawned). + +### CLI Flags (main.go) + +Add flags to cobra root command: +```go +rootCmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "Run in headless daemon mode") +rootCmd.Flags().StringVar(&format, "format", "default", "Output format: default or json") +rootCmd.Flags().Bool("spawn-all", false, "Spawn workers for all tasks immediately") +``` + +**TTY detection** (auto-daemon): +```go +if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { + daemon = true +} +``` + +**Program setup**: +```go +var opts []tea.ProgramOption +if daemon { + opts = append(opts, tea.WithoutRenderer()) +} else { + opts = append(opts, tea.WithAltScreen()) +} +p := tea.NewProgram(model, opts...) +``` + +### --spawn-all Flag + +When `--spawn-all` is set (typically with daemon mode and a task source): +1. After loading tasks, spawn a worker for each unblocked task +2. Use suggested role and description as prompt +3. As workers complete and blocked tasks become unblocked, spawn those too +4. Exit when all tasks are done or failed + +This creates a batch execution pipeline perfect for CI/CD. + +### SIGPIPE Handling + +In daemon mode, stdout may be a pipe. Handle SIGPIPE gracefully: +```go +signal.Ignore(syscall.SIGPIPE) +``` +Continue managing workers even if stdout pipe breaks. + +## What NOT to Do + +- Do NOT log worker_output events by default (too noisy). Maybe add a `--verbose` flag later. +- Do NOT implement interactive input in daemon mode (no stdin reading) +- Do NOT implement worker output capture differently for daemon mode (same OutputBuffer) +- Do NOT make daemon mode exit immediately on first failure (wait for all workers) + +## Acceptance Criteria + +1. `kasmos -d --format json` outputs NDJSON events to stdout +2. `kasmos -d` (default format) outputs human-readable log lines +3. Exit code is 0 if all workers passed, 1 if any failed +4. `kasmos -d --tasks tasks.md --spawn-all` spawns all tasks and exits when done +5. Non-interactive terminal auto-detects daemon mode +6. Session end event shows aggregate stats +7. SIGPIPE doesn't crash kasmos (daemon stdout to a broken pipe) +8. Same Update loop drives both TUI and daemon mode (no code duplication) +9. `go test ./...` passes diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md new file mode 100644 index 0000000..55e33d0 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md @@ -0,0 +1,237 @@ +--- +work_package_id: "WP13" +title: "Session Persistence + Reattach" +lane: "planned" +dependencies: + - "WP04" +subtasks: + - "internal/persist/schema.go - SessionState struct (maps to JSON schema)" + - "internal/persist/session.go - SessionPersister (save/load, atomic write, debounce)" + - "cmd/kasmos/main.go - --attach flag" + - "Reattach logic: detect running session, restore state" + - "Orphan detection (PID dead, mark workers killed)" + - "Output tail preservation (last 200 lines per worker)" + - "Unit tests" +phase: "Wave 3 - Daemon Mode + Persistence" +assignee: "" +agent: "" +shell_pid: "" +review_status: "" +reviewed_by: "" +history: + - timestamp: "2026-02-17T00:00:00Z" + lane: "planned" + agent: "planner" + action: "Prompt generated via /spec-kitty.tasks" +--- + +# Work Package Prompt: WP13 - Session Persistence + Reattach + +## Mission + +Implement session state persistence and reattach: kasmos saves session state to +`.kasmos/session.json` after every state change (debounced), and `kasmos --attach` +restores the session from disk. Orphaned sessions (PID dead) get their running +workers marked as killed. This delivers User Story 8 (Session Persistence and +Reattach). + +## Scope + +### Files to Create + +``` +internal/persist/schema.go # SessionState, WorkerSnapshot, TaskSourceConfig structs +internal/persist/session.go # SessionPersister: save, load, atomic write, debounce +internal/persist/session_test.go +internal/persist/schema_test.go +``` + +### Files to Modify + +``` +cmd/kasmos/main.go # --attach flag, session restore on startup +internal/tui/model.go # Add SessionPersister, trigger saves +internal/tui/update.go # Call persister.Save() after state-mutating messages +internal/worker/manager.go # ResetWorkerCounter for session restore +``` + +### Technical References + +- `kitty-specs/016-kasmos-agent-orchestrator/research/tui-technical.md`: + - **Section 5**: Session persistence schema, JSON schema, example file, + persistence behavior (lines 703-904) + - **Section 8**: Graceful shutdown persist step (lines 1089-1121) +- `kitty-specs/016-kasmos-agent-orchestrator/data-model.md`: + - SessionState entity (lines 80-93) +- `kitty-specs/016-kasmos-agent-orchestrator/spec.md`: + - User Story 8 acceptance scenarios (lines 123-135) + +## Implementation + +### schema.go + +Define Go structs matching the JSON schema in tui-technical.md Section 5: + +```go +type SessionState struct { + Version int `json:"version"` // always 1 + SessionID string `json:"session_id"` // "ks-{unix_ts}-{rand4}" + StartedAt time.Time `json:"started_at"` + TaskSource *TaskSourceConfig `json:"task_source"` // null for ad-hoc + Workers []WorkerSnapshot `json:"workers"` + NextWorkerNum int `json:"next_worker_num"` + PID int `json:"pid"` +} + +type TaskSourceConfig struct { + Type string `json:"type"` // "spec-kitty", "gsd", "ad-hoc" + Path string `json:"path"` +} + +type WorkerSnapshot struct { + ID string `json:"id"` + Role string `json:"role"` + Prompt string `json:"prompt"` + Files []string `json:"files"` + State string `json:"state"` // "pending","spawning","running","exited","failed","killed" + ExitCode *int `json:"exit_code"` // null if not exited + SpawnedAt time.Time `json:"spawned_at"` + ExitedAt *time.Time `json:"exited_at"` // null if running + DurationMs *int64 `json:"duration_ms"` // null if running + SessionID string `json:"session_id"` + ParentID string `json:"parent_id"` + TaskID string `json:"task_id"` + PID *int `json:"pid"` // null if not running + OutputTail string `json:"output_tail"` // last 200 lines +} +``` + +**Session ID generation**: +```go +func NewSessionID() string { + return fmt.Sprintf("ks-%d-%s", time.Now().Unix(), randomAlpha(4)) +} +``` + +**Conversion functions**: +- `WorkerToSnapshot(w *worker.Worker) WorkerSnapshot` -- converts live worker to snapshot +- `SnapshotToWorker(s WorkerSnapshot) *worker.Worker` -- restores worker from snapshot + (Handle will be nil, Output will be populated from OutputTail) + +### session.go + +Implement SessionPersister: + +```go +type SessionPersister struct { + Path string // ".kasmos/session.json" + debounce time.Duration // 1 second + mu sync.Mutex + dirty bool + timer *time.Timer +} +``` + +**Save(state SessionState)**: +- Set dirty flag +- If no timer running, start debounce timer +- When timer fires: acquire lock, write to temp file, rename atomically + +**SaveSync(state SessionState)**: +- Immediate write (no debounce). Used during shutdown. + +**Atomic write**: +```go +func (p *SessionPersister) writeAtomic(state SessionState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { return err } + + tmpPath := p.Path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { return err } + return os.Rename(tmpPath, p.Path) +} +``` + +**Load()**: +- Read `.kasmos/session.json` +- Unmarshal into SessionState +- Validate version field + +### Reattach Logic (main.go) + +`kasmos --attach` flag: + +1. Check if `.kasmos/session.json` exists +2. Load session state +3. Check if PID in session is alive: `syscall.Kill(pid, 0)` + - If alive: "Session already active (PID {pid}). Cannot reattach to a running session." + Exit 1. (True reattach to a running process would require IPC -- future work) + - If dead: proceed with restore +4. Mark all workers with state "running" as "killed" (orphaned) +5. Reset worker counter to `NextWorkerNum` from session +6. Restore worker list (from snapshots, with OutputTail loaded into OutputBuffers) +7. Start TUI with restored state +8. Generate new session PID + +If no session file exists: "No session found. Start a new session with `kasmos`." + +### Integration with TUI (model.go / update.go) + +Add `persister *persist.SessionPersister` to Model. + +Call `persister.Save(m.buildSessionState())` after every state-mutating message: +- workerSpawnedMsg +- workerExitedMsg +- workerKilledMsg +- spawnDialogSubmittedMsg +- continueDialogSubmittedMsg +- taskStateChangedMsg + +`buildSessionState()` converts current Model state to a SessionState struct. + +On graceful shutdown (from WP06): call `persister.SaveSync()` before exit. + +### Startup Session File + +On normal startup (not --attach): +1. Create `.kasmos/` directory if not exists +2. Generate new session ID +3. Write initial session state with PID = os.Getpid() +4. On exit: write final state + +### Testing + +**schema_test.go**: +- Test SessionState JSON marshaling/unmarshaling roundtrip +- Test WorkerToSnapshot/SnapshotToWorker conversion +- Test SessionID generation format +- Test null handling for optional fields (ExitCode, ExitedAt, PID) + +**session_test.go**: +- Test atomic write (file exists after write, no partial writes) +- Test debounce (multiple saves within 1s produce one write) +- Test SaveSync (immediate write, no debounce) +- Test Load with valid/invalid/missing files +- Test PID-alive detection (mock with current PID) + +## What NOT to Do + +- Do NOT implement true process reattach (connecting to a running kasmos instance) + -- that would require IPC/socket communication, which is future work +- Do NOT persist worker output beyond OutputTail (200 lines) -- full output is lost +- Do NOT persist the task source state (tasks are re-loaded from files on reattach) +- Do NOT encrypt session.json (no secrets stored) +- Do NOT persist TUI state (focus, scroll position, layout mode) -- only worker data + +## Acceptance Criteria + +1. `.kasmos/session.json` is written after worker state changes +2. File contents match the JSON schema from tui-technical.md Section 5 +3. Atomic write: no partial/corrupt files even on crash +4. Debounce: rapid state changes produce at most one write per second +5. `kasmos --attach` restores worker states from session file +6. Orphaned workers (PID dead) are marked as killed on restore +7. Worker counter resets correctly (no ID collisions after restore) +8. OutputTail (last 200 lines) is preserved and displayed on restore +9. "No session found" message when no session file exists +10. `go test ./internal/persist/...` passes, including `-race` From cb002ab76f3927ddf0a35b9cbd0206858c6d1b6c Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 00:09:07 -0600 Subject: [PATCH 05/26] feat(016): WP01 project bootstrap + WP02 worker backend WP01: go.mod with all deps, cmd/kasmos/main.go with cobra + minimal bubbletea program, signal handling, alt screen. WP02: internal/worker/ package - WorkerBackend interface, SubprocessBackend (os/exec), Worker state machine, OutputBuffer ring buffer, session ID extraction, WorkerManager with atomic IDs. Full test suite passes including -race. --- .gitignore | 1 + go.mod | 35 +++++++ go.sum | 114 ++++++++++++++++++++++ internal/worker/backend.go | 41 ++++++++ internal/worker/backend_test.go | 58 +++++++++++ internal/worker/manager.go | 67 +++++++++++++ internal/worker/manager_test.go | 59 +++++++++++ internal/worker/output.go | 135 +++++++++++++++++++++++++ internal/worker/output_test.go | 102 +++++++++++++++++++ internal/worker/session.go | 20 ++++ internal/worker/session_test.go | 46 +++++++++ internal/worker/subprocess.go | 168 ++++++++++++++++++++++++++++++++ internal/worker/worker.go | 110 +++++++++++++++++++++ internal/worker/worker_test.go | 125 ++++++++++++++++++++++++ 14 files changed, 1081 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/worker/backend.go create mode 100644 internal/worker/backend_test.go create mode 100644 internal/worker/manager.go create mode 100644 internal/worker/manager_test.go create mode 100644 internal/worker/output.go create mode 100644 internal/worker/output_test.go create mode 100644 internal/worker/session.go create mode 100644 internal/worker/session_test.go create mode 100644 internal/worker/subprocess.go create mode 100644 internal/worker/worker.go create mode 100644 internal/worker/worker_test.go diff --git a/.gitignore b/.gitignore index 0dbbf21..351e3ba 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ HANDOFF.md SPEC_REVIEW_PLAN.md kasmos-collect.sh kasmos-config.sh +kasmos diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..faa4927 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module github.com/user/kasmos + +go 1.23.0 + +require ( + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 + github.com/spf13/cobra v1.8.0 +) + +require github.com/charmbracelet/x/input v0.3.7 // indirect + +require ( + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/huh v0.6.0 + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/windows v0.2.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/gamut v0.3.1 + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e5989fa --- /dev/null +++ b/go.sum @@ -0,0 +1,114 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= +github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0= +github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/input v0.3.7 h1:UzVbkt1vgM9dBQ+K+uRolBlN6IF2oLchmPKKo/aucXo= +github.com/charmbracelet/x/input v0.3.7/go.mod h1:ZSS9Cia6Cycf2T6ToKIOxeTBTDwl25AGwArJuGaOBH8= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= +github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/clusters v0.0.0-20180605185049-a07a36e67d36/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= +github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 h1:p4A2Jx7Lm3NV98VRMKlyWd3nqf8obft8NfXlAUmqd3I= +github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= +github.com/muesli/gamut v0.3.1 h1:8hozovcrDBWLLAwuOXC+UDyO0/uNroIdXAmY/lQOMHo= +github.com/muesli/gamut v0.3.1/go.mod h1:BED0DN21PXU1YaYNwaTmX9700SRHPcWWd6Llj0zsz5k= +github.com/muesli/kmeans v0.3.1 h1:KshLQ8wAETfLWOJKMuDCVYHnafddSa1kwGh/IypGIzY= +github.com/muesli/kmeans v0.3.1/go.mod h1:8/OvJW7cHc1BpRf8URb43m+vR105DDe+Kj1WcFXYDqc= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I= +github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= +golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/worker/backend.go b/internal/worker/backend.go new file mode 100644 index 0000000..c909b9d --- /dev/null +++ b/internal/worker/backend.go @@ -0,0 +1,41 @@ +package worker + +import ( + "context" + "io" + "time" +) + +// WorkerBackend abstracts the mechanism for running worker processes. +type WorkerBackend interface { + Spawn(ctx context.Context, cfg SpawnConfig) (WorkerHandle, error) + Name() string +} + +// SpawnConfig contains everything needed to start a worker. +type SpawnConfig struct { + ID string + Role string + Prompt string + Files []string + ContinueSession string + Model string + WorkDir string + Env map[string]string +} + +// WorkerHandle provides lifecycle control over a running worker. +type WorkerHandle interface { + Stdout() io.Reader + Wait() ExitResult + Kill(gracePeriod time.Duration) error + PID() int +} + +// ExitResult contains the outcome of a completed worker process. +type ExitResult struct { + Code int + Duration time.Duration + SessionID string + Error error +} diff --git a/internal/worker/backend_test.go b/internal/worker/backend_test.go new file mode 100644 index 0000000..7d05e78 --- /dev/null +++ b/internal/worker/backend_test.go @@ -0,0 +1,58 @@ +package worker + +import "testing" + +func TestBuildArgs(t *testing.T) { + b := &SubprocessBackend{} + + tests := []struct { + name string + cfg SpawnConfig + want []string + }{ + { + name: "prompt only", + cfg: SpawnConfig{Prompt: "implement feature"}, + want: []string{"run", "implement feature"}, + }, + { + name: "role and prompt", + cfg: SpawnConfig{Role: "coder", Prompt: "do work"}, + want: []string{"run", "--agent", "coder", "do work"}, + }, + { + name: "continue and role", + cfg: SpawnConfig{ContinueSession: "ses_abc123", Role: "reviewer", Prompt: "continue"}, + want: []string{"run", "--agent", "reviewer", "--continue", "-s", "ses_abc123", "continue"}, + }, + { + name: "model and files", + cfg: SpawnConfig{ + Role: "planner", + Model: "openai/gpt-5", + Files: []string{"spec.md", "plan.md"}, + Prompt: "plan it", + }, + want: []string{"run", "--agent", "planner", "--model", "openai/gpt-5", "--file", "spec.md", "--file", "plan.md", "plan it"}, + }, + { + name: "empty config", + cfg: SpawnConfig{}, + want: []string{"run"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := b.buildArgs(tc.cfg) + if len(got) != len(tc.want) { + t.Fatalf("len mismatch: got=%d want=%d args=%v", len(got), len(tc.want), got) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("arg[%d] mismatch: got=%q want=%q all=%v", i, got[i], tc.want[i], got) + } + } + }) + } +} diff --git a/internal/worker/manager.go b/internal/worker/manager.go new file mode 100644 index 0000000..8a124f7 --- /dev/null +++ b/internal/worker/manager.go @@ -0,0 +1,67 @@ +package worker + +import ( + "fmt" + "sync" + "sync/atomic" +) + +type WorkerManager struct { + mu sync.RWMutex + workers []*Worker + counter atomic.Int64 +} + +func NewWorkerManager() *WorkerManager { + return &WorkerManager{workers: make([]*Worker, 0)} +} + +func (m *WorkerManager) NextWorkerID() string { + n := m.counter.Add(1) + return fmt.Sprintf("w-%03d", n) +} + +func (m *WorkerManager) ResetWorkerCounter(n int64) { + m.counter.Store(n) +} + +func (m *WorkerManager) Add(w *Worker) { + if w == nil { + return + } + + m.mu.Lock() + defer m.mu.Unlock() + m.workers = append(m.workers, w) +} + +func (m *WorkerManager) Get(id string) *Worker { + m.mu.RLock() + defer m.mu.RUnlock() + for _, w := range m.workers { + if w.ID == id { + return w + } + } + return nil +} + +func (m *WorkerManager) All() []*Worker { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]*Worker, len(m.workers)) + copy(out, m.workers) + return out +} + +func (m *WorkerManager) Running() []*Worker { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]*Worker, 0) + for _, w := range m.workers { + if w.State == StateRunning { + out = append(out, w) + } + } + return out +} diff --git a/internal/worker/manager_test.go b/internal/worker/manager_test.go new file mode 100644 index 0000000..b7d792d --- /dev/null +++ b/internal/worker/manager_test.go @@ -0,0 +1,59 @@ +package worker + +import "testing" + +func TestWorkerManagerIDSequence(t *testing.T) { + m := NewWorkerManager() + m.ResetWorkerCounter(0) + + if got, want := m.NextWorkerID(), "w-001"; got != want { + t.Fatalf("first id mismatch: got=%q want=%q", got, want) + } + if got, want := m.NextWorkerID(), "w-002"; got != want { + t.Fatalf("second id mismatch: got=%q want=%q", got, want) + } + if got, want := m.NextWorkerID(), "w-003"; got != want { + t.Fatalf("third id mismatch: got=%q want=%q", got, want) + } +} + +func TestWorkerManagerResetWorkerCounter(t *testing.T) { + m := NewWorkerManager() + m.ResetWorkerCounter(41) + + if got, want := m.NextWorkerID(), "w-042"; got != want { + t.Fatalf("id mismatch after reset: got=%q want=%q", got, want) + } +} + +func TestWorkerManagerAddGetRunning(t *testing.T) { + m := NewWorkerManager() + + w1 := &Worker{ID: "w-001", State: StateRunning} + w2 := &Worker{ID: "w-002", State: StateExited} + w3 := &Worker{ID: "w-003", State: StateSpawning} + + m.Add(w1) + m.Add(w2) + m.Add(w3) + + if got := m.Get("w-002"); got == nil || got.ID != "w-002" { + t.Fatalf("expected to fetch worker w-002") + } + if got := m.Get("missing"); got != nil { + t.Fatalf("expected nil for missing worker") + } + + all := m.All() + if len(all) != 3 { + t.Fatalf("all workers length mismatch: got=%d want=3", len(all)) + } + + running := m.Running() + if len(running) != 1 { + t.Fatalf("running workers length mismatch: got=%d want=1", len(running)) + } + if running[0].ID != "w-001" { + t.Fatalf("running worker id mismatch: got=%q want=%q", running[0].ID, "w-001") + } +} diff --git a/internal/worker/output.go b/internal/worker/output.go new file mode 100644 index 0000000..7084873 --- /dev/null +++ b/internal/worker/output.go @@ -0,0 +1,135 @@ +package worker + +import ( + "strings" + "sync" + "unicode/utf8" +) + +const DefaultMaxLines = 5000 + +// OutputBuffer is a thread-safe ring buffer of output lines. +type OutputBuffer struct { + mu sync.RWMutex + lines []string + maxLines int + start int + count int + total int +} + +func NewOutputBuffer(maxLines int) *OutputBuffer { + if maxLines <= 0 { + maxLines = DefaultMaxLines + } + + return &OutputBuffer{ + lines: make([]string, maxLines), + maxLines: maxLines, + } +} + +func (b *OutputBuffer) Append(data string) { + if data == "" { + return + } + + parts := strings.Split(data, "\n") + if len(parts) > 0 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + + b.mu.Lock() + defer b.mu.Unlock() + + for _, line := range parts { + line = sanitizeUTF8(line) + b.appendLineLocked(line) + } +} + +func (b *OutputBuffer) Lines() []string { + b.mu.RLock() + defer b.mu.RUnlock() + + out := make([]string, 0, b.count) + for i := 0; i < b.count; i++ { + idx := (b.start + i) % b.maxLines + out = append(out, b.lines[idx]) + } + + return out +} + +func (b *OutputBuffer) Content() string { + return strings.Join(b.Lines(), "\n") +} + +func (b *OutputBuffer) Tail(n int) string { + if n <= 0 { + return "" + } + + b.mu.RLock() + defer b.mu.RUnlock() + + if b.count == 0 { + return "" + } + + if n > b.count { + n = b.count + } + + start := b.count - n + out := make([]string, 0, n) + for i := start; i < b.count; i++ { + idx := (b.start + i) % b.maxLines + out = append(out, b.lines[idx]) + } + + return strings.Join(out, "\n") +} + +func (b *OutputBuffer) LineCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return b.count +} + +func (b *OutputBuffer) TotalLines() int { + b.mu.RLock() + defer b.mu.RUnlock() + return b.total +} + +func (b *OutputBuffer) Truncated() int { + b.mu.RLock() + defer b.mu.RUnlock() + if b.total <= b.count { + return 0 + } + return b.total - b.count +} + +func (b *OutputBuffer) appendLineLocked(line string) { + if b.count < b.maxLines { + idx := (b.start + b.count) % b.maxLines + b.lines[idx] = line + b.count++ + b.total++ + return + } + + b.lines[b.start] = line + b.start = (b.start + 1) % b.maxLines + b.total++ +} + +func sanitizeUTF8(s string) string { + if utf8.ValidString(s) { + return s + } + + return strings.ToValidUTF8(s, "\uFFFD") +} diff --git a/internal/worker/output_test.go b/internal/worker/output_test.go new file mode 100644 index 0000000..5847b68 --- /dev/null +++ b/internal/worker/output_test.go @@ -0,0 +1,102 @@ +package worker + +import ( + "strconv" + "sync" + "testing" +) + +func TestOutputBufferAppendAndLines(t *testing.T) { + b := NewOutputBuffer(10) + b.Append("line1\nline2\nline3") + + got := b.Lines() + want := []string{"line1", "line2", "line3"} + if len(got) != len(want) { + t.Fatalf("len mismatch: got=%d want=%d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("line[%d] mismatch: got=%q want=%q", i, got[i], want[i]) + } + } +} + +func TestOutputBufferOverflow(t *testing.T) { + b := NewOutputBuffer(3) + b.Append("1\n2\n3\n4") + + if b.LineCount() != 3 { + t.Fatalf("line count: got=%d want=3", b.LineCount()) + } + if b.TotalLines() != 4 { + t.Fatalf("total lines: got=%d want=4", b.TotalLines()) + } + if b.Truncated() != 1 { + t.Fatalf("truncated: got=%d want=1", b.Truncated()) + } + + got := b.Lines() + want := []string{"2", "3", "4"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("line[%d] mismatch: got=%q want=%q", i, got[i], want[i]) + } + } +} + +func TestOutputBufferContentAndTail(t *testing.T) { + b := NewOutputBuffer(10) + b.Append("a\nb\nc\nd") + + if got, want := b.Content(), "a\nb\nc\nd"; got != want { + t.Fatalf("content mismatch: got=%q want=%q", got, want) + } + if got, want := b.Tail(2), "c\nd"; got != want { + t.Fatalf("tail mismatch: got=%q want=%q", got, want) + } + if got, want := b.Tail(0), ""; got != want { + t.Fatalf("tail(0) mismatch: got=%q want=%q", got, want) + } +} + +func TestOutputBufferConcurrentAppend(t *testing.T) { + b := NewOutputBuffer(5000) + + const ( + goroutines = 20 + perG = 200 + ) + + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + go func(g int) { + defer wg.Done() + for i := 0; i < perG; i++ { + b.Append("g" + strconv.Itoa(g) + "-" + strconv.Itoa(i)) + } + }(g) + } + wg.Wait() + + if got, want := b.TotalLines(), goroutines*perG; got != want { + t.Fatalf("total lines mismatch: got=%d want=%d", got, want) + } + if b.LineCount() > 5000 { + t.Fatalf("line count exceeded max: %d", b.LineCount()) + } +} + +func TestOutputBufferInvalidUTF8(t *testing.T) { + b := NewOutputBuffer(10) + b.Append(string([]byte{0xff, 'a'})) + + lines := b.Lines() + if len(lines) != 1 { + t.Fatalf("line count mismatch: got=%d want=1", len(lines)) + } + if lines[0] != "\ufffda" { + t.Fatalf("invalid utf8 not replaced: got=%q", lines[0]) + } +} diff --git a/internal/worker/session.go b/internal/worker/session.go new file mode 100644 index 0000000..cbd0a00 --- /dev/null +++ b/internal/worker/session.go @@ -0,0 +1,20 @@ +package worker + +import "regexp" + +var ( + sessionTextPattern = regexp.MustCompile(`session:\s+(ses_[a-zA-Z0-9]+)`) + sessionJSONPattern = regexp.MustCompile(`"session_id"\s*:\s*"(ses_[a-zA-Z0-9]+)"`) +) + +func extractSessionID(output string) string { + if m := sessionTextPattern.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + + if m := sessionJSONPattern.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + + return "" +} diff --git a/internal/worker/session_test.go b/internal/worker/session_test.go new file mode 100644 index 0000000..e31e362 --- /dev/null +++ b/internal/worker/session_test.go @@ -0,0 +1,46 @@ +package worker + +import "testing" + +func TestExtractSessionID(t *testing.T) { + tests := []struct { + name string + input string + output string + }{ + { + name: "text format", + input: "[coder] session: ses_ab12CD34", + output: "ses_ab12CD34", + }, + { + name: "json format", + input: `{"event":"start","session_id":"ses_Z9y8X7"}`, + output: "ses_Z9y8X7", + }, + { + name: "no match", + input: "no session in this output", + output: "", + }, + { + name: "partial text", + input: "session: ses_", + output: "", + }, + { + name: "partial json", + input: `{"session_id":"ses_"}`, + output: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractSessionID(tc.input) + if got != tc.output { + t.Fatalf("extractSessionID() = %q, want %q", got, tc.output) + } + }) + } +} diff --git a/internal/worker/subprocess.go b/internal/worker/subprocess.go new file mode 100644 index 0000000..0704cbf --- /dev/null +++ b/internal/worker/subprocess.go @@ -0,0 +1,168 @@ +package worker + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +var _ WorkerBackend = (*SubprocessBackend)(nil) + +type SubprocessBackend struct { + OpenCodeBin string +} + +func NewSubprocessBackend() (*SubprocessBackend, error) { + bin, err := exec.LookPath("opencode") + if err != nil { + return nil, fmt.Errorf("opencode not found in PATH: %w", err) + } + + return &SubprocessBackend{OpenCodeBin: bin}, nil +} + +func (b *SubprocessBackend) Name() string { + return "subprocess" +} + +func (b *SubprocessBackend) Spawn(ctx context.Context, cfg SpawnConfig) (WorkerHandle, error) { + args := b.buildArgs(cfg) + cmd := exec.CommandContext(ctx, b.OpenCodeBin, args...) + if cfg.WorkDir != "" { + cmd.Dir = cfg.WorkDir + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + cmd.Stderr = cmd.Stdout + + cmd.Env = os.Environ() + for k, v := range cfg.Env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + startTime := time.Now() + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start opencode: %w", err) + } + + return &subprocessHandle{ + cmd: cmd, + stdout: stdout, + startTime: startTime, + }, nil +} + +func (b *SubprocessBackend) buildArgs(cfg SpawnConfig) []string { + args := []string{"run"} + + if cfg.Role != "" { + args = append(args, "--agent", cfg.Role) + } + if cfg.ContinueSession != "" { + args = append(args, "--continue", "-s", cfg.ContinueSession) + } + if cfg.Model != "" { + args = append(args, "--model", cfg.Model) + } + for _, f := range cfg.Files { + args = append(args, "--file", f) + } + if cfg.Prompt != "" { + args = append(args, cfg.Prompt) + } + + return args +} + +type subprocessHandle struct { + cmd *exec.Cmd + stdout io.ReadCloser + startTime time.Time + + waitOnce sync.Once + waitResult ExitResult +} + +func (h *subprocessHandle) Stdout() io.Reader { + return h.stdout +} + +func (h *subprocessHandle) Wait() ExitResult { + h.waitOnce.Do(func() { + result := ExitResult{Duration: time.Since(h.startTime)} + + err := h.cmd.Wait() + if err == nil { + result.Code = 0 + h.waitResult = result + return + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.Code = exitErr.ExitCode() + h.waitResult = result + return + } + + result.Code = -1 + result.Error = err + h.waitResult = result + }) + + return h.waitResult +} + +func (h *subprocessHandle) Kill(gracePeriod time.Duration) error { + if h.cmd == nil || h.cmd.Process == nil { + return errors.New("process not started") + } + + pid := h.cmd.Process.Pid + if pid <= 0 { + return errors.New("invalid process id") + } + + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("send SIGTERM to process group %d: %w", pid, err) + } + + if gracePeriod <= 0 { + return nil + } + + timer := time.NewTimer(gracePeriod) + defer timer.Stop() + <-timer.C + + if err := syscall.Kill(-pid, 0); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + return fmt.Errorf("check process group %d: %w", pid, err) + } + + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("send SIGKILL to process group %d: %w", pid, err) + } + + return nil +} + +func (h *subprocessHandle) PID() int { + if h.cmd == nil || h.cmd.Process == nil { + return 0 + } + + return h.cmd.Process.Pid +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go new file mode 100644 index 0000000..4623c78 --- /dev/null +++ b/internal/worker/worker.go @@ -0,0 +1,110 @@ +package worker + +import ( + "errors" + "fmt" + "time" +) + +type WorkerState string + +const ( + StatePending WorkerState = "pending" + StateSpawning WorkerState = "spawning" + StateRunning WorkerState = "running" + StateExited WorkerState = "exited" + StateFailed WorkerState = "failed" + StateKilled WorkerState = "killed" +) + +// Worker represents a managed agent session. +type Worker struct { + ID string + Role string + Prompt string + Files []string + + State WorkerState + ExitCode int + SpawnedAt time.Time + ExitedAt time.Time + + SessionID string + + ParentID string + TaskID string + + Handle WorkerHandle + Output *OutputBuffer +} + +func (w *Worker) Duration() time.Duration { + if w.ExitedAt.IsZero() { + if w.SpawnedAt.IsZero() { + return 0 + } + return time.Since(w.SpawnedAt) + } + + return w.ExitedAt.Sub(w.SpawnedAt) +} + +func (w *Worker) FormatDuration() string { + if w.State == StatePending || w.State == StateSpawning { + return " - " + } + + d := w.Duration() + if d < time.Hour { + return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60) + } + + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) +} + +func (w *Worker) Children(all []*Worker) []string { + ids := make([]string, 0) + for _, other := range all { + if other.ParentID == w.ID { + ids = append(ids, other.ID) + } + } + + return ids +} + +func (s WorkerState) CanTransition(to WorkerState) bool { + switch s { + case StatePending: + return to == StateSpawning + case StateSpawning: + return to == StateRunning || to == StateFailed || to == StateKilled + case StateRunning: + return to == StateExited || to == StateFailed || to == StateKilled + case StateExited, StateFailed, StateKilled: + return false + default: + return false + } +} + +func (w *Worker) Transition(to WorkerState) error { + if w.State == to { + return nil + } + if !w.State.CanTransition(to) { + return fmt.Errorf("invalid worker state transition: %s -> %s", w.State, to) + } + + w.State = to + return nil +} + +func (s WorkerState) Validate() error { + switch s { + case StatePending, StateSpawning, StateRunning, StateExited, StateFailed, StateKilled: + return nil + default: + return errors.New("invalid worker state") + } +} diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go new file mode 100644 index 0000000..ae77227 --- /dev/null +++ b/internal/worker/worker_test.go @@ -0,0 +1,125 @@ +package worker + +import ( + "testing" + "time" +) + +func TestWorkerDuration(t *testing.T) { + now := time.Now() + tests := []struct { + name string + w Worker + min time.Duration + max time.Duration + }{ + { + name: "pending", + w: Worker{State: StatePending}, + min: 0, + max: 0, + }, + { + name: "running", + w: Worker{ + State: StateRunning, + SpawnedAt: now.Add(-2 * time.Second), + }, + min: time.Second, + max: 3 * time.Second, + }, + { + name: "exited", + w: Worker{ + State: StateExited, + SpawnedAt: now.Add(-90 * time.Second), + ExitedAt: now, + }, + min: 90 * time.Second, + max: 90 * time.Second, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.w.Duration() + if got < tc.min || got > tc.max { + t.Fatalf("duration out of range: got=%v range=[%v,%v]", got, tc.min, tc.max) + } + }) + } +} + +func TestWorkerFormatDuration(t *testing.T) { + now := time.Now() + tests := []struct { + name string + w Worker + out string + }{ + { + name: "pending", + w: Worker{State: StatePending}, + out: " - ", + }, + { + name: "spawning", + w: Worker{State: StateSpawning}, + out: " - ", + }, + { + name: "minutes and seconds", + w: Worker{ + State: StateExited, + SpawnedAt: now.Add(-95 * time.Second), + ExitedAt: now, + }, + out: "1m 35s", + }, + { + name: "hours and minutes", + w: Worker{ + State: StateExited, + SpawnedAt: now.Add(-(2*time.Hour + 15*time.Minute)), + ExitedAt: now, + }, + out: "2h 15m", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.w.FormatDuration(); got != tc.out { + t.Fatalf("format mismatch: got=%q want=%q", got, tc.out) + } + }) + } +} + +func TestWorkerStateTransitions(t *testing.T) { + tests := []struct { + name string + from WorkerState + to WorkerState + ok bool + }{ + {name: "pending to spawning", from: StatePending, to: StateSpawning, ok: true}, + {name: "spawning to running", from: StateSpawning, to: StateRunning, ok: true}, + {name: "running to exited", from: StateRunning, to: StateExited, ok: true}, + {name: "pending to running invalid", from: StatePending, to: StateRunning, ok: false}, + {name: "exited to running invalid", from: StateExited, to: StateRunning, ok: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := &Worker{State: tc.from} + err := w.Transition(tc.to) + if tc.ok && err != nil { + t.Fatalf("expected transition success, got error: %v", err) + } + if !tc.ok && err == nil { + t.Fatalf("expected transition error, got nil") + } + }) + } +} From 2d4b1bf944c262d41e718f987d4080d2b75836dc Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 02:53:47 -0600 Subject: [PATCH 06/26] feat(016): WP03 TUI foundation - layout, styles, keys, empty dashboard internal/tui/ package with 6 files: model.go (Model + Init/Update/View), layout.go (4 breakpoints, responsive dimension math), styles.go (full Charm bubblegum palette, gradient title, indicators, badges), keys.go (26 bindings, ShortHelp/FullHelp, context-dependent states), messages.go (22 message types for full event catalog), panels.go (header, empty table, welcome viewport, status bar, help overlay). Empty dashboard renders matching V11 mockup. Resize, focus cycling, help toggle all functional. --- .gitignore | 2 +- cmd/kasmos/main.go | 47 ++++++ go.mod | 34 ++-- go.sum | 94 +++-------- internal/tui/keys.go | 187 ++++++++++++++++++++++ internal/tui/layout.go | 154 ++++++++++++++++++ internal/tui/messages.go | 126 +++++++++++++++ internal/tui/model.go | 223 ++++++++++++++++++++++++++ internal/tui/panels.go | 119 ++++++++++++++ internal/tui/styles.go | 333 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 1235 insertions(+), 84 deletions(-) create mode 100644 cmd/kasmos/main.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/layout.go create mode 100644 internal/tui/messages.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/panels.go create mode 100644 internal/tui/styles.go diff --git a/.gitignore b/.gitignore index 351e3ba..d227a9f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,4 @@ HANDOFF.md SPEC_REVIEW_PLAN.md kasmos-collect.sh kasmos-config.sh -kasmos +/kasmos diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go new file mode 100644 index 0000000..786ee13 --- /dev/null +++ b/cmd/kasmos/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/spf13/cobra" + + "github.com/user/kasmos/internal/tui" +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + var showVersion bool + + cmd := &cobra.Command{ + Use: "kasmos", + Short: "Kasmos agent orchestrator", + RunE: func(cmd *cobra.Command, args []string) error { + if showVersion { + fmt.Fprintln(cmd.OutOrStdout(), "kasmos v0.1.0") + return nil + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + program := tea.NewProgram(tui.NewModel(), tea.WithAltScreen(), tea.WithContext(ctx)) + _, err := program.Run() + return err + }, + } + + cmd.Flags().BoolVar(&showVersion, "version", false, "print version and exit") + + return cmd +} diff --git a/go.mod b/go.mod index faa4927..c31640a 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,37 @@ module github.com/user/kasmos go 1.23.0 require ( - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 + github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2 + github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2 github.com/spf13/cobra v1.8.0 ) -require github.com/charmbracelet/x/input v0.3.7 // indirect +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.1.6 // indirect + github.com/charmbracelet/x/ansi v0.4.3 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 // indirect + github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 // indirect + github.com/muesli/kmeans v0.3.1 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect +) require ( - github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/huh v0.6.0 - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/charmbracelet/x/windows v0.2.1 // indirect + github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2 + github.com/charmbracelet/x/cellbuf v0.0.3 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 + github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/gamut v0.3.1 github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 + golang.org/x/sync v0.8.0 // indirect ) diff --git a/go.sum b/go.sum index e5989fa..3afadea 100644 --- a/go.sum +++ b/go.sum @@ -2,73 +2,41 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= -github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= -github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= -github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= -github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= -github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= -github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/input v0.3.7 h1:UzVbkt1vgM9dBQ+K+uRolBlN6IF2oLchmPKKo/aucXo= -github.com/charmbracelet/x/input v0.3.7/go.mod h1:ZSS9Cia6Cycf2T6ToKIOxeTBTDwl25AGwArJuGaOBH8= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= -github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= +github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2 h1:Oevn3XNNcccbI8m6cOI6rAMsY1niKsDMv55qtejWRXE= +github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2/go.mod h1:BWGE1i9NQA60C720gn2FYOyRyJp2BVtQNVfai7wcMoM= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2 h1:NkQFWhCii9NtL7Q0L/4mNKtZFgrDpfPSVZAzTwEJdGg= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2/go.mod h1:24niqT9RbtXhWg8zLRU/v/xTixlo1+DUsHQZ3+kez5Y= +github.com/charmbracelet/colorprofile v0.1.6 h1:nMMqCns0c0DfCwNGdagBh6SxutFqkltSxxKk5S9kt+Y= +github.com/charmbracelet/colorprofile v0.1.6/go.mod h1:3EMXDxwRDJl0c17eJ1jX99MhtlP9OxE/9Qw0C5lvyUg= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2 h1:Gp+S9hMymU6HmxD1dihbnoMOGwt6wDMMvf0jyw3gEc0= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2/go.mod h1:72/7KVsLdRldv/CeBjZx6igXIZ9CFtBzQUmDEbhXZ3w= +github.com/charmbracelet/x/ansi v0.4.3 h1:wcdDrW0ejaaZGJxCyxVNzzmctqV+oARIudaFGQvsRkA= +github.com/charmbracelet/x/ansi v0.4.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/cellbuf v0.0.3 h1:HapUUjlo0pZ7iGijrTer1f4X8Uvq17l0zR+80Oh+iJg= +github.com/charmbracelet/x/cellbuf v0.0.3/go.mod h1:SF8R3AqchNzYKKJCFT7co8wt1HgQDfAitQ+SBoxWLNc= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 h1:D5OO0lVavz7A+Swdhp62F9gbkibxmz9B2hZ/jVdMPf0= +github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91/go.mod h1:Ey8PFmYwH+/td9bpiEx07Fdx9ZVkxfIjWXxBluxF4Nw= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/clusters v0.0.0-20180605185049-a07a36e67d36/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= @@ -78,19 +46,13 @@ github.com/muesli/gamut v0.3.1 h1:8hozovcrDBWLLAwuOXC+UDyO0/uNroIdXAmY/lQOMHo= github.com/muesli/gamut v0.3.1/go.mod h1:BED0DN21PXU1YaYNwaTmX9700SRHPcWWd6Llj0zsz5k= github.com/muesli/kmeans v0.3.1 h1:KshLQ8wAETfLWOJKMuDCVYHnafddSa1kwGh/IypGIzY= github.com/muesli/kmeans v0.3.1/go.mod h1:8/OvJW7cHc1BpRf8URb43m+vR105DDe+Kj1WcFXYDqc= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I= github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= @@ -98,17 +60,13 @@ github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRT github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..039f73c --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,187 @@ +package tui + +import "github.com/charmbracelet/bubbles/v2/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + NextPanel key.Binding + PrevPanel key.Binding + + Spawn key.Binding + Kill key.Binding + Continue key.Binding + Restart key.Binding + Batch key.Binding + + Fullscreen key.Binding + ScrollDown key.Binding + ScrollUp key.Binding + HalfDown key.Binding + HalfUp key.Binding + GotoBottom key.Binding + GotoTop key.Binding + Search key.Binding + + GenPrompt key.Binding + Analyze key.Binding + + Filter key.Binding + Select key.Binding + + Help key.Binding + Quit key.Binding + ForceQuit key.Binding + Back key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("↓/j", "down"), + ), + NextPanel: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next panel"), + ), + PrevPanel: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("S-tab", "prev panel"), + ), + Spawn: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "spawn worker"), + ), + Kill: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "kill worker"), + ), + Continue: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "continue session"), + ), + Restart: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "restart worker"), + ), + Batch: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "batch spawn"), + ), + Fullscreen: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "fullscreen"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("↓/j", "scroll down"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("↑/k", "scroll up"), + ), + HalfDown: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ), + HalfUp: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ), + GotoBottom: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "bottom"), + ), + GotoTop: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "top"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), + GenPrompt: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "gen prompt (AI)"), + ), + Analyze: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "analyze failure (AI)"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "quit"), + ), + ForceQuit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "force quit"), + ), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + } +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Spawn, k.Kill, k.Continue, k.Restart, + k.GenPrompt, k.Analyze, k.Fullscreen, + k.NextPanel, k.Help, k.Quit, + } +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.NextPanel, k.PrevPanel, k.Select, k.Back}, + {k.Spawn, k.Kill, k.Continue, k.Restart, k.Batch, k.GenPrompt, k.Analyze}, + {k.Fullscreen, k.ScrollDown, k.ScrollUp, k.GotoBottom, k.GotoTop, k.Search}, + {k.Help, k.Quit, k.ForceQuit, k.Filter}, + } +} + +func (m *Model) updateKeyStates() { + m.keys.Spawn.SetEnabled(true) + m.keys.Help.SetEnabled(true) + m.keys.Quit.SetEnabled(true) + m.keys.ForceQuit.SetEnabled(true) + m.keys.NextPanel.SetEnabled(true) + m.keys.PrevPanel.SetEnabled(true) + m.keys.Up.SetEnabled(true) + m.keys.Down.SetEnabled(true) + m.keys.ScrollDown.SetEnabled(true) + m.keys.ScrollUp.SetEnabled(true) + m.keys.Back.SetEnabled(true) + + m.keys.Kill.SetEnabled(false) + m.keys.Continue.SetEnabled(false) + m.keys.Restart.SetEnabled(false) + m.keys.Batch.SetEnabled(false) + m.keys.Fullscreen.SetEnabled(false) + m.keys.HalfDown.SetEnabled(false) + m.keys.HalfUp.SetEnabled(false) + m.keys.GotoBottom.SetEnabled(false) + m.keys.GotoTop.SetEnabled(false) + m.keys.Search.SetEnabled(false) + m.keys.GenPrompt.SetEnabled(false) + m.keys.Analyze.SetEnabled(false) + m.keys.Filter.SetEnabled(false) + m.keys.Select.SetEnabled(false) +} diff --git a/internal/tui/layout.go b/internal/tui/layout.go new file mode 100644 index 0000000..60f389e --- /dev/null +++ b/internal/tui/layout.go @@ -0,0 +1,154 @@ +package tui + +import "github.com/charmbracelet/bubbles/v2/table" + +type layoutMode int + +const ( + layoutTooSmall layoutMode = iota + layoutNarrow + layoutStandard + layoutWide +) + +type panel int + +const ( + panelTable panel = iota + panelViewport + panelTasks +) + +func (m *Model) recalculateLayout() { + if m.width < 80 || m.height < 24 { + m.layoutMode = layoutTooSmall + return + } + + contentHeight := max(0, m.height-m.chromeHeight()) + const ( + borderH = 4 + borderV = 2 + ) + + switch { + case m.width >= 160 && m.hasTaskSource(): + m.layoutMode = layoutWide + + available := max(0, m.width-2) + m.tasksOuterWidth = int(float64(available) * 0.25) + m.tableOuterWidth = int(float64(available) * 0.35) + m.viewportOuterWidth = max(0, available-m.tasksOuterWidth-m.tableOuterWidth) + + m.tasksOuterHeight = contentHeight + m.tableOuterHeight = contentHeight + m.viewportOuterHeight = contentHeight + + case m.width >= 100: + m.layoutMode = layoutStandard + + m.tableOuterWidth = int(float64(m.width) * 0.40) + m.viewportOuterWidth = max(0, m.width-m.tableOuterWidth-1) + m.tableOuterHeight = contentHeight + m.viewportOuterHeight = contentHeight + m.tasksOuterWidth = 0 + m.tasksOuterHeight = 0 + + case m.width >= 80: + m.layoutMode = layoutNarrow + + m.tableOuterWidth = m.width + m.viewportOuterWidth = m.width + m.tableOuterHeight = int(float64(contentHeight) * 0.45) + m.viewportOuterHeight = max(0, contentHeight-m.tableOuterHeight) + m.tasksOuterWidth = 0 + m.tasksOuterHeight = 0 + + default: + m.layoutMode = layoutTooSmall + return + } + + m.tableInnerWidth = max(1, m.tableOuterWidth-borderH) + m.tableInnerHeight = max(1, m.tableOuterHeight-borderV) + m.viewportInnerWidth = max(1, m.viewportOuterWidth-borderH) + m.viewportInnerHeight = max(1, m.viewportOuterHeight-borderV) + m.tasksInnerWidth = max(0, m.tasksOuterWidth-borderH) + m.tasksInnerHeight = max(0, m.tasksOuterHeight-borderV) + + m.table.SetWidth(m.tableInnerWidth) + m.table.SetHeight(max(1, m.tableInnerHeight-1)) + m.table.SetColumns(m.workerTableColumns()) + m.viewport.SetWidth(m.viewportInnerWidth) + m.viewport.SetHeight(max(1, m.viewportInnerHeight-1)) + + panels := m.cyclablePanels() + if len(panels) > 0 { + found := false + for _, p := range panels { + if p == m.focused { + found = true + break + } + } + if !found { + m.focused = panels[0] + } + } +} + +func (m Model) workerTableColumns() []table.Column { + cols := []table.Column{ + {Title: "ID", Width: 10}, + {Title: "Status", Width: 14}, + {Title: "Role", Width: 10}, + {Title: "Duration", Width: 9}, + } + + if m.width >= 100 { + fixed := 0 + for _, c := range cols { + fixed += c.Width + } + remaining := m.tableInnerWidth - fixed - len(cols) + if remaining >= 15 { + cols = append(cols, table.Column{Title: "Task", Width: remaining}) + } + } + + return cols +} + +func (m Model) cyclablePanels() []panel { + panels := []panel{panelTable, panelViewport} + if m.hasTaskSource() && m.layoutMode == layoutWide { + panels = []panel{panelTasks, panelTable, panelViewport} + } + return panels +} + +func (m *Model) cyclePanel(dir int) { + panels := m.cyclablePanels() + if len(panels) == 0 { + return + } + + idx := 0 + for i, p := range panels { + if p == m.focused { + idx = i + break + } + } + + idx = (idx + dir + len(panels)) % len(panels) + m.focused = panels[idx] +} + +func (m Model) chromeHeight() int { + headerLines := 2 + if m.hasTaskSource() { + headerLines = 3 + } + return headerLines + 1 + 1 +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go new file mode 100644 index 0000000..d5c7139 --- /dev/null +++ b/internal/tui/messages.go @@ -0,0 +1,126 @@ +package tui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea/v2" +) + +type workerSpawnedMsg struct { + WorkerID string + PID int +} + +type workerOutputMsg struct { + WorkerID string + Data string +} + +type workerExitedMsg struct { + WorkerID string + ExitCode int + Duration time.Duration + SessionID string + Err error +} + +type workerKilledMsg struct { + WorkerID string + Err error +} + +type tickMsg time.Time + +type focusChangedMsg struct { + From panel + To panel +} + +type layoutChangedMsg struct { + From layoutMode + To layoutMode +} + +type spawnDialogSubmittedMsg struct { + Role string + Prompt string + Files []string + TaskID string +} + +type spawnDialogCancelledMsg struct{} + +type continueDialogSubmittedMsg struct { + ParentWorkerID string + SessionID string + FollowUp string +} + +type continueDialogCancelledMsg struct{} + +type quitConfirmedMsg struct{} + +type quitCancelledMsg struct{} + +type analyzeStartedMsg struct { + WorkerID string +} + +type analyzeCompletedMsg struct { + WorkerID string + RootCause string + SuggestedPrompt string + Err error +} + +type genPromptStartedMsg struct { + TaskID string +} + +type genPromptCompletedMsg struct { + TaskID string + Prompt string + Err error +} + +type Task struct { + ID string + Title string + Description string + SuggestedRole string + Dependencies []string + State TaskState + WorkerID string + Metadata map[string]string +} + +type tasksLoadedMsg struct { + Source string + Path string + Tasks []Task + Err error +} + +type taskStateChangedMsg struct { + TaskID string + NewState TaskState + WorkerID string +} + +type sessionSavedMsg struct { + Path string + Err error +} + +type SessionState struct{} + +type sessionLoadedMsg struct { + Session *SessionState + Err error +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..26a4de5 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,223 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/spinner" + "github.com/charmbracelet/bubbles/v2/table" + "github.com/charmbracelet/bubbles/v2/viewport" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" +) + +type Model struct { + width int + height int + + ready bool + focused panel + layoutMode layoutMode + showHelp bool + + keys keyMap + help help.Model + table table.Model + viewport viewport.Model + spinner spinner.Model + + statusBar string + + tableInnerWidth int + tableInnerHeight int + tableOuterWidth int + tableOuterHeight int + viewportInnerWidth int + viewportInnerHeight int + viewportOuterWidth int + viewportOuterHeight int + tasksInnerWidth int + tasksInnerHeight int + tasksOuterWidth int + tasksOuterHeight int + + taskSourceType string + taskSourcePath string +} + +func NewModel() Model { + t := table.New( + table.WithColumns([]table.Column{ + {Title: "ID", Width: 10}, + {Title: "Status", Width: 14}, + {Title: "Role", Width: 10}, + {Title: "Duration", Width: 9}, + }), + table.WithRows([]table.Row{}), + table.WithHeight(1), + table.WithFocused(true), + ) + t.SetStyles(workerTableStyles()) + + vp := viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)) + vp.SetContent(welcomeViewportText()) + + m := Model{ + focused: panelTable, + layoutMode: layoutTooSmall, + keys: defaultKeyMap(), + help: styledHelp(), + table: t, + viewport: vp, + spinner: styledSpinner(), + } + m.updateKeyStates() + return m +} + +func (m Model) Init() (tea.Model, tea.Cmd) { + return m, tea.Batch(tickCmd(), m.spinner.Tick) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ready = true + + prev := m.layoutMode + m.recalculateLayout() + if prev != m.layoutMode { + cmds = append(cmds, func() tea.Msg { + return layoutChangedMsg{From: prev, To: m.layoutMode} + }) + } + + return m, tea.Batch(cmds...) + + case tea.KeyMsg: + if key.Matches(msg, m.keys.ForceQuit, m.keys.Quit) { + return m, tea.Quit + } + + if key.Matches(msg, m.keys.Help) { + m.showHelp = !m.showHelp + return m, nil + } + + if m.showHelp { + if key.Matches(msg, m.keys.Back) { + m.showHelp = false + } + return m, nil + } + + if m.layoutMode == layoutTooSmall { + return m, nil + } + + switch { + case key.Matches(msg, m.keys.NextPanel): + m.cyclePanel(1) + m.updateKeyStates() + return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } + case key.Matches(msg, m.keys.PrevPanel): + m.cyclePanel(-1) + m.updateKeyStates() + return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } + } + + var cmd tea.Cmd + switch m.focused { + case panelTable: + m.table, cmd = m.table.Update(msg) + case panelViewport: + m.viewport, cmd = m.viewport.Update(msg) + } + return m, cmd + + case tickMsg: + return m, tickCmd() + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if !m.ready { + return "" + } + + if m.layoutMode == layoutTooSmall { + warn := lipgloss.NewStyle().Foreground(colorOrange).Bold(true).Render("Terminal too small") + meta := lipgloss.NewStyle().Foreground(colorMidGray).Render("Minimum: 80x24") + curr := lipgloss.NewStyle().Foreground(colorLightGray).Render(fmt.Sprintf("Current: %dx%d", m.width, m.height)) + body := lipgloss.JoinVertical(lipgloss.Center, warn, meta, curr) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, body) + } + + var content string + switch m.layoutMode { + case layoutNarrow: + content = lipgloss.JoinVertical(lipgloss.Left, m.renderWorkerTable(), m.renderViewport()) + case layoutWide: + if m.hasTaskSource() { + content = lipgloss.JoinHorizontal(lipgloss.Top, m.renderTasksPanel(), " ", m.renderWorkerTable(), " ", m.renderViewport()) + } else { + content = lipgloss.JoinHorizontal(lipgloss.Top, m.renderWorkerTable(), " ", m.renderViewport()) + } + default: + content = lipgloss.JoinHorizontal(lipgloss.Top, m.renderWorkerTable(), " ", m.renderViewport()) + } + + view := lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + content, + m.renderStatusBar(), + m.renderHelpBar(), + ) + + if m.showHelp { + return m.renderHelpOverlay() + } + + return view +} + +func (m Model) hasTaskSource() bool { + return m.taskSourceType != "" +} + +func (m Model) modeName() string { + if m.hasTaskSource() { + return m.taskSourceType + } + return "ad-hoc" +} + +func welcomeViewportText() string { + setup := filePathStyle.Render("kasmos setup") + lines := []string{ + "", + " 🫧 Welcome to kasmos!", + "", + " Spawn your first worker to get started.", + " Select a worker to view its output here.", + "", + " Tip: Run " + setup + " to scaffold", + " agent configurations if you haven't yet.", + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go new file mode 100644 index 0000000..dda82d6 --- /dev/null +++ b/internal/tui/panels.go @@ -0,0 +1,119 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss/v2" +) + +const appVersion = "v0.1.0" + +func (m Model) renderHeader() string { + title := " " + renderGradientTitle("kasmos") + " " + dimSubtitleStyle.Render("agent orchestrator") + version := versionStyle.Render(appVersion) + gap := strings.Repeat(" ", max(1, m.width-lipgloss.Width(title)-lipgloss.Width(version))) + line := title + gap + version + if !m.hasTaskSource() { + return lipgloss.JoinVertical(lipgloss.Left, line, "") + } + + subtitle := sourceSubtitleStyle.Render(fmt.Sprintf("%s: %s", m.taskSourceType, m.taskSourcePath)) + return lipgloss.JoinVertical(lipgloss.Left, line, subtitle) +} + +func (m Model) renderWorkerTable() string { + if m.tableInnerWidth <= 0 || m.tableInnerHeight <= 0 { + return "" + } + + body := m.table.View() + if len(m.table.Rows()) == 0 { + empty := lipgloss.NewStyle().Foreground(colorMidGray).Render("No workers yet") + + "\n\n" + + lipgloss.NewStyle().Foreground(colorLightGray).Render("Press s to spawn your first worker") + body = lipgloss.Place(m.tableInnerWidth, max(1, m.tableInnerHeight-1), lipgloss.Center, lipgloss.Center, empty) + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Workers"), + body, + ) + + return panelStyle(m.focused == panelTable). + Width(m.tableInnerWidth). + Height(m.tableInnerHeight). + Render(content) +} + +func (m Model) renderViewport() string { + if m.viewportInnerWidth <= 0 || m.viewportInnerHeight <= 0 { + return "" + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Output"), + m.viewport.View(), + ) + + return panelStyle(m.focused == panelViewport). + Width(m.viewportInnerWidth). + Height(m.viewportInnerHeight). + Render(content) +} + +func (m Model) renderStatusBar() string { + left := " 0 workers" + scrollStr := "-" + if m.focused == panelViewport && m.viewport.TotalLineCount() > 0 { + scrollStr = fmt.Sprintf("%.0f%%", m.viewport.ScrollPercent()*100) + } + right := fmt.Sprintf("mode: %s scroll: %s ", m.modeName(), scrollStr) + gap := strings.Repeat(" ", max(0, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2)) + m.statusBar = left + gap + right + return statusBarStyle.Width(m.width).Render(m.statusBar) +} + +func (m Model) renderHelpBar() string { + h := m.help + h.Width = m.width + h.ShowAll = false + return h.View(m.keys) +} + +func (m Model) renderTasksPanel() string { + if m.tasksInnerWidth <= 0 || m.tasksInnerHeight <= 0 { + return "" + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Tasks"), + lipgloss.NewStyle().Foreground(colorMidGray).Render("No tasks loaded"), + ) + + return panelStyle(m.focused == panelTasks). + Width(m.tasksInnerWidth). + Height(m.tasksInnerHeight). + Render(content) +} + +func (m Model) renderHelpOverlay() string { + h := m.help + h.ShowAll = true + h.Width = min(74, max(30, m.width-10)) + + overlay := lipgloss.JoinVertical( + lipgloss.Left, + dialogHeaderStyle.Render("Keyboard Shortcuts"), + "", + h.View(m.keys), + ) + + dialogWidth := min(78, max(36, m.width-6)) + dialog := dialogStyle.Width(dialogWidth).Render(overlay) + + return m.renderWithBackdrop(dialog) +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..307fc60 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,333 @@ +package tui + +import ( + "fmt" + "image/color" + "strings" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/spinner" + "github.com/charmbracelet/bubbles/v2/table" + "github.com/charmbracelet/bubbles/v2/textarea" + "github.com/charmbracelet/bubbles/v2/textinput" + "github.com/charmbracelet/lipgloss/v2" + "github.com/lucasb-eyer/go-colorful" + "github.com/muesli/gamut" + + "github.com/user/kasmos/internal/worker" +) + +var ( + colorPurple = lipgloss.Color("#7D56F4") + colorHotPink = lipgloss.Color("#F25D94") + colorGreen = lipgloss.Color("#73F59F") + colorLightBlue = lipgloss.Color("#82CFFF") + colorYellow = lipgloss.Color("#EDFF82") + colorOrange = lipgloss.Color("#FF9F43") + colorCream = lipgloss.Color("#FFFDF5") + colorWhite = lipgloss.Color("#FAFAFA") + colorDarkGray = lipgloss.Color("#383838") + colorMidGray = lipgloss.Color("#5C5C5C") + colorLightGray = lipgloss.Color("#9B9B9B") +) + +var ( + subtleColor color.Color = lipgloss.Color("#383838") + highlightColor color.Color = lipgloss.Color("#7D56F4") + specialColor color.Color = lipgloss.Color("#73F59F") +) + +var ( + colorRunning = colorPurple + colorDone = colorGreen + colorFailed = colorOrange + colorKilled = colorHotPink + colorPending = colorMidGray + colorWarning = colorYellow +) + +var ( + colorFocusBorder = colorPurple + colorUnfocusBorder = colorDarkGray + colorDialogBorder = colorHotPink + colorAlertBorder = colorOrange + colorHeader = colorHotPink + colorHelp = colorMidGray + colorAccent = colorLightBlue + colorTimestamp = colorLightGray +) + +var roleBadgeColors = map[string]struct{ bg, fg color.Color }{ + "planner": {bg: lipgloss.Color("#2D6A4F"), fg: colorCream}, + "coder": {bg: colorPurple, fg: colorCream}, + "reviewer": {bg: colorLightBlue, fg: lipgloss.Color("#0a0a18")}, + "release": {bg: lipgloss.Color("#8B5CF6"), fg: colorCream}, +} + +var ( + focusedPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorFocusBorder). + Padding(0, 1) + + unfocusedPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorUnfocusBorder). + Padding(0, 1) +) + +func panelStyle(focused bool) lipgloss.Style { + if focused { + return focusedPanelStyle + } + return unfocusedPanelStyle +} + +var ( + titleBaseStyle = lipgloss.NewStyle().Bold(true) + + dimSubtitleStyle = lipgloss.NewStyle(). + Foreground(colorMidGray) + + versionStyle = lipgloss.NewStyle(). + Foreground(colorLightGray) + + sourceSubtitleStyle = lipgloss.NewStyle(). + Foreground(colorLightGray). + MarginLeft(2) +) + +func renderGradientTitle(text string) string { + if text == "" { + return "" + } + + start, ok := colorful.MakeColor(colorHotPink) + if !ok { + return titleBaseStyle.Render(text) + } + + end, ok := colorful.MakeColor(colorPurple) + if !ok { + return titleBaseStyle.Render(text) + } + + colors := gamut.Blends(start, end, len([]rune(text))) + + var out strings.Builder + for i, ch := range text { + hex := gamut.ToHex(colors[i]) + out.WriteString(titleBaseStyle.Foreground(lipgloss.Color(hex)).Render(string(ch))) + } + return out.String() +} + +func workerTableStyles() table.Styles { + s := table.DefaultStyles() + + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colorPurple). + BorderBottom(true). + Bold(true). + Foreground(colorHotPink) + + s.Selected = s.Selected. + Foreground(colorCream). + Background(colorPurple). + Bold(false) + + s.Cell = s.Cell. + Padding(0, 1) + + return s +} + +var statusBarStyle = lipgloss.NewStyle(). + Foreground(colorCream). + Background(colorPurple). + Padding(0, 1). + Bold(false) + +func styledHelp() help.Model { + h := help.New() + h.ShowAll = false + + h.Styles.ShortKey = lipgloss.NewStyle(). + Foreground(colorPurple). + Bold(true) + + h.Styles.ShortDesc = lipgloss.NewStyle(). + Foreground(colorMidGray) + + h.Styles.ShortSeparator = lipgloss.NewStyle(). + Foreground(colorDarkGray) + + h.Styles.FullKey = lipgloss.NewStyle(). + Foreground(colorPurple). + Bold(true) + + h.Styles.FullDesc = lipgloss.NewStyle(). + Foreground(colorLightGray) + + h.Styles.FullSeparator = lipgloss.NewStyle(). + Foreground(colorDarkGray) + + return h +} + +var ( + dialogStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorDialogBorder). + Padding(1, 2) + + alertDialogStyle = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderForeground(colorAlertBorder). + Padding(1, 2) + + dialogHeaderStyle = lipgloss.NewStyle(). + Foreground(colorHotPink). + Bold(true) + + activeButtonStyle = lipgloss.NewStyle(). + Foreground(colorCream). + Background(colorPurple). + Padding(0, 2). + Bold(true) + + inactiveButtonStyle = lipgloss.NewStyle(). + Foreground(colorLightGray). + Background(colorDarkGray). + Padding(0, 2) + + alertButtonStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#0a0a18")). + Background(colorOrange). + Padding(0, 2). + Bold(true) +) + +func styledSpinner() spinner.Model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(colorPurple) + return s +} + +func styledTextInput() textinput.Model { + ti := textinput.New() + ti.PromptStyle = lipgloss.NewStyle().Foreground(colorPurple) + ti.TextStyle = lipgloss.NewStyle().Foreground(colorCream) + ti.Cursor.Style = lipgloss.NewStyle().Foreground(colorHotPink) + ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(colorMidGray) + return ti +} + +func styledTextArea() textarea.Model { + ta := textarea.New() + ta.Styles.Focused.CursorLine = lipgloss.NewStyle(). + Background(colorDarkGray) + ta.Styles.Focused.Base = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPurple) + ta.Styles.Blurred.Base = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorDarkGray) + return ta +} + +func statusIndicator(state worker.WorkerState, exitCode int) string { + switch state { + case worker.StateRunning: + return lipgloss.NewStyle().Foreground(colorRunning).Render("⟳ running") + case worker.StateExited: + return lipgloss.NewStyle().Foreground(colorDone).Render("✓ done") + case worker.StateFailed: + return lipgloss.NewStyle().Foreground(colorFailed).Render(fmt.Sprintf("✗ failed(%d)", exitCode)) + case worker.StateKilled: + return lipgloss.NewStyle().Foreground(colorKilled).Render("☠ killed") + case worker.StatePending: + return lipgloss.NewStyle().Foreground(colorPending).Render("○ pending") + case worker.StateSpawning: + return lipgloss.NewStyle().Foreground(colorPurple).Render("◌ spawning") + default: + return lipgloss.NewStyle().Foreground(colorMidGray).Render("? unknown") + } +} + +type TaskState int + +const ( + TaskUnassigned TaskState = iota + TaskBlocked + TaskInProgress + TaskDone + TaskFailed +) + +func taskStatusBadge(state TaskState, blockingDep string) string { + switch state { + case TaskDone: + return lipgloss.NewStyle().Foreground(colorDone).Render("✓ done") + case TaskInProgress: + return lipgloss.NewStyle().Foreground(colorRunning).Render("⟳ in-progress") + case TaskBlocked: + return lipgloss.NewStyle().Foreground(colorOrange).Render(fmt.Sprintf("⊘ blocked (%s)", blockingDep)) + case TaskFailed: + return lipgloss.NewStyle().Foreground(colorFailed).Render("✗ failed") + default: + return lipgloss.NewStyle().Foreground(colorPending).Render("○ unassigned") + } +} + +func roleBadge(role string) string { + colors, ok := roleBadgeColors[role] + if !ok { + colors = struct{ bg, fg color.Color }{ + bg: colorDarkGray, + fg: colorCream, + } + } + + return lipgloss.NewStyle(). + Foreground(colors.fg). + Background(colors.bg). + Padding(0, 1). + Render(role) +} + +var ( + timestampStyle = lipgloss.NewStyle().Foreground(colorTimestamp) + filePathStyle = lipgloss.NewStyle().Foreground(colorLightBlue) + successStyle = lipgloss.NewStyle().Foreground(colorGreen) + failStyle = lipgloss.NewStyle().Foreground(colorOrange) + warningStyle = lipgloss.NewStyle().Foreground(colorYellow) + agentTagStyle = lipgloss.NewStyle().Foreground(colorPurple) + + analysisHeaderStyle = lipgloss.NewStyle(). + Foreground(colorHotPink). + Bold(true) + + rootCauseLabelStyle = lipgloss.NewStyle(). + Foreground(colorOrange). + Bold(true) + + suggestedFixLabelStyle = lipgloss.NewStyle(). + Foreground(colorGreen). + Bold(true) + + analysisHintStyle = lipgloss.NewStyle(). + Foreground(colorMidGray). + Faint(true) +) + +func (m Model) renderWithBackdrop(dialog string) string { + return lipgloss.Place(m.width, m.height, + lipgloss.Center, lipgloss.Center, + dialog, + lipgloss.WithWhitespaceChars("░"), + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(colorDarkGray)), + ) +} From 2981d223069a7bb1338f205f6992ec54b645e32b Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 07:30:55 -0600 Subject: [PATCH 07/26] feat(016): WP04 worker-TUI integration - spawn, output, lifecycle commands.go: spawnWorkerCmd, readWorkerOutput (goroutine + Send), waitWorkerCmd, killWorkerCmd tea.Cmd constructors. overlays.go: spawn dialog with role selector, prompt textarea, files input. Centered with hot pink border + backdrop. update.go: full lifecycle dispatch - spawn dialog submit, workerSpawned/Output/Exited handlers, table selection sync, viewport auto-follow, spinner and tick refresh. panels.go: table rows from live workers with spinner status, viewport shows selected worker output, real status bar counts. main.go: SubprocessBackend init, NewModel(backend), SetProgram. --- cmd/kasmos/main.go | 13 +- internal/tui/commands.go | 73 ++++++++++ internal/tui/messages.go | 3 + internal/tui/model.go | 107 ++++---------- internal/tui/overlays.go | 218 ++++++++++++++++++++++++++++ internal/tui/panels.go | 60 ++++++-- internal/tui/update.go | 306 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 690 insertions(+), 90 deletions(-) create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/overlays.go create mode 100644 internal/tui/update.go diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index 786ee13..372d9bf 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/user/kasmos/internal/tui" + "github.com/user/kasmos/internal/worker" ) func main() { @@ -32,11 +33,19 @@ func newRootCmd() *cobra.Command { return nil } + backend, err := worker.NewSubprocessBackend() + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - program := tea.NewProgram(tui.NewModel(), tea.WithAltScreen(), tea.WithContext(ctx)) - _, err := program.Run() + model := tui.NewModel(backend) + program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx)) + model.SetProgram(program) + + _, err = program.Run() return err }, } diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..a29f359 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,73 @@ +package tui + +import ( + "context" + "errors" + "io" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/user/kasmos/internal/worker" +) + +func spawnWorkerCmd(backend worker.WorkerBackend, cfg worker.SpawnConfig) tea.Cmd { + return func() tea.Msg { + if backend == nil { + return workerExitedMsg{WorkerID: cfg.ID, Err: errors.New("worker backend is not configured")} + } + + handle, err := backend.Spawn(context.Background(), cfg) + if err != nil { + return workerExitedMsg{WorkerID: cfg.ID, Err: err} + } + + return workerSpawnedMsg{WorkerID: cfg.ID, PID: handle.PID(), Handle: handle} + } +} + +func readWorkerOutput(workerID string, reader io.Reader, program *tea.Program) { + if workerID == "" || reader == nil || program == nil { + return + } + + go func() { + buf := make([]byte, 4096) + for { + n, err := reader.Read(buf) + if n > 0 { + program.Send(workerOutputMsg{WorkerID: workerID, Data: string(buf[:n])}) + } + if err != nil { + return + } + } + }() +} + +func waitWorkerCmd(workerID string, handle worker.WorkerHandle) tea.Cmd { + return func() tea.Msg { + if handle == nil { + return workerExitedMsg{WorkerID: workerID, Err: errors.New("worker handle is nil")} + } + + result := handle.Wait() + return workerExitedMsg{ + WorkerID: workerID, + ExitCode: result.Code, + Duration: result.Duration, + SessionID: result.SessionID, + Err: result.Error, + } + } +} + +func killWorkerCmd(workerID string, handle worker.WorkerHandle, grace time.Duration) tea.Cmd { + return func() tea.Msg { + if handle == nil { + return workerKilledMsg{WorkerID: workerID, Err: errors.New("worker handle is nil")} + } + + return workerKilledMsg{WorkerID: workerID, Err: handle.Kill(grace)} + } +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go index d5c7139..2b44ba0 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -4,11 +4,14 @@ import ( "time" tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/user/kasmos/internal/worker" ) type workerSpawnedMsg struct { WorkerID string PID int + Handle worker.WorkerHandle } type workerOutputMsg struct { diff --git a/internal/tui/model.go b/internal/tui/model.go index 26a4de5..4d30898 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5,12 +5,13 @@ import ( "strings" "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" "github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/table" "github.com/charmbracelet/bubbles/v2/viewport" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + + "github.com/user/kasmos/internal/worker" ) type Model struct { @@ -27,9 +28,19 @@ type Model struct { table table.Model viewport viewport.Model spinner spinner.Model + backend worker.WorkerBackend + manager *worker.WorkerManager + workers []*worker.Worker + program *tea.Program statusBar string + showSpawnDialog bool + spawnForm *spawnDialogModel + spawnDraft spawnDialogDraft + + selectedWorkerID string + tableInnerWidth int tableInnerHeight int tableOuterWidth int @@ -47,7 +58,7 @@ type Model struct { taskSourcePath string } -func NewModel() Model { +func NewModel(backend worker.WorkerBackend) *Model { t := table.New( table.WithColumns([]table.Column{ {Title: "ID", Width: 10}, @@ -64,7 +75,7 @@ func NewModel() Model { vp := viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)) vp.SetContent(welcomeViewportText()) - m := Model{ + m := &Model{ focused: panelTable, layoutMode: layoutTooSmall, keys: defaultKeyMap(), @@ -72,89 +83,23 @@ func NewModel() Model { table: t, viewport: vp, spinner: styledSpinner(), + backend: backend, + manager: worker.NewWorkerManager(), + workers: make([]*worker.Worker, 0), } m.updateKeyStates() return m } -func (m Model) Init() (tea.Model, tea.Cmd) { - return m, tea.Batch(tickCmd(), m.spinner.Tick) +func (m *Model) SetProgram(program *tea.Program) { + m.program = program } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.ready = true - - prev := m.layoutMode - m.recalculateLayout() - if prev != m.layoutMode { - cmds = append(cmds, func() tea.Msg { - return layoutChangedMsg{From: prev, To: m.layoutMode} - }) - } - - return m, tea.Batch(cmds...) - - case tea.KeyMsg: - if key.Matches(msg, m.keys.ForceQuit, m.keys.Quit) { - return m, tea.Quit - } - - if key.Matches(msg, m.keys.Help) { - m.showHelp = !m.showHelp - return m, nil - } - - if m.showHelp { - if key.Matches(msg, m.keys.Back) { - m.showHelp = false - } - return m, nil - } - - if m.layoutMode == layoutTooSmall { - return m, nil - } - - switch { - case key.Matches(msg, m.keys.NextPanel): - m.cyclePanel(1) - m.updateKeyStates() - return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } - case key.Matches(msg, m.keys.PrevPanel): - m.cyclePanel(-1) - m.updateKeyStates() - return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } - } - - var cmd tea.Cmd - switch m.focused { - case panelTable: - m.table, cmd = m.table.Update(msg) - case panelViewport: - m.viewport, cmd = m.viewport.Update(msg) - } - return m, cmd - - case tickMsg: - return m, tickCmd() - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - } - - return m, tea.Batch(cmds...) +func (m *Model) Init() (tea.Model, tea.Cmd) { + return m, tea.Batch(tickCmd(), m.spinner.Tick) } -func (m Model) View() string { +func (m *Model) View() string { if !m.ready { return "" } @@ -193,14 +138,18 @@ func (m Model) View() string { return m.renderHelpOverlay() } + if m.showSpawnDialog { + return m.renderSpawnDialog() + } + return view } -func (m Model) hasTaskSource() bool { +func (m *Model) hasTaskSource() bool { return m.taskSourceType != "" } -func (m Model) modeName() string { +func (m *Model) modeName() string { if m.hasTaskSource() { return m.taskSourceType } diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go new file mode 100644 index 0000000..c782403 --- /dev/null +++ b/internal/tui/overlays.go @@ -0,0 +1,218 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textarea" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" +) + +type spawnDialogDraft struct { + Role string + Prompt string + Files string +} + +type spawnDialogModel struct { + roles []spawnRoleOption + roleIndex int + prompt textarea.Model + files textinput.Model + focusedIdx int +} + +type spawnRoleOption struct { + role string + description string +} + +const ( + spawnFocusRole = iota + spawnFocusPrompt + spawnFocusFiles +) + +func (m *Model) openSpawnDialog() tea.Cmd { + m.showSpawnDialog = true + m.spawnDraft = spawnDialogDraft{Role: "coder"} + m.spawnForm = newSpawnDialogModel() + return m.spawnForm.focusCurrentField() +} + +func newSpawnDialogModel() *spawnDialogModel { + prompt := styledTextArea() + prompt.Placeholder = "Describe the task for this worker" + prompt.SetWidth(58) + prompt.SetHeight(6) + + files := styledTextInput() + files.Placeholder = "path/to/file.go, another/file.go" + files.SetWidth(58) + + form := &spawnDialogModel{ + roles: []spawnRoleOption{ + {role: "planner", description: "Research and planning, read-only filesystem"}, + {role: "coder", description: "Implementation, full tool access"}, + {role: "reviewer", description: "Code review, read-only + test execution"}, + {role: "release", description: "Merge, finalization, cleanup operations"}, + }, + roleIndex: 1, + prompt: prompt, + files: files, + focusedIdx: spawnFocusRole, + } + + form.prompt.Blur() + form.files.Blur() + return form +} + +func (f *spawnDialogModel) focusCurrentField() tea.Cmd { + f.prompt.Blur() + f.files.Blur() + + switch f.focusedIdx { + case spawnFocusPrompt: + return f.prompt.Focus() + case spawnFocusFiles: + return f.files.Focus() + default: + return nil + } +} + +func (f *spawnDialogModel) cycleFocus(delta int) tea.Cmd { + f.focusedIdx = (f.focusedIdx + delta + 3) % 3 + return f.focusCurrentField() +} + +func (m *Model) closeSpawnDialog() { + m.showSpawnDialog = false + m.spawnForm = nil + m.spawnDraft = spawnDialogDraft{} +} + +func (m *Model) updateSpawnDialog(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if key.Matches(keyMsg, m.keys.Back) { + m.closeSpawnDialog() + return m, func() tea.Msg { return spawnDialogCancelledMsg{} } + } + if m.spawnForm == nil { + m.closeSpawnDialog() + return m, nil + } + + switch { + case keyMsg.String() == "tab": + return m, m.spawnForm.cycleFocus(1) + case keyMsg.String() == "shift+tab": + return m, m.spawnForm.cycleFocus(-1) + case keyMsg.String() == "up" && m.spawnForm.focusedIdx == spawnFocusRole: + if m.spawnForm.roleIndex > 0 { + m.spawnForm.roleIndex-- + } + return m, nil + case keyMsg.String() == "down" && m.spawnForm.focusedIdx == spawnFocusRole: + if m.spawnForm.roleIndex < len(m.spawnForm.roles)-1 { + m.spawnForm.roleIndex++ + } + return m, nil + case keyMsg.String() == "enter" && m.spawnForm.focusedIdx == spawnFocusFiles: + m.spawnDraft = spawnDialogDraft{ + Role: m.spawnForm.roles[m.spawnForm.roleIndex].role, + Prompt: strings.TrimSpace(m.spawnForm.prompt.Value()), + Files: strings.TrimSpace(m.spawnForm.files.Value()), + } + submitted := spawnDialogSubmittedMsg{ + Role: m.spawnDraft.Role, + Prompt: m.spawnDraft.Prompt, + Files: parseSpawnFiles(m.spawnDraft.Files), + } + m.closeSpawnDialog() + return m, func() tea.Msg { return submitted } + } + } + + if m.spawnForm == nil { + m.closeSpawnDialog() + return m, nil + } + + var cmd tea.Cmd + switch m.spawnForm.focusedIdx { + case spawnFocusPrompt: + m.spawnForm.prompt, cmd = m.spawnForm.prompt.Update(msg) + case spawnFocusFiles: + m.spawnForm.files, cmd = m.spawnForm.files.Update(msg) + default: + cmd = nil + } + + return m, cmd +} + +func parseSpawnFiles(input string) []string { + if strings.TrimSpace(input) == "" { + return nil + } + + parts := strings.Split(input, ",") + files := make([]string, 0, len(parts)) + for _, part := range parts { + file := strings.TrimSpace(part) + if file == "" { + continue + } + files = append(files, file) + } + + return files +} + +func (m *Model) renderSpawnDialog() string { + if m.spawnForm == nil { + return m.renderWithBackdrop("") + } + + roleLines := make([]string, 0, len(m.spawnForm.roles)) + for i, opt := range m.spawnForm.roles { + marker := "○" + if i == m.spawnForm.roleIndex { + marker = "●" + } + line := fmt.Sprintf(" %s %-8s %s", marker, opt.role, opt.description) + if i == m.spawnForm.roleIndex { + line = lipgloss.NewStyle().Foreground(colorPurple).Render(line) + } else { + line = lipgloss.NewStyle().Foreground(colorLightGray).Render(line) + } + roleLines = append(roleLines, line) + } + + roleBox := strings.Join(roleLines, "\n") + helpText := lipgloss.NewStyle().Foreground(colorMidGray).Render("tab/S-tab field up/down role enter on files to spawn esc cancel") + + content := lipgloss.JoinVertical( + lipgloss.Left, + dialogHeaderStyle.Render("Spawn Worker"), + "", + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Agent Role"), + roleBox, + "", + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Prompt"), + m.spawnForm.prompt.View(), + "", + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Attach Files (optional)"), + m.spawnForm.files.View(), + "", + helpText, + ) + + dialog := dialogStyle.Width(70).Render(content) + return m.renderWithBackdrop(dialog) +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go index dda82d6..6f053f7 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -5,11 +5,13 @@ import ( "strings" "github.com/charmbracelet/lipgloss/v2" + + "github.com/user/kasmos/internal/worker" ) const appVersion = "v0.1.0" -func (m Model) renderHeader() string { +func (m *Model) renderHeader() string { title := " " + renderGradientTitle("kasmos") + " " + dimSubtitleStyle.Render("agent orchestrator") version := versionStyle.Render(appVersion) gap := strings.Repeat(" ", max(1, m.width-lipgloss.Width(title)-lipgloss.Width(version))) @@ -22,7 +24,7 @@ func (m Model) renderHeader() string { return lipgloss.JoinVertical(lipgloss.Left, line, subtitle) } -func (m Model) renderWorkerTable() string { +func (m *Model) renderWorkerTable() string { if m.tableInnerWidth <= 0 || m.tableInnerHeight <= 0 { return "" } @@ -47,14 +49,19 @@ func (m Model) renderWorkerTable() string { Render(content) } -func (m Model) renderViewport() string { +func (m *Model) renderViewport() string { if m.viewportInnerWidth <= 0 || m.viewportInnerHeight <= 0 { return "" } + title := "Output" + if selected := m.selectedWorker(); selected != nil { + title = fmt.Sprintf("Output: %s %s", selected.ID, selected.Role) + } + content := lipgloss.JoinVertical( lipgloss.Left, - lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Output"), + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render(title), m.viewport.View(), ) @@ -64,8 +71,16 @@ func (m Model) renderViewport() string { Render(content) } -func (m Model) renderStatusBar() string { - left := " 0 workers" +func (m *Model) renderStatusBar() string { + counts := m.workerCounts() + left := fmt.Sprintf(" %s %d running %s %d done %s %d failed %s %d killed %s %d pending", + m.spinner.View(), counts.running, + successStyle.Render("✓"), counts.done, + failStyle.Render("✗"), counts.failed, + warningStyle.Render("☠"), counts.killed, + lipgloss.NewStyle().Foreground(colorPending).Render("○"), counts.pending, + ) + scrollStr := "-" if m.focused == panelViewport && m.viewport.TotalLineCount() > 0 { scrollStr = fmt.Sprintf("%.0f%%", m.viewport.ScrollPercent()*100) @@ -76,14 +91,14 @@ func (m Model) renderStatusBar() string { return statusBarStyle.Width(m.width).Render(m.statusBar) } -func (m Model) renderHelpBar() string { +func (m *Model) renderHelpBar() string { h := m.help h.Width = m.width h.ShowAll = false return h.View(m.keys) } -func (m Model) renderTasksPanel() string { +func (m *Model) renderTasksPanel() string { if m.tasksInnerWidth <= 0 || m.tasksInnerHeight <= 0 { return "" } @@ -100,7 +115,7 @@ func (m Model) renderTasksPanel() string { Render(content) } -func (m Model) renderHelpOverlay() string { +func (m *Model) renderHelpOverlay() string { h := m.help h.ShowAll = true h.Width = min(74, max(30, m.width-10)) @@ -117,3 +132,30 @@ func (m Model) renderHelpOverlay() string { return m.renderWithBackdrop(dialog) } + +type workerStateCounts struct { + running int + done int + failed int + killed int + pending int +} + +func (m *Model) workerCounts() workerStateCounts { + counts := workerStateCounts{} + for _, w := range m.workers { + switch w.State { + case worker.StateRunning: + counts.running++ + case worker.StateExited: + counts.done++ + case worker.StateFailed: + counts.failed++ + case worker.StateKilled: + counts.killed++ + case worker.StatePending, worker.StateSpawning: + counts.pending++ + } + } + return counts +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..e7a1c49 --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,306 @@ +package tui + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/spinner" + "github.com/charmbracelet/bubbles/v2/table" + tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/user/kasmos/internal/worker" +) + +var ( + sessionTextPattern = regexp.MustCompile(`session:\s+(ses_[a-zA-Z0-9]+)`) + sessionJSONPattern = regexp.MustCompile(`"session_id"\s*:\s*"(ses_[a-zA-Z0-9]+)"`) +) + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.showSpawnDialog { + return m.updateSpawnDialog(msg) + } + + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ready = true + + prev := m.layoutMode + m.recalculateLayout() + m.refreshTableRows() + m.refreshViewportFromSelected(false) + if prev != m.layoutMode { + cmds = append(cmds, func() tea.Msg { + return layoutChangedMsg{From: prev, To: m.layoutMode} + }) + } + + return m, tea.Batch(cmds...) + + case tea.KeyMsg: + if key.Matches(msg, m.keys.ForceQuit, m.keys.Quit) { + return m, tea.Quit + } + + if key.Matches(msg, m.keys.Help) { + m.showHelp = !m.showHelp + return m, nil + } + + if m.showHelp { + if key.Matches(msg, m.keys.Back) { + m.showHelp = false + } + return m, nil + } + + if m.layoutMode == layoutTooSmall { + return m, nil + } + + switch { + case key.Matches(msg, m.keys.NextPanel): + m.cyclePanel(1) + m.updateKeyStates() + return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } + case key.Matches(msg, m.keys.PrevPanel): + m.cyclePanel(-1) + m.updateKeyStates() + return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } + } + + if m.focused == panelTable { + if key.Matches(msg, m.keys.Spawn) { + return m, m.openSpawnDialog() + } + + if key.Matches(msg, m.keys.Up, m.keys.Down) { + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + m.syncSelectionFromTable() + m.refreshViewportFromSelected(false) + return m, cmd + } + } + + var cmd tea.Cmd + switch m.focused { + case panelTable: + m.table, cmd = m.table.Update(msg) + m.syncSelectionFromTable() + m.refreshViewportFromSelected(false) + case panelViewport: + m.viewport, cmd = m.viewport.Update(msg) + } + return m, cmd + + case spawnDialogSubmittedMsg: + role := strings.TrimSpace(msg.Role) + prompt := strings.TrimSpace(msg.Prompt) + if role == "" { + role = "coder" + } + + id := m.manager.NextWorkerID() + w := &worker.Worker{ + ID: id, + Role: role, + Prompt: prompt, + Files: msg.Files, + TaskID: msg.TaskID, + State: worker.StateSpawning, + SpawnedAt: time.Now(), + Output: worker.NewOutputBuffer(worker.DefaultMaxLines), + } + m.manager.Add(w) + m.workers = m.manager.All() + if m.selectedWorkerID == "" { + m.selectedWorkerID = w.ID + } + m.refreshTableRows() + m.refreshViewportFromSelected(true) + + cfg := worker.SpawnConfig{ID: w.ID, Role: w.Role, Prompt: w.Prompt, Files: w.Files} + return m, spawnWorkerCmd(m.backend, cfg) + + case spawnDialogCancelledMsg: + m.closeSpawnDialog() + return m, nil + + case workerSpawnedMsg: + w := m.manager.Get(msg.WorkerID) + if w == nil { + return m, nil + } + if err := w.Transition(worker.StateRunning); err != nil { + w.State = worker.StateRunning + } + w.Handle = msg.Handle + if w.SpawnedAt.IsZero() { + w.SpawnedAt = time.Now() + } + m.workers = m.manager.All() + m.refreshTableRows() + + readWorkerOutput(w.ID, w.Handle.Stdout(), m.program) + return m, waitWorkerCmd(w.ID, w.Handle) + + case workerOutputMsg: + w := m.manager.Get(msg.WorkerID) + if w == nil { + return m, nil + } + if w.Output == nil { + w.Output = worker.NewOutputBuffer(worker.DefaultMaxLines) + } + w.Output.Append(msg.Data) + if w.ID == m.selectedWorkerID { + m.refreshViewportFromSelected(true) + } + return m, nil + + case workerExitedMsg: + w := m.manager.Get(msg.WorkerID) + if w == nil { + return m, nil + } + + w.ExitCode = msg.ExitCode + if msg.Duration > 0 { + w.ExitedAt = w.SpawnedAt.Add(msg.Duration) + } else { + w.ExitedAt = time.Now() + } + if msg.Err != nil || msg.ExitCode != 0 { + w.State = worker.StateFailed + } else { + w.State = worker.StateExited + } + if msg.SessionID != "" { + w.SessionID = msg.SessionID + } else if w.Output != nil { + w.SessionID = extractSessionID(w.Output.Content()) + } + w.Handle = nil + + m.workers = m.manager.All() + m.refreshTableRows() + if w.ID == m.selectedWorkerID { + m.refreshViewportFromSelected(true) + } + return m, nil + + case workerKilledMsg: + if w := m.manager.Get(msg.WorkerID); w != nil { + w.State = worker.StateKilled + w.ExitedAt = time.Now() + w.Handle = nil + m.refreshTableRows() + if w.ID == m.selectedWorkerID { + m.refreshViewportFromSelected(true) + } + } + return m, nil + + case tickMsg: + m.refreshTableRows() + return m, tickCmd() + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + m.refreshTableRows() + return m, cmd + } + + return m, tea.Batch(cmds...) +} + +func (m *Model) refreshTableRows() { + m.workers = m.manager.All() + rows := make([]table.Row, 0, len(m.workers)) + withTask := len(m.workerTableColumns()) == 5 + for _, w := range m.workers { + status := statusIndicator(w.State, w.ExitCode) + if w.State == worker.StateRunning { + status = m.spinner.View() + " running" + } + + row := table.Row{w.ID, status, roleBadge(w.Role), w.FormatDuration()} + if withTask { + task := w.TaskID + if task == "" { + task = "-" + } + row = append(row, task) + } + rows = append(rows, row) + } + + m.table.SetRows(rows) + m.syncSelectionFromTable() +} + +func (m *Model) syncSelectionFromTable() { + rows := m.table.Rows() + if len(rows) == 0 { + m.selectedWorkerID = "" + return + } + + cursor := m.table.Cursor() + if cursor < 0 { + cursor = 0 + } + if cursor >= len(rows) { + cursor = len(rows) - 1 + m.table.SetCursor(cursor) + } + if len(rows[cursor]) == 0 { + m.selectedWorkerID = "" + return + } + + m.selectedWorkerID = fmt.Sprintf("%v", rows[cursor][0]) +} + +func (m *Model) refreshViewportFromSelected(autoFollow bool) { + w := m.selectedWorker() + if w == nil || w.Output == nil { + m.setViewportContent(welcomeViewportText(), false) + return + } + m.setViewportContent(w.Output.Content(), autoFollow) +} + +func (m *Model) setViewportContent(content string, autoFollow bool) { + atBottom := m.viewport.AtBottom() + m.viewport.SetContent(content) + if autoFollow && atBottom { + m.viewport.GotoBottom() + } +} + +func (m *Model) selectedWorker() *worker.Worker { + if m.selectedWorkerID == "" { + return nil + } + return m.manager.Get(m.selectedWorkerID) +} + +func extractSessionID(output string) string { + if match := sessionTextPattern.FindStringSubmatch(output); len(match) > 1 { + return match[1] + } + if match := sessionJSONPattern.FindStringSubmatch(output); len(match) > 1 { + return match[1] + } + return "" +} From ef3491d581cded4bd2166739e590ae61e7dbf379 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 08:11:30 -0600 Subject: [PATCH 08/26] fix(016): View purity + dedup extractSessionID before WP05 - Remove statusBar field mutation in renderStatusBar() (View must be pure) - Export ExtractSessionID from worker package, delete duplicate in tui/update.go - Remove unused statusBar field from Model struct --- internal/tui/model.go | 2 - internal/tui/panels.go | 4 +- internal/tui/update.go | 18 +------ internal/worker/session.go | 4 +- internal/worker/session_test.go | 4 +- .../tasks/WP03-tui-foundation.md | 50 ++++++++++------- .../tasks/WP04-worker-tui-integration.md | 54 +++++++++++-------- 7 files changed, 70 insertions(+), 66 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 4d30898..b014aed 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -33,8 +33,6 @@ type Model struct { workers []*worker.Worker program *tea.Program - statusBar string - showSpawnDialog bool spawnForm *spawnDialogModel spawnDraft spawnDialogDraft diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 6f053f7..c43dbd6 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -87,8 +87,8 @@ func (m *Model) renderStatusBar() string { } right := fmt.Sprintf("mode: %s scroll: %s ", m.modeName(), scrollStr) gap := strings.Repeat(" ", max(0, m.width-lipgloss.Width(left)-lipgloss.Width(right)-2)) - m.statusBar = left + gap + right - return statusBarStyle.Width(m.width).Render(m.statusBar) + bar := left + gap + right + return statusBarStyle.Width(m.width).Render(bar) } func (m *Model) renderHelpBar() string { diff --git a/internal/tui/update.go b/internal/tui/update.go index e7a1c49..66301cd 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "regexp" "strings" "time" @@ -14,11 +13,6 @@ import ( "github.com/user/kasmos/internal/worker" ) -var ( - sessionTextPattern = regexp.MustCompile(`session:\s+(ses_[a-zA-Z0-9]+)`) - sessionJSONPattern = regexp.MustCompile(`"session_id"\s*:\s*"(ses_[a-zA-Z0-9]+)"`) -) - func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.showSpawnDialog { return m.updateSpawnDialog(msg) @@ -186,7 +180,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.SessionID != "" { w.SessionID = msg.SessionID } else if w.Output != nil { - w.SessionID = extractSessionID(w.Output.Content()) + w.SessionID = worker.ExtractSessionID(w.Output.Content()) } w.Handle = nil @@ -294,13 +288,3 @@ func (m *Model) selectedWorker() *worker.Worker { } return m.manager.Get(m.selectedWorkerID) } - -func extractSessionID(output string) string { - if match := sessionTextPattern.FindStringSubmatch(output); len(match) > 1 { - return match[1] - } - if match := sessionJSONPattern.FindStringSubmatch(output); len(match) > 1 { - return match[1] - } - return "" -} diff --git a/internal/worker/session.go b/internal/worker/session.go index cbd0a00..064f192 100644 --- a/internal/worker/session.go +++ b/internal/worker/session.go @@ -7,7 +7,9 @@ var ( sessionJSONPattern = regexp.MustCompile(`"session_id"\s*:\s*"(ses_[a-zA-Z0-9]+)"`) ) -func extractSessionID(output string) string { +// ExtractSessionID scans worker output for a Claude Code session ID. +// It checks both text format ("session: ses_xxx") and JSON format ("session_id": "ses_xxx"). +func ExtractSessionID(output string) string { if m := sessionTextPattern.FindStringSubmatch(output); len(m) > 1 { return m[1] } diff --git a/internal/worker/session_test.go b/internal/worker/session_test.go index e31e362..de60d88 100644 --- a/internal/worker/session_test.go +++ b/internal/worker/session_test.go @@ -37,9 +37,9 @@ func TestExtractSessionID(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := extractSessionID(tc.input) + got := ExtractSessionID(tc.input) if got != tc.output { - t.Fatalf("extractSessionID() = %q, want %q", got, tc.output) + t.Fatalf("ExtractSessionID() = %q, want %q", got, tc.output) } }) } diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md index 004e75c..dcefe46 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP03-tui-foundation.md @@ -1,27 +1,37 @@ --- -work_package_id: "WP03" -title: "TUI Foundation (Layout, Styles, Keys)" -lane: "planned" +work_package_id: WP03 +title: TUI Foundation (Layout, Styles, Keys) +lane: done dependencies: - - "WP01" +- WP01 subtasks: - - "internal/tui/model.go - Main Model struct, Init(), View()" - - "internal/tui/layout.go - recalculateLayout(), breakpoints, dimension math" - - "internal/tui/styles.go - Full color palette, all style definitions" - - "internal/tui/keys.go - keyMap, defaultKeyMap(), ShortHelp(), FullHelp()" - - "internal/tui/messages.go - All tea.Msg type definitions" - - "internal/tui/panels.go - Empty panel rendering (table, viewport, status bar, help)" -phase: "Wave 1 - Core TUI + Worker Lifecycle" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- internal/tui/model.go - Main Model struct, Init(), View() +- internal/tui/layout.go - recalculateLayout(), breakpoints, dimension math +- internal/tui/styles.go - Full color palette, all style definitions +- internal/tui/keys.go - keyMap, defaultKeyMap(), ShortHelp(), FullHelp() +- internal/tui/messages.go - All tea.Msg type definitions +- internal/tui/panels.go - Empty panel rendering (table, viewport, status bar, help) +phase: Wave 1 - Core TUI + Worker Lifecycle +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T06:09:15.128027024+00:00' + lane: doing + actor: manager + shell_pid: '401658' + action: transition active (WP01 done, dependency satisfied. TUI foundation is next.) +- timestamp: '2026-02-18T08:53:16.631027867+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: 'transition done (Verified: 18 PASS, 1 WARN (adaptive colors). Build/vet/tests clean.)' --- # Work Package Prompt: WP03 - TUI Foundation (Layout, Styles, Keys) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md index 6456a45..abd87b7 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP04-worker-tui-integration.md @@ -1,29 +1,39 @@ --- -work_package_id: "WP04" -title: "Worker-TUI Integration (Spawn + Output + Lifecycle)" -lane: "planned" +work_package_id: WP04 +title: Worker-TUI Integration (Spawn + Output + Lifecycle) +lane: done dependencies: - - "WP02" - - "WP03" +- WP02 +- WP03 subtasks: - - "internal/tui/commands.go - spawnWorkerCmd, readOutputCmd, waitCmd, killWorkerCmd" - - "Update model.go - Add WorkerManager + WorkerBackend fields" - - "Update update.go - Worker lifecycle message handlers" - - "Update panels.go - Table rows from worker data, viewport from output buffer" - - "Spawn dialog (huh form) in overlays.go" - - "Timer tick for duration updates" - - "Spinner for running worker status cells" -phase: "Wave 1 - Core TUI + Worker Lifecycle" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- internal/tui/commands.go - spawnWorkerCmd, readOutputCmd, waitCmd, killWorkerCmd +- Update model.go - Add WorkerManager + WorkerBackend fields +- Update update.go - Worker lifecycle message handlers +- Update panels.go - Table rows from worker data, viewport from output buffer +- Spawn dialog (huh form) in overlays.go +- Timer tick for duration updates +- Spinner for running worker status cells +phase: Wave 1 - Core TUI + Worker Lifecycle +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T08:53:56.379168648+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: 'transition active (WP02+WP03 done. Launching core integration: spawn, output, lifecycle.)' +- timestamp: '2026-02-18T13:30:47.372407497+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: 'transition done (Verified: PASS. Fixed M1 (View purity). Spawn->output->exit flow works end-to-end.)' --- # Work Package Prompt: WP04 - Worker-TUI Integration (Spawn + Output + Lifecycle) From 7ddb864175f0cff7ec3a334947de8ae0e87058f0 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 08:23:45 -0600 Subject: [PATCH 09/26] feat(016): WP05 continue dialog + WP07 kill/restart + WP08 task sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP05: Continue dialog with parent info, quit confirmation with running worker count, worker chain tree rendering (├─/└─/│ glyphs), viewport chain references, updateKeyStates for Continue key. WP07: Kill running workers (x key, 3s grace SIGTERM→SIGKILL), restart failed/killed workers (r key, pre-filled spawn dialog). WP08: internal/task/ package with Source interface, SpecKittySource (YAML frontmatter parser + dependency resolution), GsdSource (checkbox markdown), AdHocSource, DetectSourceType, CLI positional arg, full test suite. Review fixes: deduplicate TaskState/Task types (TUI now imports from task package), guard done/in-progress tasks from retroactive blocking, simplify state transition in workerSpawnedMsg handler. --- cmd/kasmos/main.go | 18 +- go.mod | 1 + go.sum | 1 + internal/task/adhoc.go | 8 + internal/task/gsd.go | 74 ++++++ internal/task/gsd_test.go | 59 +++++ internal/task/source.go | 81 +++++++ internal/task/source_test.go | 69 ++++++ internal/task/speckitty.go | 192 +++++++++++++++ internal/task/speckitty_test.go | 110 +++++++++ .../task/testdata/spec-deps/tasks/WP01.md | 9 + .../task/testdata/spec-deps/tasks/WP02.md | 10 + .../task/testdata/spec-deps/tasks/WP03.md | 10 + .../testdata/spec-malformed/tasks/WP01.md | 8 + .../task/testdata/spec-missing/tasks/WP01.md | 5 + .../task/testdata/spec-roles/tasks/WP10.md | 9 + .../task/testdata/spec-roles/tasks/WP11.md | 9 + .../task/testdata/spec-roles/tasks/WP12.md | 9 + .../task/testdata/spec-roles/tasks/WP13.md | 9 + .../task/testdata/spec-single/tasks/WP01.md | 11 + internal/tui/keys.go | 17 +- internal/tui/messages.go | 26 +- internal/tui/model.go | 31 ++- internal/tui/overlays.go | 227 ++++++++++++++++++ internal/tui/panels.go | 69 ++++++ internal/tui/styles.go | 21 +- internal/tui/update.go | 147 +++++++++++- .../tasks/WP05-spawn-continue-dialogs.md | 43 ++-- .../tasks/WP07-kill-restart.md | 41 ++-- .../tasks/WP08-task-sources.md | 45 ++-- 30 files changed, 1260 insertions(+), 109 deletions(-) create mode 100644 internal/task/adhoc.go create mode 100644 internal/task/gsd.go create mode 100644 internal/task/gsd_test.go create mode 100644 internal/task/source.go create mode 100644 internal/task/source_test.go create mode 100644 internal/task/speckitty.go create mode 100644 internal/task/speckitty_test.go create mode 100644 internal/task/testdata/spec-deps/tasks/WP01.md create mode 100644 internal/task/testdata/spec-deps/tasks/WP02.md create mode 100644 internal/task/testdata/spec-deps/tasks/WP03.md create mode 100644 internal/task/testdata/spec-malformed/tasks/WP01.md create mode 100644 internal/task/testdata/spec-missing/tasks/WP01.md create mode 100644 internal/task/testdata/spec-roles/tasks/WP10.md create mode 100644 internal/task/testdata/spec-roles/tasks/WP11.md create mode 100644 internal/task/testdata/spec-roles/tasks/WP12.md create mode 100644 internal/task/testdata/spec-roles/tasks/WP13.md create mode 100644 internal/task/testdata/spec-single/tasks/WP01.md diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index 372d9bf..fa3bf32 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log" "os" "os/signal" "syscall" @@ -10,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/spf13/cobra" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/tui" "github.com/user/kasmos/internal/worker" ) @@ -27,12 +29,26 @@ func newRootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "kasmos", Short: "Kasmos agent orchestrator", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if showVersion { fmt.Fprintln(cmd.OutOrStdout(), "kasmos v0.1.0") return nil } + var source task.Source = &task.AdHocSource{} + if len(args) > 0 { + detected, err := task.DetectSourceType(args[0]) + if err != nil { + return err + } + source = detected + } + + if _, err := source.Load(); err != nil { + log.Printf("warning: failed to load task source %q (%s): %v", source.Path(), source.Type(), err) + } + backend, err := worker.NewSubprocessBackend() if err != nil { return err @@ -41,7 +57,7 @@ func newRootCmd() *cobra.Command { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - model := tui.NewModel(backend) + model := tui.NewModel(backend, source) program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx)) model.SetProgram(program) diff --git a/go.mod b/go.mod index c31640a..6c65126 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/go.sum b/go.sum index 3afadea..a217e89 100644 --- a/go.sum +++ b/go.sum @@ -69,4 +69,5 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/task/adhoc.go b/internal/task/adhoc.go new file mode 100644 index 0000000..5f7aa96 --- /dev/null +++ b/internal/task/adhoc.go @@ -0,0 +1,8 @@ +package task + +type AdHocSource struct{} + +func (s *AdHocSource) Type() string { return "ad-hoc" } +func (s *AdHocSource) Path() string { return "" } +func (s *AdHocSource) Load() ([]Task, error) { return nil, nil } +func (s *AdHocSource) Tasks() []Task { return nil } diff --git a/internal/task/gsd.go b/internal/task/gsd.go new file mode 100644 index 0000000..e37c1ba --- /dev/null +++ b/internal/task/gsd.go @@ -0,0 +1,74 @@ +package task + +import ( + "bufio" + "fmt" + "os" + "regexp" +) + +var gsdCheckboxPattern = regexp.MustCompile(`^- \[( |x)\] (.+)$`) + +type GsdSource struct { + FilePath string + tasks []Task +} + +func (s *GsdSource) Type() string { + return "gsd" +} + +func (s *GsdSource) Path() string { + return s.FilePath +} + +func (s *GsdSource) Load() ([]Task, error) { + file, err := os.Open(s.FilePath) + if err != nil { + return nil, fmt.Errorf("open gsd source %q: %w", s.FilePath, err) + } + defer file.Close() + + tasks := make([]Task, 0) + scanner := bufio.NewScanner(file) + + index := 0 + for scanner.Scan() { + line := scanner.Text() + matches := gsdCheckboxPattern.FindStringSubmatch(line) + if len(matches) != 3 { + continue + } + + index++ + state := TaskUnassigned + if matches[1] == "x" { + state = TaskDone + } + + title := matches[2] + tasks = append(tasks, Task{ + ID: fmt.Sprintf("T-%03d", index), + Title: title, + Description: title, + State: state, + }) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan gsd source %q: %w", s.FilePath, err) + } + + s.tasks = tasks + return s.Tasks(), nil +} + +func (s *GsdSource) Tasks() []Task { + if len(s.tasks) == 0 { + return nil + } + + cloned := make([]Task, len(s.tasks)) + copy(cloned, s.tasks) + return cloned +} diff --git a/internal/task/gsd_test.go b/internal/task/gsd_test.go new file mode 100644 index 0000000..2c3d4a9 --- /dev/null +++ b/internal/task/gsd_test.go @@ -0,0 +1,59 @@ +package task + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGsdSourceLoad(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "tasks.md") + content := "intro line\n- [ ] First task\n- [x] Completed task\nnot a task\n- [ ] Last task\n" + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatalf("write markdown: %v", err) + } + + source := &GsdSource{FilePath: filePath} + tasks, err := source.Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if len(tasks) != 3 { + t.Fatalf("expected 3 tasks, got %d", len(tasks)) + } + + if tasks[0].ID != "T-001" || tasks[1].ID != "T-002" || tasks[2].ID != "T-003" { + t.Fatalf("unexpected task ids: %#v", []string{tasks[0].ID, tasks[1].ID, tasks[2].ID}) + } + + if tasks[0].State != TaskUnassigned || tasks[1].State != TaskDone || tasks[2].State != TaskUnassigned { + t.Fatalf("unexpected states: %#v", []TaskState{tasks[0].State, tasks[1].State, tasks[2].State}) + } + + if tasks[1].Title != "Completed task" || tasks[1].Description != "Completed task" { + t.Fatalf("unexpected task content: %#v", tasks[1]) + } +} + +func TestGsdSourceLoadEmptyFile(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "empty.md") + if err := os.WriteFile(filePath, []byte(""), 0o600); err != nil { + t.Fatalf("write markdown: %v", err) + } + + source := &GsdSource{FilePath: filePath} + tasks, err := source.Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if len(tasks) != 0 { + t.Fatalf("expected 0 tasks, got %d", len(tasks)) + } + + if cached := source.Tasks(); len(cached) != 0 { + t.Fatalf("expected cached tasks to be empty, got %d", len(cached)) + } +} diff --git a/internal/task/source.go b/internal/task/source.go new file mode 100644 index 0000000..6a0bd97 --- /dev/null +++ b/internal/task/source.go @@ -0,0 +1,81 @@ +package task + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type Source interface { + Type() string + Path() string + Load() ([]Task, error) + Tasks() []Task +} + +type Task struct { + ID string + Title string + Description string + SuggestedRole string + Dependencies []string + State TaskState + WorkerID string + Metadata map[string]string +} + +type TaskState int + +const ( + TaskUnassigned TaskState = iota + TaskBlocked + TaskInProgress + TaskDone + TaskFailed +) + +func (s TaskState) String() string { + switch s { + case TaskUnassigned: + return "unassigned" + case TaskBlocked: + return "blocked" + case TaskInProgress: + return "in-progress" + case TaskDone: + return "done" + case TaskFailed: + return "failed" + default: + return "unknown" + } +} + +func DetectSourceType(path string) (Source, error) { + if strings.TrimSpace(path) == "" { + return &AdHocSource{}, nil + } + + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("detect task source %q: %w", path, err) + } + + if info.IsDir() { + matches, err := filepath.Glob(filepath.Join(path, "tasks", "*.md")) + if err != nil { + return nil, fmt.Errorf("scan spec-kitty tasks in %q: %w", path, err) + } + if len(matches) > 0 { + return &SpecKittySource{Dir: path}, nil + } + return nil, fmt.Errorf("directory %q is not a spec-kitty source: expected markdown files in tasks/", path) + } + + if strings.EqualFold(filepath.Ext(path), ".md") { + return &GsdSource{FilePath: path}, nil + } + + return nil, fmt.Errorf("unsupported task source %q: provide a spec-kitty directory or markdown file", path) +} diff --git a/internal/task/source_test.go b/internal/task/source_test.go new file mode 100644 index 0000000..ca1306a --- /dev/null +++ b/internal/task/source_test.go @@ -0,0 +1,69 @@ +package task + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectSourceType(t *testing.T) { + t.Run("empty path uses ad-hoc", func(t *testing.T) { + source, err := DetectSourceType("") + if err != nil { + t.Fatalf("DetectSourceType returned error: %v", err) + } + if _, ok := source.(*AdHocSource); !ok { + t.Fatalf("expected *AdHocSource, got %T", source) + } + }) + + t.Run("directory with tasks uses spec-kitty", func(t *testing.T) { + dir := t.TempDir() + tasksDir := filepath.Join(dir, "tasks") + if err := os.Mkdir(tasksDir, 0o755); err != nil { + t.Fatalf("mkdir tasks dir: %v", err) + } + filePath := filepath.Join(tasksDir, "WP01.md") + if err := os.WriteFile(filePath, []byte("---\nwork_package_id: WP01\n---\nbody"), 0o600); err != nil { + t.Fatalf("write task file: %v", err) + } + + source, err := DetectSourceType(dir) + if err != nil { + t.Fatalf("DetectSourceType returned error: %v", err) + } + specSource, ok := source.(*SpecKittySource) + if !ok { + t.Fatalf("expected *SpecKittySource, got %T", source) + } + if specSource.Dir != dir { + t.Fatalf("expected dir %q, got %q", dir, specSource.Dir) + } + }) + + t.Run("markdown file uses gsd", func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "tasks.md") + if err := os.WriteFile(filePath, []byte("- [ ] task"), 0o600); err != nil { + t.Fatalf("write markdown file: %v", err) + } + + source, err := DetectSourceType(filePath) + if err != nil { + t.Fatalf("DetectSourceType returned error: %v", err) + } + gsdSource, ok := source.(*GsdSource) + if !ok { + t.Fatalf("expected *GsdSource, got %T", source) + } + if gsdSource.FilePath != filePath { + t.Fatalf("expected path %q, got %q", filePath, gsdSource.FilePath) + } + }) + + t.Run("missing path returns error", func(t *testing.T) { + if _, err := DetectSourceType(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected error for missing path") + } + }) +} diff --git a/internal/task/speckitty.go b/internal/task/speckitty.go new file mode 100644 index 0000000..8a5852d --- /dev/null +++ b/internal/task/speckitty.go @@ -0,0 +1,192 @@ +package task + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type wpFrontmatter struct { + WorkPackageID string `yaml:"work_package_id"` + Title string `yaml:"title"` + Lane string `yaml:"lane"` + Dependencies []string `yaml:"dependencies"` + Subtasks []string `yaml:"subtasks"` + Phase string `yaml:"phase"` +} + +type SpecKittySource struct { + Dir string + tasks []Task +} + +func (s *SpecKittySource) Type() string { + return "spec-kitty" +} + +func (s *SpecKittySource) Path() string { + return s.Dir +} + +func (s *SpecKittySource) Load() ([]Task, error) { + paths, err := filepath.Glob(filepath.Join(s.Dir, "tasks", "WP*.md")) + if err != nil { + return nil, fmt.Errorf("list work package files in %q: %w", s.Dir, err) + } + if len(paths) == 0 { + return nil, fmt.Errorf("no work package files found in %q", filepath.Join(s.Dir, "tasks")) + } + sort.Strings(paths) + + tasks := make([]Task, 0, len(paths)) + for _, path := range paths { + parsed, err := parseWorkPackage(path) + if err != nil { + return nil, err + } + tasks = append(tasks, parsed) + } + + resolveDependencyStates(tasks) + s.tasks = tasks + return s.Tasks(), nil +} + +func (s *SpecKittySource) Tasks() []Task { + if len(s.tasks) == 0 { + return nil + } + + cloned := make([]Task, len(s.tasks)) + copy(cloned, s.tasks) + return cloned +} + +func parseWorkPackage(path string) (Task, error) { + content, err := os.ReadFile(path) + if err != nil { + return Task{}, fmt.Errorf("read work package %q: %w", path, err) + } + + frontmatterRaw, body, err := splitFrontmatter(string(content)) + if err != nil { + return Task{}, fmt.Errorf("parse work package %q: %w", path, err) + } + + var frontmatter wpFrontmatter + if err := yaml.Unmarshal([]byte(frontmatterRaw), &frontmatter); err != nil { + return Task{}, fmt.Errorf("decode frontmatter for %q: %w", path, err) + } + + metadata := make(map[string]string) + if frontmatter.Phase != "" { + metadata["phase"] = frontmatter.Phase + } + if len(frontmatter.Subtasks) > 0 { + metadata["subtasks"] = strings.Join(frontmatter.Subtasks, ",") + } + if len(metadata) == 0 { + metadata = nil + } + + return Task{ + ID: frontmatter.WorkPackageID, + Title: frontmatter.Title, + Description: body, + SuggestedRole: inferRole(frontmatter.Phase), + Dependencies: append([]string(nil), frontmatter.Dependencies...), + State: laneToTaskState(frontmatter.Lane), + Metadata: metadata, + }, nil +} + +func splitFrontmatter(content string) (frontmatter string, body string, err error) { + lines := strings.Split(content, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return "", "", fmt.Errorf("missing frontmatter opening delimiter") + } + + end := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + end = i + break + } + } + if end == -1 { + return "", "", fmt.Errorf("missing frontmatter closing delimiter") + } + + frontmatter = strings.Join(lines[1:end], "\n") + body = strings.TrimSpace(strings.Join(lines[end+1:], "\n")) + return frontmatter, body, nil +} + +func laneToTaskState(lane string) TaskState { + switch strings.ToLower(strings.TrimSpace(lane)) { + case "planned": + return TaskUnassigned + case "doing", "for_review": + return TaskInProgress + case "done": + return TaskDone + default: + return TaskUnassigned + } +} + +func inferRole(phase string) string { + lower := strings.ToLower(phase) + switch { + case strings.Contains(lower, "spec") || strings.Contains(lower, "clarifying"): + return "planner" + case strings.Contains(lower, "implementation"): + return "coder" + case strings.Contains(lower, "review"): + return "reviewer" + case strings.Contains(lower, "release"): + return "release" + default: + return "" + } +} + +func resolveDependencyStates(tasks []Task) { + for { + states := make(map[string]TaskState, len(tasks)) + for _, task := range tasks { + states[task.ID] = task.State + } + + changed := false + for i := range tasks { + if len(tasks[i].Dependencies) == 0 { + continue + } + // Don't retroactively block tasks that are already done or in progress. + if tasks[i].State == TaskDone || tasks[i].State == TaskInProgress { + continue + } + + blocked := false + for _, dep := range tasks[i].Dependencies { + if state, ok := states[dep]; !ok || state != TaskDone { + blocked = true + break + } + } + if blocked && tasks[i].State != TaskBlocked { + tasks[i].State = TaskBlocked + changed = true + } + } + + if !changed { + return + } + } +} diff --git a/internal/task/speckitty_test.go b/internal/task/speckitty_test.go new file mode 100644 index 0000000..bd4ddf4 --- /dev/null +++ b/internal/task/speckitty_test.go @@ -0,0 +1,110 @@ +package task + +import ( + "path/filepath" + "testing" +) + +func TestSpecKittyLoadSingleFile(t *testing.T) { + source := &SpecKittySource{Dir: filepath.Join("testdata", "spec-single")} + tasks, err := source.Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + + task := tasks[0] + if task.ID != "WP01" { + t.Fatalf("expected ID WP01, got %q", task.ID) + } + if task.Title != "Plan architecture" { + t.Fatalf("expected title %q, got %q", "Plan architecture", task.Title) + } + if task.State != TaskUnassigned { + t.Fatalf("expected TaskUnassigned, got %v", task.State) + } + if task.SuggestedRole != "planner" { + t.Fatalf("expected role planner, got %q", task.SuggestedRole) + } + if task.Metadata["phase"] != "spec_clarifying" { + t.Fatalf("expected phase metadata, got %#v", task.Metadata) + } + if task.Metadata["subtasks"] != "outline,review" { + t.Fatalf("expected subtasks metadata, got %#v", task.Metadata) + } +} + +func TestSpecKittyDependencyResolution(t *testing.T) { + source := &SpecKittySource{Dir: filepath.Join("testdata", "spec-deps")} + tasks, err := source.Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if len(tasks) != 3 { + t.Fatalf("expected 3 tasks, got %d", len(tasks)) + } + + states := map[string]TaskState{} + for _, task := range tasks { + states[task.ID] = task.State + } + + if states["WP01"] != TaskDone { + t.Fatalf("expected WP01 done, got %v", states["WP01"]) + } + if states["WP02"] != TaskUnassigned { + t.Fatalf("expected WP02 unassigned, got %v", states["WP02"]) + } + if states["WP03"] != TaskBlocked { + t.Fatalf("expected WP03 blocked, got %v", states["WP03"]) + } +} + +func TestSpecKittyRoleInference(t *testing.T) { + source := &SpecKittySource{Dir: filepath.Join("testdata", "spec-roles")} + tasks, err := source.Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + roles := map[string]string{} + for _, task := range tasks { + roles[task.ID] = task.SuggestedRole + } + + if roles["WP10"] != "planner" { + t.Fatalf("expected WP10 role planner, got %q", roles["WP10"]) + } + if roles["WP11"] != "coder" { + t.Fatalf("expected WP11 role coder, got %q", roles["WP11"]) + } + if roles["WP12"] != "reviewer" { + t.Fatalf("expected WP12 role reviewer, got %q", roles["WP12"]) + } + if roles["WP13"] != "release" { + t.Fatalf("expected WP13 role release, got %q", roles["WP13"]) + } +} + +func TestSpecKittyMissingOrMalformedFrontmatter(t *testing.T) { + tests := []struct { + name string + dir string + }{ + {name: "missing delimiters", dir: filepath.Join("testdata", "spec-missing")}, + {name: "invalid yaml", dir: filepath.Join("testdata", "spec-malformed")}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + source := &SpecKittySource{Dir: tc.dir} + if _, err := source.Load(); err == nil { + t.Fatal("expected error but got nil") + } + }) + } +} diff --git a/internal/task/testdata/spec-deps/tasks/WP01.md b/internal/task/testdata/spec-deps/tasks/WP01.md new file mode 100644 index 0000000..e7dcb11 --- /dev/null +++ b/internal/task/testdata/spec-deps/tasks/WP01.md @@ -0,0 +1,9 @@ +--- +work_package_id: WP01 +title: Foundation +lane: done +dependencies: [] +subtasks: [] +phase: Implementation +--- +Completed foundation task. diff --git a/internal/task/testdata/spec-deps/tasks/WP02.md b/internal/task/testdata/spec-deps/tasks/WP02.md new file mode 100644 index 0000000..a117476 --- /dev/null +++ b/internal/task/testdata/spec-deps/tasks/WP02.md @@ -0,0 +1,10 @@ +--- +work_package_id: WP02 +title: Build feature +lane: planned +dependencies: + - WP01 +subtasks: [] +phase: Implementation +--- +Work that depends on foundation. diff --git a/internal/task/testdata/spec-deps/tasks/WP03.md b/internal/task/testdata/spec-deps/tasks/WP03.md new file mode 100644 index 0000000..c9991cf --- /dev/null +++ b/internal/task/testdata/spec-deps/tasks/WP03.md @@ -0,0 +1,10 @@ +--- +work_package_id: WP03 +title: Follow-up +lane: planned +dependencies: + - WP02 +subtasks: [] +phase: Review +--- +Follow-up work blocked by WP02. diff --git a/internal/task/testdata/spec-malformed/tasks/WP01.md b/internal/task/testdata/spec-malformed/tasks/WP01.md new file mode 100644 index 0000000..8dcedb1 --- /dev/null +++ b/internal/task/testdata/spec-malformed/tasks/WP01.md @@ -0,0 +1,8 @@ +--- +work_package_id: WP01 +title: Broken yaml +lane: planned +dependencies: [WP00 +phase: Implementation +--- +Malformed frontmatter should fail. diff --git a/internal/task/testdata/spec-missing/tasks/WP01.md b/internal/task/testdata/spec-missing/tasks/WP01.md new file mode 100644 index 0000000..c03677a --- /dev/null +++ b/internal/task/testdata/spec-missing/tasks/WP01.md @@ -0,0 +1,5 @@ +work_package_id: WP01 +title: Missing frontmatter delimiters +lane: planned + +Body content without proper delimiters. diff --git a/internal/task/testdata/spec-roles/tasks/WP10.md b/internal/task/testdata/spec-roles/tasks/WP10.md new file mode 100644 index 0000000..a5e9b13 --- /dev/null +++ b/internal/task/testdata/spec-roles/tasks/WP10.md @@ -0,0 +1,9 @@ +--- +work_package_id: WP10 +title: Clarify scope +lane: planned +dependencies: [] +subtasks: [] +phase: spec clarifying +--- +Clarify requirements. diff --git a/internal/task/testdata/spec-roles/tasks/WP11.md b/internal/task/testdata/spec-roles/tasks/WP11.md new file mode 100644 index 0000000..03de284 --- /dev/null +++ b/internal/task/testdata/spec-roles/tasks/WP11.md @@ -0,0 +1,9 @@ +--- +work_package_id: WP11 +title: Implement parser +lane: doing +dependencies: [] +subtasks: [] +phase: Implementation wave 2 +--- +Implement parser changes. diff --git a/internal/task/testdata/spec-roles/tasks/WP12.md b/internal/task/testdata/spec-roles/tasks/WP12.md new file mode 100644 index 0000000..8045472 --- /dev/null +++ b/internal/task/testdata/spec-roles/tasks/WP12.md @@ -0,0 +1,9 @@ +--- +work_package_id: WP12 +title: Review changes +lane: for_review +dependencies: [] +subtasks: [] +phase: Review + QA +--- +Review implementation. diff --git a/internal/task/testdata/spec-roles/tasks/WP13.md b/internal/task/testdata/spec-roles/tasks/WP13.md new file mode 100644 index 0000000..bd518b3 --- /dev/null +++ b/internal/task/testdata/spec-roles/tasks/WP13.md @@ -0,0 +1,9 @@ +--- +work_package_id: WP13 +title: Release artifact +lane: planned +dependencies: [] +subtasks: [] +phase: Release candidate +--- +Ship release. diff --git a/internal/task/testdata/spec-single/tasks/WP01.md b/internal/task/testdata/spec-single/tasks/WP01.md new file mode 100644 index 0000000..696e990 --- /dev/null +++ b/internal/task/testdata/spec-single/tasks/WP01.md @@ -0,0 +1,11 @@ +--- +work_package_id: WP01 +title: Plan architecture +lane: planned +dependencies: [] +subtasks: + - outline + - review +phase: spec_clarifying +--- +Define architecture goals and key boundaries. diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 039f73c..09cd668 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -1,6 +1,10 @@ package tui -import "github.com/charmbracelet/bubbles/v2/key" +import ( + "github.com/charmbracelet/bubbles/v2/key" + + "github.com/user/kasmos/internal/worker" +) type keyMap struct { Up key.Binding @@ -158,6 +162,8 @@ func (k keyMap) FullHelp() [][]key.Binding { } func (m *Model) updateKeyStates() { + selected := m.selectedWorker() + m.keys.Spawn.SetEnabled(true) m.keys.Help.SetEnabled(true) m.keys.Quit.SetEnabled(true) @@ -170,9 +176,12 @@ func (m *Model) updateKeyStates() { m.keys.ScrollUp.SetEnabled(true) m.keys.Back.SetEnabled(true) - m.keys.Kill.SetEnabled(false) - m.keys.Continue.SetEnabled(false) - m.keys.Restart.SetEnabled(false) + m.keys.Kill.SetEnabled(selected != nil && selected.State == worker.StateRunning) + m.keys.Continue.SetEnabled(selected != nil && + (selected.State == worker.StateExited || selected.State == worker.StateFailed) && + selected.SessionID != "") + m.keys.Restart.SetEnabled(selected != nil && + (selected.State == worker.StateFailed || selected.State == worker.StateKilled)) m.keys.Batch.SetEnabled(false) m.keys.Fullscreen.SetEnabled(false) m.keys.HalfDown.SetEnabled(false) diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 2b44ba0..87754de 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -86,40 +87,31 @@ type genPromptCompletedMsg struct { Err error } -type Task struct { - ID string - Title string - Description string - SuggestedRole string - Dependencies []string - State TaskState - WorkerID string - Metadata map[string]string -} - +// tasksLoadedMsg is sent when a task source finishes loading. type tasksLoadedMsg struct { Source string Path string - Tasks []Task + Tasks []task.Task Err error } +// taskStateChangedMsg is sent when a task's state changes. type taskStateChangedMsg struct { TaskID string - NewState TaskState + NewState task.TaskState WorkerID string } +// sessionSavedMsg is sent when session persistence completes. type sessionSavedMsg struct { Path string Err error } -type SessionState struct{} - +// sessionLoadedMsg is sent when a session is restored from disk. type sessionLoadedMsg struct { - Session *SessionState - Err error + Path string + Err error } func tickCmd() tea.Cmd { diff --git a/internal/tui/model.go b/internal/tui/model.go index b014aed..62d8f78 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -33,11 +34,17 @@ type Model struct { workers []*worker.Worker program *tea.Program - showSpawnDialog bool - spawnForm *spawnDialogModel - spawnDraft spawnDialogDraft + showSpawnDialog bool + spawnForm *spawnDialogModel + spawnDraft spawnDialogDraft + showContinueDialog bool + continueForm *continueDialogModel + continueParentID string + showQuitConfirm bool + quitConfirmFocused int - selectedWorkerID string + selectedWorkerID string + tableRowWorkerIDs []string tableInnerWidth int tableInnerHeight int @@ -54,9 +61,10 @@ type Model struct { taskSourceType string taskSourcePath string + taskSource task.Source } -func NewModel(backend worker.WorkerBackend) *Model { +func NewModel(backend worker.WorkerBackend, source task.Source) *Model { t := table.New( table.WithColumns([]table.Column{ {Title: "ID", Width: 10}, @@ -85,6 +93,11 @@ func NewModel(backend worker.WorkerBackend) *Model { manager: worker.NewWorkerManager(), workers: make([]*worker.Worker, 0), } + if source != nil { + m.taskSource = source + m.taskSourceType = source.Type() + m.taskSourcePath = source.Path() + } m.updateKeyStates() return m } @@ -136,6 +149,14 @@ func (m *Model) View() string { return m.renderHelpOverlay() } + if m.showContinueDialog { + return m.renderContinueDialog() + } + + if m.showQuitConfirm { + return m.renderQuitConfirm() + } + if m.showSpawnDialog { return m.renderSpawnDialog() } diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index c782403..93009d3 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -9,6 +9,8 @@ import ( "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + + "github.com/user/kasmos/internal/worker" ) type spawnDialogDraft struct { @@ -30,12 +32,25 @@ type spawnRoleOption struct { description string } +type continueDialogModel struct { + parentWorkerID string + parentRole string + parentState worker.WorkerState + sessionID string + followUp textarea.Model + focusedIdx int +} + const ( spawnFocusRole = iota spawnFocusPrompt spawnFocusFiles ) +const ( + continueFocusFollowUp = iota +) + func (m *Model) openSpawnDialog() tea.Cmd { m.showSpawnDialog = true m.spawnDraft = spawnDialogDraft{Role: "coder"} @@ -43,6 +58,13 @@ func (m *Model) openSpawnDialog() tea.Cmd { return m.spawnForm.focusCurrentField() } +func (m *Model) openSpawnDialogWithPrefill(role, prompt string, files []string) tea.Cmd { + m.showSpawnDialog = true + m.spawnDraft = spawnDialogDraft{Role: role, Prompt: prompt, Files: strings.Join(files, ", ")} + m.spawnForm = newSpawnDialogModelWithPrefill(role, prompt, files) + return m.spawnForm.focusCurrentField() +} + func newSpawnDialogModel() *spawnDialogModel { prompt := styledTextArea() prompt.Placeholder = "Describe the task for this worker" @@ -71,6 +93,25 @@ func newSpawnDialogModel() *spawnDialogModel { return form } +func newSpawnDialogModelWithPrefill(role, promptText string, files []string) *spawnDialogModel { + form := newSpawnDialogModel() + + for i, opt := range form.roles { + if opt.role == role { + form.roleIndex = i + break + } + } + + form.prompt.SetValue(promptText) + + if len(files) > 0 { + form.files.SetValue(strings.Join(files, ", ")) + } + + return form +} + func (f *spawnDialogModel) focusCurrentField() tea.Cmd { f.prompt.Blur() f.files.Blur() @@ -96,6 +137,121 @@ func (m *Model) closeSpawnDialog() { m.spawnDraft = spawnDialogDraft{} } +func (m *Model) openContinueDialog(parent *worker.Worker) tea.Cmd { + if parent == nil || parent.SessionID == "" { + return nil + } + + m.showContinueDialog = true + m.continueParentID = parent.ID + m.continueForm = newContinueDialogModel(parent) + return m.continueForm.focusCurrentField() +} + +func (m *Model) closeContinueDialog() { + m.showContinueDialog = false + m.continueParentID = "" + m.continueForm = nil + m.updateKeyStates() +} + +func newContinueDialogModel(parent *worker.Worker) *continueDialogModel { + followUp := styledTextArea() + followUp.Placeholder = "Describe what to do next..." + followUp.SetWidth(58) + followUp.SetHeight(6) + followUp.Blur() + + return &continueDialogModel{ + parentWorkerID: parent.ID, + parentRole: parent.Role, + parentState: parent.State, + sessionID: parent.SessionID, + followUp: followUp, + focusedIdx: continueFocusFollowUp, + } +} + +func (f *continueDialogModel) focusCurrentField() tea.Cmd { + if f == nil { + return nil + } + f.followUp.Blur() + return f.followUp.Focus() +} + +func (f *continueDialogModel) cycleFocus(_ int) tea.Cmd { + return f.focusCurrentField() +} + +func (m *Model) updateContinueDialog(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.continueForm == nil { + m.closeContinueDialog() + return m, nil + } + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if key.Matches(keyMsg, m.keys.Back) { + m.closeContinueDialog() + return m, func() tea.Msg { return continueDialogCancelledMsg{} } + } + + switch keyMsg.String() { + case "tab": + return m, m.continueForm.cycleFocus(1) + case "shift+tab": + return m, m.continueForm.cycleFocus(-1) + case "enter": + followUp := strings.TrimSpace(m.continueForm.followUp.Value()) + if followUp == "" { + return m, nil + } + submitted := continueDialogSubmittedMsg{ + ParentWorkerID: m.continueForm.parentWorkerID, + SessionID: m.continueForm.sessionID, + FollowUp: followUp, + } + m.closeContinueDialog() + return m, func() tea.Msg { return submitted } + } + } + + var cmd tea.Cmd + m.continueForm.followUp, cmd = m.continueForm.followUp.Update(msg) + return m, cmd +} + +func (m *Model) updateQuitConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if key.Matches(keyMsg, m.keys.Back) { + m.showQuitConfirm = false + m.updateKeyStates() + return m, func() tea.Msg { return quitCancelledMsg{} } + } + + switch keyMsg.String() { + case "left", "shift+tab": + m.quitConfirmFocused = (m.quitConfirmFocused + 1) % 2 + return m, nil + case "right", "tab": + m.quitConfirmFocused = (m.quitConfirmFocused + 1) % 2 + return m, nil + case "enter": + m.showQuitConfirm = false + m.updateKeyStates() + if m.quitConfirmFocused == 0 { + return m, func() tea.Msg { return quitConfirmedMsg{} } + } + return m, func() tea.Msg { return quitCancelledMsg{} } + } + + return m, nil +} + func (m *Model) updateSpawnDialog(msg tea.Msg) (tea.Model, tea.Cmd) { if keyMsg, ok := msg.(tea.KeyMsg); ok { if key.Matches(keyMsg, m.keys.Back) { @@ -216,3 +372,74 @@ func (m *Model) renderSpawnDialog() string { dialog := dialogStyle.Width(70).Render(content) return m.renderWithBackdrop(dialog) } + +func (m *Model) renderContinueDialog() string { + if m.continueForm == nil { + return m.renderWithBackdrop("") + } + + parentStatus := statusIndicator(m.continueForm.parentState, 0) + meta := lipgloss.JoinVertical( + lipgloss.Left, + fmt.Sprintf("Worker: %s", m.continueForm.parentWorkerID), + lipgloss.JoinHorizontal(lipgloss.Left, "Role: ", roleBadge(m.continueForm.parentRole)), + fmt.Sprintf("Status: %s", parentStatus), + lipgloss.JoinHorizontal(lipgloss.Left, + "Session: ", + lipgloss.NewStyle().Foreground(colorLightBlue).Render(m.continueForm.sessionID), + ), + ) + + helpText := lipgloss.NewStyle().Foreground(colorMidGray).Render("tab/S-tab field enter submit esc cancel") + content := lipgloss.JoinVertical( + lipgloss.Left, + dialogHeaderStyle.Render("Continue Session"), + "", + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Parent Worker"), + meta, + "", + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Follow-up Message"), + m.continueForm.followUp.View(), + "", + helpText, + ) + + dialog := dialogStyle.Width(70).Render(content) + return m.renderWithBackdrop(dialog) +} + +func (m *Model) renderQuitConfirm() string { + running := m.runningWorkersCount() + body := fmt.Sprintf("%d workers are still running. They will be terminated.", running) + + forceStyle := inactiveButtonStyle + cancelStyle := inactiveButtonStyle + if m.quitConfirmFocused == 0 { + forceStyle = alertButtonStyle + } else { + cancelStyle = activeButtonStyle + } + + buttons := lipgloss.JoinHorizontal( + lipgloss.Left, + forceStyle.Render("Force Quit"), + " ", + cancelStyle.Render("Cancel"), + ) + + header := lipgloss.NewStyle().Foreground(colorOrange).Bold(true).Render("⚠ Quit kasmos?") + helpText := lipgloss.NewStyle().Foreground(colorMidGray).Render("left/right or tab switch enter select esc cancel") + content := lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.NewStyle().Foreground(colorLightGray).Render(body), + "", + buttons, + "", + helpText, + ) + + dialog := alertDialogStyle.Width(64).Render(content) + return m.renderWithBackdrop(dialog) +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go index c43dbd6..15f18d9 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -57,6 +57,9 @@ func (m *Model) renderViewport() string { title := "Output" if selected := m.selectedWorker(); selected != nil { title = fmt.Sprintf("Output: %s %s", selected.ID, selected.Role) + if selected.ParentID != "" { + title = fmt.Sprintf("%s <- %s", title, selected.ParentID) + } } content := lipgloss.JoinVertical( @@ -71,6 +74,72 @@ func (m *Model) renderViewport() string { Render(content) } +func workerTreeRows(workers []*worker.Worker) ([]*worker.Worker, map[string]string) { + if len(workers) == 0 { + return nil, map[string]string{} + } + + byID := make(map[string]*worker.Worker, len(workers)) + children := make(map[string][]*worker.Worker, len(workers)) + roots := make([]*worker.Worker, 0, len(workers)) + for _, w := range workers { + byID[w.ID] = w + } + for _, w := range workers { + if w.ParentID == "" || byID[w.ParentID] == nil { + roots = append(roots, w) + continue + } + children[w.ParentID] = append(children[w.ParentID], w) + } + + ordered := make([]*worker.Worker, 0, len(workers)) + prefixes := make(map[string]string, len(workers)) + visited := make(map[string]bool, len(workers)) + + var walk func(node *worker.Worker, depth int, ancestorHasNext []bool, isLast bool) + walk = func(node *worker.Worker, depth int, ancestorHasNext []bool, isLast bool) { + if node == nil || visited[node.ID] { + return + } + visited[node.ID] = true + + if depth > 0 { + var b strings.Builder + for i := 0; i < depth-1; i++ { + if ancestorHasNext[i] { + b.WriteString("│ ") + } else { + b.WriteString(" ") + } + } + if isLast { + b.WriteString("└─") + } else { + b.WriteString("├─") + } + prefixes[node.ID] = b.String() + } + + ordered = append(ordered, node) + next := children[node.ID] + for i, child := range next { + walk(child, depth+1, append(ancestorHasNext, i < len(next)-1), i == len(next)-1) + } + } + + for i, root := range roots { + walk(root, 0, nil, i == len(roots)-1) + } + for _, w := range workers { + if !visited[w.ID] { + walk(w, 0, nil, true) + } + } + + return ordered, prefixes +} + func (m *Model) renderStatusBar() string { counts := m.workerCounts() left := fmt.Sprintf(" %s %d running %s %d done %s %d failed %s %d killed %s %d pending", diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 307fc60..5cdf839 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -14,6 +14,7 @@ import ( "github.com/lucasb-eyer/go-colorful" "github.com/muesli/gamut" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -257,25 +258,15 @@ func statusIndicator(state worker.WorkerState, exitCode int) string { } } -type TaskState int - -const ( - TaskUnassigned TaskState = iota - TaskBlocked - TaskInProgress - TaskDone - TaskFailed -) - -func taskStatusBadge(state TaskState, blockingDep string) string { +func taskStatusBadge(state task.TaskState, blockingDep string) string { switch state { - case TaskDone: + case task.TaskDone: return lipgloss.NewStyle().Foreground(colorDone).Render("✓ done") - case TaskInProgress: + case task.TaskInProgress: return lipgloss.NewStyle().Foreground(colorRunning).Render("⟳ in-progress") - case TaskBlocked: + case task.TaskBlocked: return lipgloss.NewStyle().Foreground(colorOrange).Render(fmt.Sprintf("⊘ blocked (%s)", blockingDep)) - case TaskFailed: + case task.TaskFailed: return lipgloss.NewStyle().Foreground(colorFailed).Render("✗ failed") default: return lipgloss.NewStyle().Foreground(colorPending).Render("○ unassigned") diff --git a/internal/tui/update.go b/internal/tui/update.go index 66301cd..1e1b799 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -9,11 +9,20 @@ import ( "github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/table" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" "github.com/user/kasmos/internal/worker" ) func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.showContinueDialog { + return m.updateContinueDialog(msg) + } + + if m.showQuitConfirm { + return m.updateQuitConfirm(msg) + } + if m.showSpawnDialog { return m.updateSpawnDialog(msg) } @@ -39,10 +48,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) case tea.KeyMsg: - if key.Matches(msg, m.keys.ForceQuit, m.keys.Quit) { + if key.Matches(msg, m.keys.ForceQuit) { return m, tea.Quit } + if key.Matches(msg, m.keys.Quit) { + running := m.runningWorkersCount() + if running == 0 { + return m, tea.Quit + } + m.showQuitConfirm = true + m.quitConfirmFocused = 1 + m.updateKeyStates() + return m, nil + } + if key.Matches(msg, m.keys.Help) { m.showHelp = !m.showHelp return m, nil @@ -75,6 +95,32 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.openSpawnDialog() } + if key.Matches(msg, m.keys.Continue) { + selected := m.selectedWorker() + if selected != nil && + (selected.State == worker.StateExited || selected.State == worker.StateFailed) && + selected.SessionID != "" { + return m, m.openContinueDialog(selected) + } + return m, nil + } + + if key.Matches(msg, m.keys.Kill) { + selected := m.selectedWorker() + if selected != nil && selected.State == worker.StateRunning && selected.Handle != nil { + return m, killWorkerCmd(selected.ID, selected.Handle, 3*time.Second) + } + return m, nil + } + + if key.Matches(msg, m.keys.Restart) { + selected := m.selectedWorker() + if selected != nil && (selected.State == worker.StateFailed || selected.State == worker.StateKilled) { + return m, m.openSpawnDialogWithPrefill(selected.Role, selected.Prompt, selected.Files) + } + return m, nil + } + if key.Matches(msg, m.keys.Up, m.keys.Down) { var cmd tea.Cmd m.table, cmd = m.table.Update(msg) @@ -128,14 +174,56 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.closeSpawnDialog() return m, nil + case continueDialogSubmittedMsg: + parent := m.manager.Get(msg.ParentWorkerID) + if parent == nil { + return m, nil + } + id := m.manager.NextWorkerID() + w := &worker.Worker{ + ID: id, + Role: parent.Role, + Prompt: msg.FollowUp, + ParentID: msg.ParentWorkerID, + State: worker.StateSpawning, + SpawnedAt: time.Now(), + Output: worker.NewOutputBuffer(worker.DefaultMaxLines), + } + m.manager.Add(w) + m.workers = m.manager.All() + m.selectedWorkerID = w.ID + m.refreshTableRows() + m.refreshViewportFromSelected(true) + + cfg := worker.SpawnConfig{ + ID: w.ID, + Role: w.Role, + Prompt: msg.FollowUp, + ContinueSession: msg.SessionID, + } + return m, spawnWorkerCmd(m.backend, cfg) + + case continueDialogCancelledMsg: + m.closeContinueDialog() + return m, nil + + case quitConfirmedMsg: + return m, tea.Quit + + case quitCancelledMsg: + m.showQuitConfirm = false + m.updateKeyStates() + return m, nil + case workerSpawnedMsg: w := m.manager.Get(msg.WorkerID) if w == nil { return m, nil } - if err := w.Transition(worker.StateRunning); err != nil { - w.State = worker.StateRunning - } + // Force to running — the transition may fail if the worker was already + // in an unexpected state (e.g., killed during spawn), but we trust the + // backend's spawned confirmation. + w.State = worker.StateRunning w.Handle = msg.Handle if w.SpawnedAt.IsZero() { w.SpawnedAt = time.Now() @@ -219,15 +307,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) refreshTableRows() { m.workers = m.manager.All() - rows := make([]table.Row, 0, len(m.workers)) + ordered, prefixes := workerTreeRows(m.workers) + m.tableRowWorkerIDs = make([]string, 0, len(ordered)) + rows := make([]table.Row, 0, len(ordered)) withTask := len(m.workerTableColumns()) == 5 - for _, w := range m.workers { + treePrefixStyle := lipgloss.NewStyle().Foreground(colorMidGray).Faint(true) + for _, w := range ordered { status := statusIndicator(w.State, w.ExitCode) if w.State == worker.StateRunning { status = m.spinner.View() + " running" } - row := table.Row{w.ID, status, roleBadge(w.Role), w.FormatDuration()} + idLabel := w.ID + if prefix := prefixes[w.ID]; prefix != "" { + idLabel = treePrefixStyle.Render(prefix) + w.ID + } + + row := table.Row{idLabel, status, roleBadge(w.Role), w.FormatDuration()} if withTask { task := w.TaskID if task == "" { @@ -236,9 +332,18 @@ func (m *Model) refreshTableRows() { row = append(row, task) } rows = append(rows, row) + m.tableRowWorkerIDs = append(m.tableRowWorkerIDs, w.ID) } m.table.SetRows(rows) + if m.selectedWorkerID != "" { + for i, id := range m.tableRowWorkerIDs { + if id == m.selectedWorkerID { + m.table.SetCursor(i) + break + } + } + } m.syncSelectionFromTable() } @@ -257,12 +362,14 @@ func (m *Model) syncSelectionFromTable() { cursor = len(rows) - 1 m.table.SetCursor(cursor) } - if len(rows[cursor]) == 0 { + if cursor < 0 || cursor >= len(m.tableRowWorkerIDs) { m.selectedWorkerID = "" + m.updateKeyStates() return } - m.selectedWorkerID = fmt.Sprintf("%v", rows[cursor][0]) + m.selectedWorkerID = m.tableRowWorkerIDs[cursor] + m.updateKeyStates() } func (m *Model) refreshViewportFromSelected(autoFollow bool) { @@ -271,7 +378,17 @@ func (m *Model) refreshViewportFromSelected(autoFollow bool) { m.setViewportContent(welcomeViewportText(), false) return } - m.setViewportContent(w.Output.Content(), autoFollow) + content := w.Output.Content() + if w.ParentID != "" { + parentRole := "unknown" + if parent := m.manager.Get(w.ParentID); parent != nil { + parentRole = parent.Role + } + line := lipgloss.NewStyle().Foreground(colorMidGray).Faint(true). + Render(fmt.Sprintf("← continued from %s (%s)", w.ParentID, parentRole)) + content = line + "\n" + content + } + m.setViewportContent(content, autoFollow) } func (m *Model) setViewportContent(content string, autoFollow bool) { @@ -288,3 +405,13 @@ func (m *Model) selectedWorker() *worker.Worker { } return m.manager.Get(m.selectedWorkerID) } + +func (m *Model) runningWorkersCount() int { + count := 0 + for _, w := range m.manager.All() { + if w.State == worker.StateRunning { + count++ + } + } + return count +} diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md index 8c59dc4..03cab2a 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md @@ -1,26 +1,31 @@ --- -work_package_id: "WP05" -title: "Continue Dialog + Overlays + Worker Chains" -lane: "planned" +work_package_id: WP05 +title: Continue Dialog + Overlays + Worker Chains +lane: doing dependencies: - - "WP04" +- WP04 subtasks: - - "Continue session dialog (huh form + parent info)" - - "Quit confirmation dialog" - - "Worker continuation chains (ParentID, tree glyphs)" - - "Viewport title shows chain reference" - - "Update key handlers for c (continue)" -phase: "Wave 1 - Core TUI + Worker Lifecycle" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- Continue session dialog (huh form + parent info) +- Quit confirmation dialog +- Worker continuation chains (ParentID, tree glyphs) +- Viewport title shows chain reference +- Update key handlers for c (continue) +phase: Wave 1 - Core TUI + Worker Lifecycle +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:12:27.565191687+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP05 coder - continue dialog + overlays + worker chains) --- # Work Package Prompt: WP05 - Continue Dialog + Overlays + Worker Chains diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md index d3b1214..8d01a89 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md @@ -1,25 +1,30 @@ --- -work_package_id: "WP07" -title: "Kill + Restart Workers" -lane: "planned" +work_package_id: WP07 +title: Kill + Restart Workers +lane: doing dependencies: - - "WP04" +- WP04 subtasks: - - "Kill worker handler (x key -> SIGTERM -> workerKilledMsg)" - - "Restart worker handler (r key -> spawn dialog pre-filled)" - - "Worker state transitions for killed/restarted" - - "Kill confirmation for running workers" -phase: "Wave 2 - Task Sources + Worker Management" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- Kill worker handler (x key -> SIGTERM -> workerKilledMsg) +- Restart worker handler (r key -> spawn dialog pre-filled) +- Worker state transitions for killed/restarted +- Kill confirmation for running workers +phase: Wave 2 - Task Sources + Worker Management +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:12:28.785022125+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP07 coder - kill + restart workers) --- # Work Package Prompt: WP07 - Kill + Restart Workers diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md index ed347f7..411668c 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md @@ -1,27 +1,32 @@ --- -work_package_id: "WP08" -title: "Task Source Framework + Adapters" -lane: "planned" +work_package_id: WP08 +title: Task Source Framework + Adapters +lane: doing dependencies: - - "WP02" +- WP02 subtasks: - - "internal/task/source.go - Source interface, Task struct, TaskState enum" - - "internal/task/speckitty.go - SpecKittySource (YAML frontmatter parser)" - - "internal/task/gsd.go - GsdSource (checkbox markdown parser)" - - "internal/task/adhoc.go - AdHocSource (empty/noop)" - - "CLI argument parsing: detect source type from path" - - "Unit tests for all adapters" -phase: "Wave 2 - Task Sources + Worker Management" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- internal/task/source.go - Source interface, Task struct, TaskState enum +- internal/task/speckitty.go - SpecKittySource (YAML frontmatter parser) +- internal/task/gsd.go - GsdSource (checkbox markdown parser) +- internal/task/adhoc.go - AdHocSource (empty/noop) +- 'CLI argument parsing: detect source type from path' +- Unit tests for all adapters +phase: Wave 2 - Task Sources + Worker Management +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:12:30.527335431+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP08 coder - task source framework + adapters) --- # Work Package Prompt: WP08 - Task Source Framework + Adapters From 6c6babd46c0d091be1df72b6f6d685a23813254e Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 08:30:55 -0600 Subject: [PATCH 10/26] feat(016): WP06 fullscreen viewport + shutdown, WP10 setup command WP06: Fullscreen viewport mode (f key toggle), auto-follow logic, vim-style scroll controls (j/k/d/u/G/g), 4-phase key routing refactor, context-dependent key activation, graceful shutdown (kill all running workers on quit confirm). WP10: kasmos setup subcommand with dependency validation (opencode, git), agent definition scaffolding (.opencode/agents/ with planner, coder, reviewer, release templates), idempotent file creation, project root detection, full test suite. --- cmd/kasmos/main.go | 10 + internal/setup/agents.go | 159 +++++++++++++ internal/setup/deps.go | 36 +++ internal/setup/deps_test.go | 31 +++ internal/setup/setup.go | 73 ++++++ internal/setup/setup_test.go | 53 +++++ internal/tui/keys.go | 48 ++-- internal/tui/model.go | 19 ++ internal/tui/panels.go | 88 +++++++ internal/tui/update.go | 214 ++++++++++++++---- .../tasks/WP05-spawn-continue-dialogs.md | 7 +- .../tasks/WP06-output-viewport.md | 49 ++-- .../tasks/WP07-kill-restart.md | 7 +- .../tasks/WP08-task-sources.md | 7 +- .../tasks/WP10-setup-command.md | 43 ++-- 15 files changed, 735 insertions(+), 109 deletions(-) create mode 100644 internal/setup/agents.go create mode 100644 internal/setup/deps.go create mode 100644 internal/setup/deps_test.go create mode 100644 internal/setup/setup.go create mode 100644 internal/setup/setup_test.go diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index fa3bf32..5248278 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/spf13/cobra" + "github.com/user/kasmos/internal/setup" "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/tui" "github.com/user/kasmos/internal/worker" @@ -68,5 +69,14 @@ func newRootCmd() *cobra.Command { cmd.Flags().BoolVar(&showVersion, "version", false, "print version and exit") + setupCmd := &cobra.Command{ + Use: "setup", + Short: "Validate dependencies and scaffold agent configurations", + RunE: func(cmd *cobra.Command, args []string) error { + return setup.Run() + }, + } + cmd.AddCommand(setupCmd) + return cmd } diff --git a/internal/setup/agents.go b/internal/setup/agents.go new file mode 100644 index 0000000..a61f27f --- /dev/null +++ b/internal/setup/agents.go @@ -0,0 +1,159 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" +) + +type AgentDef struct { + Filename string + Content string +} + +var agentDefinitions = []AgentDef{ + {Filename: "planner.md", Content: plannerTemplate}, + {Filename: "coder.md", Content: coderTemplate}, + {Filename: "reviewer.md", Content: reviewerTemplate}, + {Filename: "release.md", Content: releaseTemplate}, +} + +func WriteAgentDefinitions(dir string) (created, skipped int, err error) { + agentDir := filepath.Join(dir, ".opencode", "agents") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + return 0, 0, fmt.Errorf("create agent dir: %w", err) + } + + for _, agent := range agentDefinitions { + path := filepath.Join(agentDir, agent.Filename) + if _, err := os.Stat(path); err == nil { + skipped++ + continue + } else if !os.IsNotExist(err) { + return created, skipped, fmt.Errorf("stat %s: %w", agent.Filename, err) + } + + if err := os.WriteFile(path, []byte(agent.Content), 0o644); err != nil { + return created, skipped, fmt.Errorf("write %s: %w", agent.Filename, err) + } + created++ + } + + return created, skipped, nil +} + +const plannerTemplate = `--- +name: planner +description: Research and planning agent for work package preparation +--- + +# Planner Agent + +## Role +- Analyze requirements, constraints, and architecture before coding starts. +- Produce implementation plans, milestones, and risk lists. +- Keep scope aligned with the selected work package. + +## Capabilities +- Read repository files and related documentation. +- Compare options and recommend a concrete path. +- Define acceptance checks and verification steps. + +## Constraints +- Read-only filesystem behavior: do not edit source files. +- Do not run destructive commands or change git history. +- Do not implement code changes directly. + +## Deliverables +1. Problem statement with assumptions and unknowns. +2. Step-by-step implementation plan. +3. Test and validation strategy. +4. Risks, dependencies, and handoff notes for coder. +` + +const coderTemplate = `--- +name: coder +description: Implementation agent for building and testing changes +--- + +# Coder Agent + +## Role +- Implement approved plans with robust, production-ready code. +- Keep changes scoped, readable, and maintainable. +- Update or add tests for new behavior. + +## Capabilities +- Full tool access for editing files and running commands. +- Build, test, and verify code using project workflows. +- Refactor related code when needed to keep quality high. + +## Constraints +- Follow existing repository conventions and architecture. +- Fail fast on invalid states and surface clear errors. +- Avoid unrelated edits and never commit secrets. + +## Done Criteria +1. Code compiles and tests pass for touched areas. +2. New behavior is covered by tests. +3. Notes include what changed and why. +4. Work is ready for reviewer handoff. +` + +const reviewerTemplate = `--- +name: reviewer +description: Review agent focused on correctness, security, and quality +--- + +# Reviewer Agent + +## Role +- Audit implementation for correctness and hidden regressions. +- Verify behavior against requirements and acceptance criteria. +- Confirm code clarity, maintainability, and safety. + +## Capabilities +- Read repository files, diffs, and test output. +- Run non-destructive validation commands and test suites. +- Identify edge cases and missing coverage. + +## Constraints +- Read-only review posture: do not modify source files. +- Block approval when critical issues are unresolved. +- Prioritize correctness, security, and reliability over speed. + +## Review Output +1. Pass/fail recommendation with rationale. +2. Findings grouped by severity. +3. Required fixes before merge. +4. Optional improvements for follow-up work. +` + +const releaseTemplate = `--- +name: release +description: Finalization agent for merge, cleanup, and handoff +--- + +# Release Agent + +## Role +- Finalize approved work and prepare release-ready state. +- Coordinate branch hygiene, merge steps, and closure tasks. +- Ensure final documentation and notes are complete. + +## Capabilities +- Run final verification commands and status checks. +- Perform non-destructive git operations for integration. +- Prepare concise release notes and completion summary. + +## Constraints +- Respect repository policies and protected branch rules. +- Avoid force pushes and unsafe history rewriting. +- Stop and report if unresolved blockers remain. + +## Final Checklist +1. Review findings are resolved. +2. Required tests and checks are green. +3. Merge/finalization steps are documented. +4. Cleanup and follow-up actions are recorded. +` diff --git a/internal/setup/deps.go b/internal/setup/deps.go new file mode 100644 index 0000000..b7d19d0 --- /dev/null +++ b/internal/setup/deps.go @@ -0,0 +1,36 @@ +package setup + +import "os/exec" + +type DepResult struct { + Name string + Found bool + Path string + Required bool + InstallHint string +} + +func CheckDependencies() []DepResult { + checks := []struct { + name string + required bool + hint string + }{ + {"opencode", true, "go install github.com/anomalyco/opencode@latest"}, + {"git", true, "install via system package manager"}, + } + + results := make([]DepResult, 0, len(checks)) + for _, c := range checks { + path, err := exec.LookPath(c.name) + results = append(results, DepResult{ + Name: c.name, + Found: err == nil, + Path: path, + Required: c.required, + InstallHint: c.hint, + }) + } + + return results +} diff --git a/internal/setup/deps_test.go b/internal/setup/deps_test.go new file mode 100644 index 0000000..ae4d1aa --- /dev/null +++ b/internal/setup/deps_test.go @@ -0,0 +1,31 @@ +package setup + +import "testing" + +func TestCheckDependencies(t *testing.T) { + t.Parallel() + + results := CheckDependencies() + if len(results) != 2 { + t.Fatalf("expected 2 dependency results, got %d", len(results)) + } + + seen := map[string]DepResult{} + for _, r := range results { + if r.Name == "" { + t.Fatal("dependency name must not be empty") + } + if r.InstallHint == "" { + t.Fatalf("install hint must not be empty for %q", r.Name) + } + seen[r.Name] = r + } + + gitResult, ok := seen["git"] + if !ok { + t.Fatal("expected git dependency in results") + } + if !gitResult.Found { + t.Skip("git not available in this test environment") + } +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..f8e9d8c --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,73 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" +) + +func Run() error { + fmt.Println("kasmos setup") + fmt.Println() + + fmt.Println("Checking dependencies...") + results := CheckDependencies() + allFound := true + for _, r := range results { + if r.Found { + fmt.Printf(" [OK] %-12s found (%s)\n", r.Name, r.Path) + continue + } + + fmt.Printf(" [MISSING] %-12s NOT FOUND\n", r.Name) + if r.InstallHint != "" { + fmt.Printf(" Install: %s\n", r.InstallHint) + } + if r.Required { + allFound = false + } + } + fmt.Println() + + if !allFound { + return fmt.Errorf("required dependencies missing") + } + + root, err := findProjectRoot() + if err != nil { + return fmt.Errorf("find project root: %w", err) + } + + fmt.Println("Scaffolding agent definitions...") + created, skipped, err := WriteAgentDefinitions(root) + if err != nil { + return fmt.Errorf("write agents: %w", err) + } + fmt.Printf(" %d created, %d skipped (already exist)\n", created, skipped) + fmt.Println() + fmt.Println("Setup complete!") + + return nil +} + +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("no go.mod or .git found in parent directories") + } + dir = parent + } +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..9d7f1b0 --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,53 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteAgentDefinitions(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + created, skipped, err := WriteAgentDefinitions(tempDir) + if err != nil { + t.Fatalf("first write failed: %v", err) + } + if created != len(agentDefinitions) { + t.Fatalf("expected %d files created, got %d", len(agentDefinitions), created) + } + if skipped != 0 { + t.Fatalf("expected 0 skipped on first run, got %d", skipped) + } + + agentDir := filepath.Join(tempDir, ".opencode", "agents") + if info, err := os.Stat(agentDir); err != nil { + t.Fatalf("expected agent directory to exist: %v", err) + } else if !info.IsDir() { + t.Fatalf("expected %s to be a directory", agentDir) + } + + for _, def := range agentDefinitions { + path := filepath.Join(agentDir, def.Filename) + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected file %s: %v", def.Filename, err) + } + if len(content) == 0 { + t.Fatalf("expected non-empty content for %s", def.Filename) + } + } + + created, skipped, err = WriteAgentDefinitions(tempDir) + if err != nil { + t.Fatalf("second write failed: %v", err) + } + if created != 0 { + t.Fatalf("expected 0 files created on second run, got %d", created) + } + if skipped != len(agentDefinitions) { + t.Fatalf("expected %d skipped on second run, got %d", len(agentDefinitions), skipped) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 09cd668..2832f18 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -147,7 +147,8 @@ func defaultKeyMap() keyMap { func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{ k.Spawn, k.Kill, k.Continue, k.Restart, - k.GenPrompt, k.Analyze, k.Fullscreen, + k.Fullscreen, k.ScrollDown, k.ScrollUp, + k.GotoBottom, k.GotoTop, k.NextPanel, k.Help, k.Quit, } } @@ -162,35 +163,52 @@ func (k keyMap) FullHelp() [][]key.Binding { } func (m *Model) updateKeyStates() { - selected := m.selectedWorker() - + // Always enabled m.keys.Spawn.SetEnabled(true) m.keys.Help.SetEnabled(true) m.keys.Quit.SetEnabled(true) m.keys.ForceQuit.SetEnabled(true) - m.keys.NextPanel.SetEnabled(true) - m.keys.PrevPanel.SetEnabled(true) + m.keys.NextPanel.SetEnabled(!m.fullScreen) + m.keys.PrevPanel.SetEnabled(!m.fullScreen) m.keys.Up.SetEnabled(true) m.keys.Down.SetEnabled(true) - m.keys.ScrollDown.SetEnabled(true) - m.keys.ScrollUp.SetEnabled(true) m.keys.Back.SetEnabled(true) + selected := m.selectedWorker() + + // Worker action keys m.keys.Kill.SetEnabled(selected != nil && selected.State == worker.StateRunning) m.keys.Continue.SetEnabled(selected != nil && (selected.State == worker.StateExited || selected.State == worker.StateFailed) && selected.SessionID != "") m.keys.Restart.SetEnabled(selected != nil && (selected.State == worker.StateFailed || selected.State == worker.StateKilled)) - m.keys.Batch.SetEnabled(false) - m.keys.Fullscreen.SetEnabled(false) - m.keys.HalfDown.SetEnabled(false) - m.keys.HalfUp.SetEnabled(false) - m.keys.GotoBottom.SetEnabled(false) - m.keys.GotoTop.SetEnabled(false) + + // Viewport keys + m.keys.Fullscreen.SetEnabled(selected != nil) + viewportActive := m.focused == panelViewport || m.fullScreen + m.keys.ScrollDown.SetEnabled(viewportActive) + m.keys.ScrollUp.SetEnabled(viewportActive) + m.keys.HalfDown.SetEnabled(viewportActive) + m.keys.HalfUp.SetEnabled(viewportActive) + m.keys.GotoBottom.SetEnabled(viewportActive) + m.keys.GotoTop.SetEnabled(viewportActive) m.keys.Search.SetEnabled(false) - m.keys.GenPrompt.SetEnabled(false) + + // g key conflict: GotoTop in viewport, GenPrompt in table + if viewportActive { + m.keys.GotoTop.SetEnabled(true) + m.keys.GenPrompt.SetEnabled(false) + } else { + m.keys.GotoTop.SetEnabled(false) + m.keys.GenPrompt.SetEnabled(false) + } + + // AI helpers (disabled until WP11) m.keys.Analyze.SetEnabled(false) + + // Task panel keys (disabled until WP09) + m.keys.Batch.SetEnabled(false) m.keys.Filter.SetEnabled(false) - m.keys.Select.SetEnabled(false) + m.keys.Select.SetEnabled(m.focused == panelTable && selected != nil) } diff --git a/internal/tui/model.go b/internal/tui/model.go index 62d8f78..fad6f6a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -23,6 +23,8 @@ type Model struct { focused panel layoutMode layoutMode showHelp bool + fullScreen bool + autoFollow bool keys keyMap help help.Model @@ -123,6 +125,23 @@ func (m *Model) View() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, body) } + if m.fullScreen { + view := m.renderFullScreen() + if m.showHelp { + return m.renderHelpOverlay() + } + if m.showContinueDialog { + return m.renderContinueDialog() + } + if m.showQuitConfirm { + return m.renderQuitConfirm() + } + if m.showSpawnDialog { + return m.renderSpawnDialog() + } + return view + } + var content string switch m.layoutMode { case layoutNarrow: diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 15f18d9..c3c1ab2 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -74,6 +74,94 @@ func (m *Model) renderViewport() string { Render(content) } +func (m *Model) renderFullScreen() string { + contentHeight := max(0, m.height-m.chromeHeight()) + const ( + borderH = 4 + borderV = 2 + ) + + vpInnerWidth := max(1, m.width-borderH) + vpInnerHeight := max(1, contentHeight-borderV) + m.viewport.SetWidth(vpInnerWidth) + m.viewport.SetHeight(max(1, vpInnerHeight-1)) + + title := "Output" + if selected := m.selectedWorker(); selected != nil { + title = fmt.Sprintf("Output: %s %s - %s", selected.ID, selected.Role, truncateMiddle(strings.TrimSpace(selected.Prompt), 40)) + } + + viewportPanel := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPurple). + Padding(0, 1). + Width(vpInnerWidth). + Height(vpInnerHeight). + Render(lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render(title), + m.viewport.View(), + )) + + view := lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + viewportPanel, + m.renderFullScreenStatusBar(), + m.renderHelpBar(), + ) + + return view +} + +func (m *Model) renderFullScreenStatusBar() string { + selected := m.selectedWorker() + if selected == nil { + line := " - - - exit(-) duration: - session: - scroll: - " + return statusBarStyle.Width(m.width).Render(line) + } + + session := selected.SessionID + if session == "" { + session = "-" + } + + exit := "-" + if selected.State == worker.StateExited || selected.State == worker.StateFailed { + exit = fmt.Sprintf("%d", selected.ExitCode) + } + + scroll := "-" + if m.viewport.TotalLineCount() > 0 { + scroll = fmt.Sprintf("%.0f%%", m.viewport.ScrollPercent()*100) + } + + line := fmt.Sprintf(" %s %s %s exit(%s) duration: %s session: %s scroll: %s ", + selected.ID, + selected.Role, + selected.State, + exit, + selected.FormatDuration(), + session, + scroll, + ) + + return statusBarStyle.Width(m.width).Render(line) +} + +func truncateMiddle(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } + if len([]rune(s)) <= maxLen { + return s + } + if maxLen <= 3 { + return strings.Repeat(".", maxLen) + } + return string([]rune(s)[:maxLen-3]) + "..." +} + func workerTreeRows(workers []*worker.Worker) ([]*worker.Worker, map[string]string) { if len(workers) == 0 { return nil, map[string]string{} diff --git a/internal/tui/update.go b/internal/tui/update.go index 1e1b799..992cdb1 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -36,7 +36,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ready = true prev := m.layoutMode - m.recalculateLayout() + if m.fullScreen { + m.resizeFullScreenViewport() + } else { + m.recalculateLayout() + } m.refreshTableRows() m.refreshViewportFromSelected(false) if prev != m.layoutMode { @@ -48,6 +52,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) case tea.KeyMsg: + // Phase 1: Global keys if key.Matches(msg, m.keys.ForceQuit) { return m, tea.Quit } @@ -79,67 +84,32 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - switch { - case key.Matches(msg, m.keys.NextPanel): + if key.Matches(msg, m.keys.NextPanel) { m.cyclePanel(1) m.updateKeyStates() return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } - case key.Matches(msg, m.keys.PrevPanel): + } + + if key.Matches(msg, m.keys.PrevPanel) { m.cyclePanel(-1) m.updateKeyStates() return m, func() tea.Msg { return focusChangedMsg{To: m.focused} } } - if m.focused == panelTable { - if key.Matches(msg, m.keys.Spawn) { - return m, m.openSpawnDialog() - } - - if key.Matches(msg, m.keys.Continue) { - selected := m.selectedWorker() - if selected != nil && - (selected.State == worker.StateExited || selected.State == worker.StateFailed) && - selected.SessionID != "" { - return m, m.openContinueDialog(selected) - } - return m, nil - } - - if key.Matches(msg, m.keys.Kill) { - selected := m.selectedWorker() - if selected != nil && selected.State == worker.StateRunning && selected.Handle != nil { - return m, killWorkerCmd(selected.ID, selected.Handle, 3*time.Second) - } - return m, nil - } - - if key.Matches(msg, m.keys.Restart) { - selected := m.selectedWorker() - if selected != nil && (selected.State == worker.StateFailed || selected.State == worker.StateKilled) { - return m, m.openSpawnDialogWithPrefill(selected.Role, selected.Prompt, selected.Files) - } - return m, nil - } - - if key.Matches(msg, m.keys.Up, m.keys.Down) { - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - m.syncSelectionFromTable() - m.refreshViewportFromSelected(false) - return m, cmd - } + // Phase 2: Fullscreen keys + if m.fullScreen { + return m.updateFullScreenKeys(msg) } - var cmd tea.Cmd + // Phase 3: Panel-specific keys switch m.focused { case panelTable: - m.table, cmd = m.table.Update(msg) - m.syncSelectionFromTable() - m.refreshViewportFromSelected(false) + return m.updateTableKeys(msg) case panelViewport: - m.viewport, cmd = m.viewport.Update(msg) + return m.updateViewportKeys(msg) + default: + return m, nil } - return m, cmd case spawnDialogSubmittedMsg: role := strings.TrimSpace(msg.Role) @@ -208,6 +178,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case quitConfirmedMsg: + for _, w := range m.manager.All() { + if w.State == worker.StateRunning && w.Handle != nil { + _ = w.Handle.Kill(3 * time.Second) + } + } return m, tea.Quit case quitCancelledMsg: @@ -392,13 +367,152 @@ func (m *Model) refreshViewportFromSelected(autoFollow bool) { } func (m *Model) setViewportContent(content string, autoFollow bool) { - atBottom := m.viewport.AtBottom() + wasAtBottom := m.viewport.AtBottom() m.viewport.SetContent(content) - if autoFollow && atBottom { + if autoFollow && (wasAtBottom || m.autoFollow) { + m.viewport.GotoBottom() + m.autoFollow = true + } +} + +func (m *Model) updateFullScreenKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, m.keys.Back) { + m.fullScreen = false + m.recalculateLayout() + m.updateKeyStates() + return m, nil + } + + if key.Matches(msg, m.keys.Continue) { + selected := m.selectedWorker() + if selected != nil && + (selected.State == worker.StateExited || selected.State == worker.StateFailed) && + selected.SessionID != "" { + return m, m.openContinueDialog(selected) + } + return m, nil + } + + if key.Matches(msg, m.keys.Restart) { + selected := m.selectedWorker() + if selected != nil && (selected.State == worker.StateFailed || selected.State == worker.StateKilled) { + return m, m.openSpawnDialogWithPrefill(selected.Role, selected.Prompt, selected.Files) + } + return m, nil + } + + return m.updateViewportScrollKeys(msg) +} + +func (m *Model) updateTableKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, m.keys.Spawn) { + return m, m.openSpawnDialog() + } + + if key.Matches(msg, m.keys.Continue) { + selected := m.selectedWorker() + if selected != nil && + (selected.State == worker.StateExited || selected.State == worker.StateFailed) && + selected.SessionID != "" { + return m, m.openContinueDialog(selected) + } + return m, nil + } + + if key.Matches(msg, m.keys.Kill) { + selected := m.selectedWorker() + if selected != nil && selected.State == worker.StateRunning && selected.Handle != nil { + return m, killWorkerCmd(selected.ID, selected.Handle, 3*time.Second) + } + return m, nil + } + + if key.Matches(msg, m.keys.Restart) { + selected := m.selectedWorker() + if selected != nil && (selected.State == worker.StateFailed || selected.State == worker.StateKilled) { + return m, m.openSpawnDialogWithPrefill(selected.Role, selected.Prompt, selected.Files) + } + return m, nil + } + + if key.Matches(msg, m.keys.Fullscreen, m.keys.Select) { + if m.selectedWorker() != nil { + m.fullScreen = true + m.resizeFullScreenViewport() + m.updateKeyStates() + } + return m, nil + } + + if key.Matches(msg, m.keys.Up, m.keys.Down) { + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + m.syncSelectionFromTable() + m.refreshViewportFromSelected(false) + return m, cmd + } + + return m, nil +} + +func (m *Model) updateViewportKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, m.keys.Fullscreen) { + if m.selectedWorker() != nil { + m.fullScreen = true + m.resizeFullScreenViewport() + m.updateKeyStates() + } + return m, nil + } + + return m.updateViewportScrollKeys(msg) +} + +func (m *Model) updateViewportScrollKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.ScrollDown, m.keys.Down): + m.viewport.LineDown(1) + if m.viewport.AtBottom() { + m.autoFollow = true + } + return m, nil + case key.Matches(msg, m.keys.ScrollUp, m.keys.Up): + m.viewport.LineUp(1) + m.autoFollow = false + return m, nil + case key.Matches(msg, m.keys.HalfDown): + m.viewport.HalfViewDown() + if m.viewport.AtBottom() { + m.autoFollow = true + } + return m, nil + case key.Matches(msg, m.keys.HalfUp): + m.viewport.HalfViewUp() + m.autoFollow = false + return m, nil + case key.Matches(msg, m.keys.GotoBottom): m.viewport.GotoBottom() + m.autoFollow = true + return m, nil + case key.Matches(msg, m.keys.GotoTop): + m.viewport.GotoTop() + m.autoFollow = false + return m, nil + default: + return m, nil } } +func (m *Model) resizeFullScreenViewport() { + contentHeight := max(0, m.height-m.chromeHeight()) + const ( + borderH = 4 + borderV = 2 + ) + m.viewport.SetWidth(max(1, m.width-borderH)) + m.viewport.SetHeight(max(1, contentHeight-borderV-1)) +} + func (m *Model) selectedWorker() *worker.Worker { if m.selectedWorkerID == "" { return nil diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md index 03cab2a..0ae76ee 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP05-spawn-continue-dialogs.md @@ -1,7 +1,7 @@ --- work_package_id: WP05 title: Continue Dialog + Overlays + Worker Chains -lane: doing +lane: done dependencies: - WP04 subtasks: @@ -26,6 +26,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP05 coder - continue dialog + overlays + worker chains) +- timestamp: '2026-02-18T14:23:50.653262752+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented and reviewed - continue dialog, quit confirm, worker chains) --- # Work Package Prompt: WP05 - Continue Dialog + Overlays + Worker Chains diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md index 6c3c367..8b7be25 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md @@ -1,29 +1,34 @@ --- -work_package_id: "WP06" -title: "Output Viewport + Fullscreen + Update Dispatch + Shutdown" -lane: "planned" +work_package_id: WP06 +title: Output Viewport + Fullscreen + Update Dispatch + Shutdown +lane: doing dependencies: - - "WP04" - - "WP05" +- WP04 +- WP05 subtasks: - - "Full-screen viewport mode (f key toggle)" - - "Auto-follow logic (track AtBottom, GotoBottom on new content)" - - "Viewport scroll controls (d/u half-page, G bottom, g top)" - - "internal/tui/update.go - Complete 4-phase key routing" - - "Context-dependent key activation (updateKeyStates)" - - "Graceful shutdown protocol (SIGTERM -> SIGKILL -> persist)" - - "Signal handling refinement in main.go" -phase: "Wave 1 - Core TUI + Worker Lifecycle" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- Full-screen viewport mode (f key toggle) +- Auto-follow logic (track AtBottom, GotoBottom on new content) +- Viewport scroll controls (d/u half-page, G bottom, g top) +- internal/tui/update.go - Complete 4-phase key routing +- Context-dependent key activation (updateKeyStates) +- Graceful shutdown protocol (SIGTERM -> SIGKILL -> persist) +- Signal handling refinement in main.go +phase: Wave 1 - Core TUI + Worker Lifecycle +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:24:28.504267429+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP06 coder - output viewport + fullscreen + shutdown) --- # Work Package Prompt: WP06 - Output Viewport + Fullscreen + Update Dispatch + Shutdown diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md index 8d01a89..9356067 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP07-kill-restart.md @@ -1,7 +1,7 @@ --- work_package_id: WP07 title: Kill + Restart Workers -lane: doing +lane: done dependencies: - WP04 subtasks: @@ -25,6 +25,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP07 coder - kill + restart workers) +- timestamp: '2026-02-18T14:23:51.661595646+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented and reviewed - kill/restart workers) --- # Work Package Prompt: WP07 - Kill + Restart Workers diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md index 411668c..f8fe130 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP08-task-sources.md @@ -1,7 +1,7 @@ --- work_package_id: WP08 title: Task Source Framework + Adapters -lane: doing +lane: done dependencies: - WP02 subtasks: @@ -27,6 +27,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP08 coder - task source framework + adapters) +- timestamp: '2026-02-18T14:23:52.903083020+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented and reviewed - task source framework + all 3 adapters) --- # Work Package Prompt: WP08 - Task Source Framework + Adapters diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md index ce834ec..f012792 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md @@ -1,26 +1,31 @@ --- -work_package_id: "WP10" -title: "Setup Command + Agent Scaffolding" -lane: "planned" +work_package_id: WP10 +title: Setup Command + Agent Scaffolding +lane: doing dependencies: - - "WP01" +- WP01 subtasks: - - "internal/setup/setup.go - Setup orchestration" - - "internal/setup/agents.go - Agent definition templates" - - "internal/setup/deps.go - Dependency validation" - - "cmd/kasmos setup.go - Cobra subcommand" - - "Unit tests" -phase: "Wave 2 - Task Sources + Worker Management" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- internal/setup/setup.go - Setup orchestration +- internal/setup/agents.go - Agent definition templates +- internal/setup/deps.go - Dependency validation +- cmd/kasmos setup.go - Cobra subcommand +- Unit tests +phase: Wave 2 - Task Sources + Worker Management +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:24:29.725046729+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP10 coder - setup command + agent scaffolding) --- # Work Package Prompt: WP10 - Setup Command + Agent Scaffolding From 5e5971db4ca2e36cd757ada818a95124bedd279d Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 08:39:14 -0600 Subject: [PATCH 11/26] feat(016): WP09 task panel + batch spawn, WP11 AI helpers WP09: Real task panel rendering with selection, status badges, and worker assignment display. Batch spawn dialog with checkbox selection. Worker-task association on spawn (InProgress) and exit (Done/Failed). Dependency re-resolution on task completion. Task counts in status bar. WP11: On-demand AI helpers - failure analysis (a key) spawns headless reviewer worker to identify root cause + suggest fix. Prompt generation (g key) spawns headless planner to generate implementation prompts. Analysis viewport rendering with structured output. Restart with suggested prompt flow. --- internal/tui/helpers.go | 153 +++++++++ internal/tui/keys.go | 25 +- internal/tui/model.go | 35 ++- internal/tui/overlays.go | 144 +++++++++ internal/tui/panels.go | 144 ++++++++- internal/tui/update.go | 290 ++++++++++++++++++ .../tasks/WP06-output-viewport.md | 7 +- .../tasks/WP09-task-panel-batch.md | 51 +-- .../tasks/WP10-setup-command.md | 7 +- .../tasks/WP11-ai-helpers.md | 47 +-- 10 files changed, 839 insertions(+), 64 deletions(-) create mode 100644 internal/tui/helpers.go diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go new file mode 100644 index 0000000..9f5f77f --- /dev/null +++ b/internal/tui/helpers.go @@ -0,0 +1,153 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + + "github.com/user/kasmos/internal/worker" +) + +func analyzeCmd(backend worker.WorkerBackend, workerID, role string, exitCode int, duration, outputTail string) tea.Cmd { + return func() tea.Msg { + if backend == nil { + return analyzeCompletedMsg{WorkerID: workerID, Err: fmt.Errorf("no backend")} + } + + prompt := fmt.Sprintf(`Analyze this failed agent output and identify the root cause. + +Worker: %s (%s) +Exit code: %d +Duration: %s + +Output (last 200 lines): +%s + +Respond in this exact format: +ROOT_CAUSE: +SUGGESTED_PROMPT: `, workerID, role, exitCode, duration, outputTail) + + cfg := worker.SpawnConfig{ + ID: fmt.Sprintf("analyze-%s", workerID), + Role: "reviewer", + Prompt: prompt, + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + handle, err := backend.Spawn(ctx, cfg) + if err != nil { + return analyzeCompletedMsg{WorkerID: workerID, Err: err} + } + + output := worker.NewOutputBuffer(1000) + stdout := handle.Stdout() + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 4096) + for { + n, readErr := stdout.Read(buf) + if n > 0 { + output.Append(string(buf[:n])) + } + if readErr != nil { + return + } + } + }() + + result := handle.Wait() + <-done + + content := strings.TrimSpace(output.Content()) + if result.Error != nil && content == "" { + return analyzeCompletedMsg{WorkerID: workerID, Err: result.Error} + } + + rootCause, suggestedPrompt := parseAnalysisOutput(content) + return analyzeCompletedMsg{ + WorkerID: workerID, + RootCause: rootCause, + SuggestedPrompt: suggestedPrompt, + } + } +} + +func genPromptCmd(backend worker.WorkerBackend, taskID, title, description, suggestedRole string, deps []string) tea.Cmd { + return func() tea.Msg { + if backend == nil { + return genPromptCompletedMsg{TaskID: taskID, Err: fmt.Errorf("no backend")} + } + + prompt := fmt.Sprintf(`Generate an implementation prompt for this task. + +Task: %s - %s +Description: %s +Dependencies: %s +Suggested role: %s + +Generate a detailed, actionable prompt suitable for an AI coding agent. +The prompt should be specific enough to implement without further clarification.`, taskID, title, description, strings.Join(deps, ", "), suggestedRole) + + cfg := worker.SpawnConfig{ + ID: fmt.Sprintf("genprompt-%s", taskID), + Role: "planner", + Prompt: prompt, + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + handle, err := backend.Spawn(ctx, cfg) + if err != nil { + return genPromptCompletedMsg{TaskID: taskID, Err: err} + } + + output := worker.NewOutputBuffer(1000) + stdout := handle.Stdout() + done := make(chan struct{}) + go func() { + defer close(done) + buf := make([]byte, 4096) + for { + n, readErr := stdout.Read(buf) + if n > 0 { + output.Append(string(buf[:n])) + } + if readErr != nil { + return + } + } + }() + + result := handle.Wait() + <-done + + generated := strings.TrimSpace(output.Content()) + if result.Error != nil && generated == "" { + return genPromptCompletedMsg{TaskID: taskID, Err: result.Error} + } + + return genPromptCompletedMsg{TaskID: taskID, Prompt: generated} + } +} + +func parseAnalysisOutput(output string) (rootCause, suggestedPrompt string) { + if idx := strings.Index(output, "ROOT_CAUSE:"); idx >= 0 { + rest := output[idx+len("ROOT_CAUSE:"):] + if sugIdx := strings.Index(rest, "SUGGESTED_PROMPT:"); sugIdx >= 0 { + rootCause = strings.TrimSpace(rest[:sugIdx]) + suggestedPrompt = strings.TrimSpace(rest[sugIdx+len("SUGGESTED_PROMPT:"):]) + } else { + rootCause = strings.TrimSpace(rest) + } + } else { + rootCause = strings.TrimSpace(output) + } + return rootCause, suggestedPrompt +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2832f18..82ec9e5 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -3,6 +3,7 @@ package tui import ( "github.com/charmbracelet/bubbles/v2/key" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -201,14 +202,26 @@ func (m *Model) updateKeyStates() { m.keys.GenPrompt.SetEnabled(false) } else { m.keys.GotoTop.SetEnabled(false) - m.keys.GenPrompt.SetEnabled(false) + m.keys.GenPrompt.SetEnabled(m.focused == panelTable && m.hasTaskSource() && len(m.loadedTasks) > 0 && !m.genPromptLoading) } - // AI helpers (disabled until WP11) - m.keys.Analyze.SetEnabled(false) + // AI helpers + m.keys.Analyze.SetEnabled(selected != nil && selected.State == worker.StateFailed && !m.analysisLoading) - // Task panel keys (disabled until WP09) - m.keys.Batch.SetEnabled(false) + // Task panel keys + m.keys.Batch.SetEnabled(m.hasTaskSource() && m.hasUnassignedTasks()) m.keys.Filter.SetEnabled(false) - m.keys.Select.SetEnabled(m.focused == panelTable && selected != nil) + m.keys.Select.SetEnabled( + (m.focused == panelTable && selected != nil) || + (m.focused == panelTasks && len(m.loadedTasks) > 0), + ) +} + +func (m *Model) hasUnassignedTasks() bool { + for _, t := range m.loadedTasks { + if t.State == task.TaskUnassigned { + return true + } + } + return false } diff --git a/internal/tui/model.go b/internal/tui/model.go index fad6f6a..c5eae5d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -39,6 +39,9 @@ type Model struct { showSpawnDialog bool spawnForm *spawnDialogModel spawnDraft spawnDialogDraft + showBatchDialog bool + batchSelections []bool + batchFocusedIdx int showContinueDialog bool continueForm *continueDialogModel continueParentID string @@ -48,6 +51,12 @@ type Model struct { selectedWorkerID string tableRowWorkerIDs []string + analysisMode bool + analysisResult *AnalysisResult + analysisWorkerID string + analysisLoading bool + genPromptLoading bool + tableInnerWidth int tableInnerHeight int tableOuterWidth int @@ -61,9 +70,17 @@ type Model struct { tasksOuterWidth int tasksOuterHeight int - taskSourceType string - taskSourcePath string - taskSource task.Source + taskSourceType string + taskSourcePath string + taskSource task.Source + loadedTasks []task.Task + selectedTaskIdx int +} + +type AnalysisResult struct { + WorkerID string + RootCause string + SuggestedPrompt string } func NewModel(backend worker.WorkerBackend, source task.Source) *Model { @@ -99,6 +116,11 @@ func NewModel(backend worker.WorkerBackend, source task.Source) *Model { m.taskSource = source m.taskSourceType = source.Type() m.taskSourcePath = source.Path() + if source.Type() != "ad-hoc" { + if tasks, err := source.Load(); err == nil { + m.loadedTasks = tasks + } + } } m.updateKeyStates() return m @@ -136,6 +158,9 @@ func (m *Model) View() string { if m.showQuitConfirm { return m.renderQuitConfirm() } + if m.showBatchDialog { + return m.renderBatchDialog() + } if m.showSpawnDialog { return m.renderSpawnDialog() } @@ -176,6 +201,10 @@ func (m *Model) View() string { return m.renderQuitConfirm() } + if m.showBatchDialog { + return m.renderBatchDialog() + } + if m.showSpawnDialog { return m.renderSpawnDialog() } diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index 93009d3..207bd72 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -25,6 +26,7 @@ type spawnDialogModel struct { prompt textarea.Model files textinput.Model focusedIdx int + taskID string } type spawnRoleOption struct { @@ -55,6 +57,7 @@ func (m *Model) openSpawnDialog() tea.Cmd { m.showSpawnDialog = true m.spawnDraft = spawnDialogDraft{Role: "coder"} m.spawnForm = newSpawnDialogModel() + m.spawnForm.taskID = "" return m.spawnForm.focusCurrentField() } @@ -62,9 +65,26 @@ func (m *Model) openSpawnDialogWithPrefill(role, prompt string, files []string) m.showSpawnDialog = true m.spawnDraft = spawnDialogDraft{Role: role, Prompt: prompt, Files: strings.Join(files, ", ")} m.spawnForm = newSpawnDialogModelWithPrefill(role, prompt, files) + m.spawnForm.taskID = "" return m.spawnForm.focusCurrentField() } +func (m *Model) openBatchDialog() tea.Cmd { + m.batchSelections = make([]bool, len(m.loadedTasks)) + m.batchFocusedIdx = 0 + m.showBatchDialog = true + if idx := m.firstBatchSelectableIdx(); idx >= 0 { + m.batchFocusedIdx = idx + } + return nil +} + +func (m *Model) closeBatchDialog() { + m.showBatchDialog = false + m.batchSelections = nil + m.batchFocusedIdx = 0 +} + func newSpawnDialogModel() *spawnDialogModel { prompt := styledTextArea() prompt.Placeholder = "Describe the task for this worker" @@ -288,6 +308,7 @@ func (m *Model) updateSpawnDialog(msg tea.Msg) (tea.Model, tea.Cmd) { Role: m.spawnDraft.Role, Prompt: m.spawnDraft.Prompt, Files: parseSpawnFiles(m.spawnDraft.Files), + TaskID: m.spawnForm.taskID, } m.closeSpawnDialog() return m, func() tea.Msg { return submitted } @@ -373,6 +394,129 @@ func (m *Model) renderSpawnDialog() string { return m.renderWithBackdrop(dialog) } +func (m *Model) updateBatchDialog(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if key.Matches(keyMsg, m.keys.Back) { + m.closeBatchDialog() + return m, nil + } + + if len(m.loadedTasks) == 0 { + m.closeBatchDialog() + return m, nil + } + + switch keyMsg.String() { + case "up", "k": + m.moveBatchFocus(-1) + return m, nil + case "down", "j": + m.moveBatchFocus(1) + return m, nil + case " ": + if m.batchFocusedIdx >= 0 && m.batchFocusedIdx < len(m.loadedTasks) && m.loadedTasks[m.batchFocusedIdx].State == task.TaskUnassigned { + m.batchSelections[m.batchFocusedIdx] = !m.batchSelections[m.batchFocusedIdx] + } + return m, nil + case "enter": + cmds := make([]tea.Cmd, 0) + for i, selected := range m.batchSelections { + if !selected || i >= len(m.loadedTasks) { + continue + } + t := m.loadedTasks[i] + if t.State != task.TaskUnassigned { + continue + } + role := t.SuggestedRole + if role == "" { + role = "coder" + } + msg := spawnDialogSubmittedMsg{ + Role: role, + Prompt: strings.TrimSpace(t.Description), + Files: nil, + TaskID: t.ID, + } + cmds = append(cmds, func(m spawnDialogSubmittedMsg) tea.Cmd { + return func() tea.Msg { return m } + }(msg)) + } + m.closeBatchDialog() + if len(cmds) == 0 { + return m, nil + } + return m, tea.Batch(cmds...) + } + + return m, nil +} + +func (m *Model) moveBatchFocus(dir int) { + if len(m.loadedTasks) == 0 { + m.batchFocusedIdx = 0 + return + } + + idx := m.batchFocusedIdx + for range len(m.loadedTasks) { + idx = (idx + dir + len(m.loadedTasks)) % len(m.loadedTasks) + if m.loadedTasks[idx].State == task.TaskUnassigned { + m.batchFocusedIdx = idx + return + } + } +} + +func (m *Model) firstBatchSelectableIdx() int { + for i, t := range m.loadedTasks { + if t.State == task.TaskUnassigned { + return i + } + } + return -1 +} + +func (m *Model) renderBatchDialog() string { + lines := make([]string, 0, len(m.loadedTasks)) + for i, t := range m.loadedTasks { + if t.State != task.TaskUnassigned { + continue + } + check := "[ ]" + if i < len(m.batchSelections) && m.batchSelections[i] { + check = "[x]" + } + style := lipgloss.NewStyle().Foreground(colorLightGray) + if i == m.batchFocusedIdx { + style = style.Foreground(colorPurple).Bold(true) + } + lines = append(lines, style.Render(fmt.Sprintf(" %s %s %s", check, t.ID, t.Title))) + } + + if len(lines) == 0 { + lines = append(lines, lipgloss.NewStyle().Foreground(colorMidGray).Render(" No unassigned tasks available")) + } + + helpText := lipgloss.NewStyle().Foreground(colorMidGray).Render("j/k navigate space toggle enter spawn selected esc cancel") + + content := lipgloss.JoinVertical( + lipgloss.Left, + dialogHeaderStyle.Render("Batch Spawn"), + "", + strings.Join(lines, "\n"), + "", + helpText, + ) + + dialog := dialogStyle.Width(70).Render(content) + return m.renderWithBackdrop(dialog) +} + func (m *Model) renderContinueDialog() string { if m.continueForm == nil { return m.renderWithBackdrop("") diff --git a/internal/tui/panels.go b/internal/tui/panels.go index c3c1ab2..dd46c61 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -55,10 +56,15 @@ func (m *Model) renderViewport() string { } title := "Output" + if m.analysisMode && m.analysisResult != nil { + title = fmt.Sprintf("Analysis: %s", m.analysisResult.WorkerID) + } if selected := m.selectedWorker(); selected != nil { - title = fmt.Sprintf("Output: %s %s", selected.ID, selected.Role) - if selected.ParentID != "" { - title = fmt.Sprintf("%s <- %s", title, selected.ParentID) + if !m.analysisMode { + title = fmt.Sprintf("Output: %s %s", selected.ID, selected.Role) + if selected.ParentID != "" { + title = fmt.Sprintf("%s <- %s", title, selected.ParentID) + } } } @@ -74,6 +80,34 @@ func (m *Model) renderViewport() string { Render(content) } +func (m *Model) renderAnalysisView() string { + if m.analysisResult == nil { + return "" + } + + r := m.analysisResult + dividerWidth := max(1, min(40, m.viewportInnerWidth)) + lines := []string{ + analysisHeaderStyle.Render(fmt.Sprintf("Analysis: %s", r.WorkerID)), + strings.Repeat("-", dividerWidth), + "", + rootCauseLabelStyle.Render("Root Cause:"), + r.RootCause, + } + + if strings.TrimSpace(r.SuggestedPrompt) != "" { + lines = append(lines, + "", + suggestedFixLabelStyle.Render("Suggested Fix:"), + r.SuggestedPrompt, + "", + analysisHintStyle.Render("Press r to restart with suggested prompt"), + ) + } + + return strings.Join(lines, "\n") +} + func (m *Model) renderFullScreen() string { contentHeight := max(0, m.height-m.chromeHeight()) const ( @@ -87,8 +121,13 @@ func (m *Model) renderFullScreen() string { m.viewport.SetHeight(max(1, vpInnerHeight-1)) title := "Output" + if m.analysisMode && m.analysisResult != nil { + title = fmt.Sprintf("Analysis: %s", m.analysisResult.WorkerID) + } if selected := m.selectedWorker(); selected != nil { - title = fmt.Sprintf("Output: %s %s - %s", selected.ID, selected.Role, truncateMiddle(strings.TrimSpace(selected.Prompt), 40)) + if !m.analysisMode { + title = fmt.Sprintf("Output: %s %s - %s", selected.ID, selected.Role, truncateMiddle(strings.TrimSpace(selected.Prompt), 40)) + } } viewportPanel := lipgloss.NewStyle(). @@ -237,6 +276,11 @@ func (m *Model) renderStatusBar() string { warningStyle.Render("☠"), counts.killed, lipgloss.NewStyle().Foreground(colorPending).Render("○"), counts.pending, ) + if m.hasTaskSource() && len(m.loadedTasks) > 0 { + taskCounts := m.taskCounts() + taskInfo := fmt.Sprintf("tasks: %d done . %d active . %d pending", taskCounts.done, taskCounts.active, taskCounts.pending) + left = " " + taskInfo + " |" + left + } scrollStr := "-" if m.focused == panelViewport && m.viewport.TotalLineCount() > 0 { @@ -260,11 +304,47 @@ func (m *Model) renderTasksPanel() string { return "" } - content := lipgloss.JoinVertical( - lipgloss.Left, - lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Tasks"), - lipgloss.NewStyle().Foreground(colorMidGray).Render("No tasks loaded"), - ) + title := lipgloss.NewStyle().Foreground(colorHeader).Bold(true).Render("Tasks") + + if len(m.loadedTasks) == 0 { + empty := lipgloss.NewStyle().Foreground(colorMidGray).Render("No tasks loaded") + content := lipgloss.JoinVertical(lipgloss.Left, title, empty) + return panelStyle(m.focused == panelTasks). + Width(m.tasksInnerWidth). + Height(m.tasksInnerHeight). + Render(content) + } + + selected := m.selectedTaskIdx + if selected < 0 { + selected = 0 + } + if selected >= len(m.loadedTasks) { + selected = len(m.loadedTasks) - 1 + } + m.selectedTaskIdx = selected + + const linesPerTask = 4 + availableLines := max(1, m.tasksInnerHeight-1) + visibleTasks := max(1, availableLines/linesPerTask) + start := selected - visibleTasks/2 + if start < 0 { + start = 0 + } + end := start + visibleTasks + if end > len(m.loadedTasks) { + end = len(m.loadedTasks) + start = max(0, end-visibleTasks) + } + + items := make([]string, 0, end-start) + for i := start; i < end; i++ { + isSelected := m.focused == panelTasks && i == selected + items = append(items, m.renderTaskItem(m.loadedTasks[i], isSelected)) + } + + taskList := strings.Join(items, "\n") + content := lipgloss.JoinVertical(lipgloss.Left, title, taskList) return panelStyle(m.focused == panelTasks). Width(m.tasksInnerWidth). @@ -272,6 +352,31 @@ func (m *Model) renderTasksPanel() string { Render(content) } +func (m *Model) renderTaskItem(t task.Task, selected bool) string { + idStyle := lipgloss.NewStyle().Bold(true) + if selected { + idStyle = idStyle.Foreground(colorPurple) + } + line1 := idStyle.Render(t.ID) + " " + t.Title + line2 := taskStatusBadge(t.State, firstBlockingDep(t)) + + line3 := "" + if t.WorkerID != "" { + line3 = lipgloss.NewStyle().Foreground(colorLightBlue).Render("-> " + t.WorkerID) + } else if t.SuggestedRole != "" { + line3 = lipgloss.NewStyle().Foreground(colorMidGray).Render("role: " + t.SuggestedRole) + } + + return lipgloss.JoinVertical(lipgloss.Left, line1, line2, line3, "") +} + +func firstBlockingDep(t task.Task) string { + if len(t.Dependencies) > 0 { + return t.Dependencies[0] + } + return "" +} + func (m *Model) renderHelpOverlay() string { h := m.help h.ShowAll = true @@ -298,6 +403,12 @@ type workerStateCounts struct { pending int } +type taskStateCounts struct { + done int + active int + pending int +} + func (m *Model) workerCounts() workerStateCounts { counts := workerStateCounts{} for _, w := range m.workers { @@ -316,3 +427,18 @@ func (m *Model) workerCounts() workerStateCounts { } return counts } + +func (m *Model) taskCounts() taskStateCounts { + counts := taskStateCounts{} + for _, t := range m.loadedTasks { + switch t.State { + case task.TaskDone: + counts.done++ + case task.TaskInProgress: + counts.active++ + default: + counts.pending++ + } + } + return counts +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 992cdb1..72e9e38 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -23,6 +24,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateQuitConfirm(msg) } + if m.showBatchDialog { + return m.updateBatchDialog(msg) + } + if m.showSpawnDialog { return m.updateSpawnDialog(msg) } @@ -107,6 +112,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateTableKeys(msg) case panelViewport: return m.updateViewportKeys(msg) + case panelTasks: + return m.updateTaskPanelKeys(msg) default: return m, nil } @@ -136,6 +143,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.refreshTableRows() m.refreshViewportFromSelected(true) + if msg.TaskID != "" { + for i := range m.loadedTasks { + if m.loadedTasks[i].ID == msg.TaskID { + m.loadedTasks[i].State = task.TaskInProgress + m.loadedTasks[i].WorkerID = w.ID + break + } + } + } + m.updateKeyStates() cfg := worker.SpawnConfig{ID: w.ID, Role: w.Role, Prompt: w.Prompt, Files: w.Files} return m, spawnWorkerCmd(m.backend, cfg) @@ -247,8 +264,25 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } w.Handle = nil + if w.TaskID != "" { + for i := range m.loadedTasks { + if m.loadedTasks[i].ID != w.TaskID { + continue + } + if w.State == worker.StateExited { + m.loadedTasks[i].State = task.TaskDone + m.loadedTasks[i].WorkerID = w.ID + } else { + m.loadedTasks[i].State = task.TaskFailed + } + m.resolveTaskDependencies() + break + } + } + m.workers = m.manager.All() m.refreshTableRows() + m.updateKeyStates() if w.ID == m.selectedWorkerID { m.refreshViewportFromSelected(true) } @@ -266,6 +300,42 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case analyzeCompletedMsg: + m.analysisLoading = false + m.analysisMode = true + if msg.Err != nil { + m.analysisResult = &AnalysisResult{ + WorkerID: msg.WorkerID, + RootCause: fmt.Sprintf("Analysis failed: %v", msg.Err), + } + } else { + m.analysisResult = &AnalysisResult{ + WorkerID: msg.WorkerID, + RootCause: msg.RootCause, + SuggestedPrompt: msg.SuggestedPrompt, + } + } + m.updateKeyStates() + m.refreshViewportFromSelected(false) + return m, nil + + case genPromptCompletedMsg: + m.genPromptLoading = false + m.updateKeyStates() + if msg.Err != nil { + m.refreshViewportFromSelected(false) + return m, nil + } + + role := "coder" + for _, t := range m.loadedTasks { + if t.ID == msg.TaskID && strings.TrimSpace(t.SuggestedRole) != "" { + role = t.SuggestedRole + break + } + } + return m, m.openSpawnDialogWithPrefill(role, msg.Prompt, nil) + case tickMsg: m.refreshTableRows() return m, tickCmd() @@ -274,6 +344,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) m.refreshTableRows() + if m.analysisLoading || m.genPromptLoading { + m.refreshViewportFromSelected(false) + } return m, cmd } @@ -348,6 +421,21 @@ func (m *Model) syncSelectionFromTable() { } func (m *Model) refreshViewportFromSelected(autoFollow bool) { + if m.analysisLoading { + m.setViewportContent(fmt.Sprintf("%s Analyzing failure for %s...", m.spinner.View(), m.analysisWorkerID), false) + return + } + + if m.genPromptLoading { + m.setViewportContent(fmt.Sprintf("%s Generating implementation prompt...", m.spinner.View()), false) + return + } + + if m.analysisMode && m.analysisResult != nil { + m.setViewportContent(m.renderAnalysisView(), false) + return + } + w := m.selectedWorker() if w == nil || w.Output == nil { m.setViewportContent(welcomeViewportText(), false) @@ -376,6 +464,30 @@ func (m *Model) setViewportContent(content string, autoFollow bool) { } func (m *Model) updateFullScreenKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.analysisMode { + if key.Matches(msg, m.keys.Back) { + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + m.refreshViewportFromSelected(false) + return m, nil + } + + if key.Matches(msg, m.keys.Restart) && m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "" { + role := "coder" + if w := m.manager.Get(m.analysisResult.WorkerID); w != nil { + role = w.Role + } + suggestedPrompt := m.analysisResult.SuggestedPrompt + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + return m, m.openSpawnDialogWithPrefill(role, suggestedPrompt, nil) + } + + return m, nil + } + if key.Matches(msg, m.keys.Back) { m.fullScreen = false m.recalculateLayout() @@ -405,6 +517,30 @@ func (m *Model) updateFullScreenKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) updateTableKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.analysisMode { + if key.Matches(msg, m.keys.Back) { + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + m.refreshViewportFromSelected(false) + return m, nil + } + + if key.Matches(msg, m.keys.Restart) && m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "" { + role := "coder" + if w := m.manager.Get(m.analysisResult.WorkerID); w != nil { + role = w.Role + } + suggestedPrompt := m.analysisResult.SuggestedPrompt + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + return m, m.openSpawnDialogWithPrefill(role, suggestedPrompt, nil) + } + + return m, nil + } + if key.Matches(msg, m.keys.Spawn) { return m, m.openSpawnDialog() } @@ -435,6 +571,44 @@ func (m *Model) updateTableKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + if key.Matches(msg, m.keys.Analyze) { + selected := m.selectedWorker() + if selected != nil && selected.State == worker.StateFailed { + m.analysisMode = false + m.analysisResult = nil + m.analysisLoading = true + m.analysisWorkerID = selected.ID + m.updateKeyStates() + m.refreshViewportFromSelected(false) + + outputTail := "" + if selected.Output != nil { + outputTail = selected.Output.Tail(200) + } + return m, analyzeCmd(m.backend, selected.ID, selected.Role, selected.ExitCode, selected.FormatDuration(), outputTail) + } + return m, nil + } + + if key.Matches(msg, m.keys.GenPrompt) { + selectedTask := m.selectTaskForPromptGen() + if selectedTask == nil { + return m, nil + } + + m.genPromptLoading = true + m.updateKeyStates() + m.refreshViewportFromSelected(false) + return m, genPromptCmd( + m.backend, + selectedTask.ID, + selectedTask.Title, + selectedTask.Description, + selectedTask.SuggestedRole, + selectedTask.Dependencies, + ) + } + if key.Matches(msg, m.keys.Fullscreen, m.keys.Select) { if m.selectedWorker() != nil { m.fullScreen = true @@ -455,7 +629,64 @@ func (m *Model) updateTableKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) updateTaskPanelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Up): + if m.selectedTaskIdx > 0 { + m.selectedTaskIdx-- + } + m.updateKeyStates() + return m, nil + case key.Matches(msg, m.keys.Down): + if m.selectedTaskIdx < len(m.loadedTasks)-1 { + m.selectedTaskIdx++ + } + m.updateKeyStates() + return m, nil + case key.Matches(msg, m.keys.Select), key.Matches(msg, m.keys.Spawn): + if m.selectedTaskIdx >= 0 && m.selectedTaskIdx < len(m.loadedTasks) { + t := m.loadedTasks[m.selectedTaskIdx] + if t.State == task.TaskUnassigned { + role := t.SuggestedRole + if role == "" { + role = "coder" + } + return m, m.openSpawnDialogWithTaskPrefill(role, strings.TrimSpace(t.Description), nil, t.ID) + } + } + return m, nil + case key.Matches(msg, m.keys.Batch): + return m, m.openBatchDialog() + default: + return m, nil + } +} + func (m *Model) updateViewportKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.analysisMode { + if key.Matches(msg, m.keys.Back) { + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + m.refreshViewportFromSelected(false) + return m, nil + } + + if key.Matches(msg, m.keys.Restart) && m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "" { + role := "coder" + if w := m.manager.Get(m.analysisResult.WorkerID); w != nil { + role = w.Role + } + suggestedPrompt := m.analysisResult.SuggestedPrompt + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + return m, m.openSpawnDialogWithPrefill(role, suggestedPrompt, nil) + } + + return m, nil + } + if key.Matches(msg, m.keys.Fullscreen) { if m.selectedWorker() != nil { m.fullScreen = true @@ -529,3 +760,62 @@ func (m *Model) runningWorkersCount() int { } return count } + +func (m *Model) openSpawnDialogWithTaskPrefill(role, prompt string, files []string, taskID string) tea.Cmd { + m.showSpawnDialog = true + m.spawnDraft = spawnDialogDraft{Role: role, Prompt: prompt, Files: strings.Join(files, ", ")} + m.spawnForm = newSpawnDialogModelWithPrefill(role, prompt, files) + m.spawnForm.taskID = taskID + return m.spawnForm.focusCurrentField() +} + +func (m *Model) resolveTaskDependencies() { + doneIDs := make(map[string]bool, len(m.loadedTasks)) + for _, t := range m.loadedTasks { + if t.State == task.TaskDone { + doneIDs[t.ID] = true + } + } + + for i := range m.loadedTasks { + if m.loadedTasks[i].State != task.TaskBlocked { + continue + } + allDone := true + for _, dep := range m.loadedTasks[i].Dependencies { + if !doneIDs[dep] { + allDone = false + break + } + } + if allDone { + m.loadedTasks[i].State = task.TaskUnassigned + } + } +} + +func (m *Model) selectTaskForPromptGen() *task.Task { + if len(m.loadedTasks) == 0 { + return nil + } + + if selected := m.selectedWorker(); selected != nil && selected.TaskID != "" { + for i := range m.loadedTasks { + if m.loadedTasks[i].ID == selected.TaskID { + return &m.loadedTasks[i] + } + } + } + + if m.selectedTaskIdx >= 0 && m.selectedTaskIdx < len(m.loadedTasks) { + return &m.loadedTasks[m.selectedTaskIdx] + } + + for i := range m.loadedTasks { + if m.loadedTasks[i].State == task.TaskUnassigned { + return &m.loadedTasks[i] + } + } + + return &m.loadedTasks[0] +} diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md index 8b7be25..8baadca 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP06-output-viewport.md @@ -1,7 +1,7 @@ --- work_package_id: WP06 title: Output Viewport + Fullscreen + Update Dispatch + Shutdown -lane: doing +lane: done dependencies: - WP04 - WP05 @@ -29,6 +29,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP06 coder - output viewport + fullscreen + shutdown) +- timestamp: '2026-02-18T14:31:00.286768251+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented and verified - fullscreen viewport, 4-phase routing, shutdown) --- # Work Package Prompt: WP06 - Output Viewport + Fullscreen + Update Dispatch + Shutdown diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md index 1b9d7fd..fba7b96 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md @@ -1,30 +1,35 @@ --- -work_package_id: "WP09" -title: "Task Panel UI + Worker-Task Association + Batch Spawn" -lane: "planned" +work_package_id: WP09 +title: Task Panel UI + Worker-Task Association + Batch Spawn +lane: doing dependencies: - - "WP03" - - "WP04" - - "WP08" +- WP03 +- WP04 +- WP08 subtasks: - - "Task list panel using bubbles/list (wide mode)" - - "Custom list.ItemDelegate for multi-line task items" - - "Task panel focus cycling (3-panel mode)" - - "Spawn from task: pre-fill dialog with task data" - - "Worker-task association: taskStateChangedMsg flow" - - "Batch spawn dialog" - - "Header subtitle with source info" -phase: "Wave 2 - Task Sources + Worker Management" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- Task list panel using bubbles/list (wide mode) +- Custom list.ItemDelegate for multi-line task items +- Task panel focus cycling (3-panel mode) +- 'Spawn from task: pre-fill dialog with task data' +- 'Worker-task association: taskStateChangedMsg flow' +- Batch spawn dialog +- Header subtitle with source info +phase: Wave 2 - Task Sources + Worker Management +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:31:13.383985135+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP09 coder - task panel UI + batch spawn) --- # Work Package Prompt: WP09 - Task Panel UI + Worker-Task Association + Batch Spawn diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md index f012792..3057bac 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP10-setup-command.md @@ -1,7 +1,7 @@ --- work_package_id: WP10 title: Setup Command + Agent Scaffolding -lane: doing +lane: done dependencies: - WP01 subtasks: @@ -26,6 +26,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP10 coder - setup command + agent scaffolding) +- timestamp: '2026-02-18T14:31:01.365155161+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented and verified - setup command + agent scaffolding) --- # Work Package Prompt: WP10 - Setup Command + Agent Scaffolding diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md index 352eecb..bfc3332 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md @@ -1,28 +1,33 @@ --- -work_package_id: "WP11" -title: "AI Helpers (Analyze Failure + Generate Prompt)" -lane: "planned" +work_package_id: WP11 +title: AI Helpers (Analyze Failure + Generate Prompt) +lane: doing dependencies: - - "WP04" - - "WP08" +- WP04 +- WP08 subtasks: - - "Analyze failure: spawn headless worker to analyze output" - - "Generate prompt: spawn headless worker to generate prompt from task" - - "Analysis viewport rendering (V9 mockup)" - - "analyzeStartedMsg/analyzeCompletedMsg handlers" - - "genPromptStartedMsg/genPromptCompletedMsg handlers" - - "Restart with suggested prompt flow" -phase: "Wave 2 - Task Sources + Worker Management" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- 'Analyze failure: spawn headless worker to analyze output' +- 'Generate prompt: spawn headless worker to generate prompt from task' +- Analysis viewport rendering (V9 mockup) +- analyzeStartedMsg/analyzeCompletedMsg handlers +- genPromptStartedMsg/genPromptCompletedMsg handlers +- Restart with suggested prompt flow +phase: Wave 2 - Task Sources + Worker Management +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:31:14.374507180+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP11 coder - AI helpers) --- # Work Package Prompt: WP11 - AI Helpers (Analyze Failure + Generate Prompt) From cc6d185a15c43c80b8b7dbee390d93c0c44878f9 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 08:43:47 -0600 Subject: [PATCH 12/26] fix(016): remove View() mutations in renderFullScreen and renderTasksPanel View purity: remove viewport.SetWidth/SetHeight from renderFullScreen (redundant with resizeFullScreenViewport in Update), remove selectedTaskIdx mutation from renderTasksPanel (use local clamped var). --- internal/tui/panels.go | 3 --- .../tasks/WP09-task-panel-batch.md | 7 ++++++- .../016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md | 7 ++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/tui/panels.go b/internal/tui/panels.go index dd46c61..7900437 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -117,8 +117,6 @@ func (m *Model) renderFullScreen() string { vpInnerWidth := max(1, m.width-borderH) vpInnerHeight := max(1, contentHeight-borderV) - m.viewport.SetWidth(vpInnerWidth) - m.viewport.SetHeight(max(1, vpInnerHeight-1)) title := "Output" if m.analysisMode && m.analysisResult != nil { @@ -322,7 +320,6 @@ func (m *Model) renderTasksPanel() string { if selected >= len(m.loadedTasks) { selected = len(m.loadedTasks) - 1 } - m.selectedTaskIdx = selected const linesPerTask = 4 availableLines := max(1, m.tasksInnerHeight-1) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md index fba7b96..2e7e7f0 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP09-task-panel-batch.md @@ -1,7 +1,7 @@ --- work_package_id: WP09 title: Task Panel UI + Worker-Task Association + Batch Spawn -lane: doing +lane: done dependencies: - WP03 - WP04 @@ -30,6 +30,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP09 coder - task panel UI + batch spawn) +- timestamp: '2026-02-18T14:39:19.046365334+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented - task panel, batch spawn, worker-task association) --- # Work Package Prompt: WP09 - Task Panel UI + Worker-Task Association + Batch Spawn diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md index bfc3332..aa4636e 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP11-ai-helpers.md @@ -1,7 +1,7 @@ --- work_package_id: WP11 title: AI Helpers (Analyze Failure + Generate Prompt) -lane: doing +lane: done dependencies: - WP04 - WP08 @@ -28,6 +28,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP11 coder - AI helpers) +- timestamp: '2026-02-18T14:39:20.119473818+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented - AI helpers (analyze + gen prompt)) --- # Work Package Prompt: WP11 - AI Helpers (Analyze Failure + Generate Prompt) From 8ffa0ddd11e87bc3a2e2ab0543e1aff6de0e82c6 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 08:59:17 -0600 Subject: [PATCH 13/26] feat(016): WP12 daemon mode + WP13 session persistence WP12: Headless daemon mode (-d flag) with NDJSON and human-readable event logging, TTY auto-detection, --spawn-all for batch execution, auto-exit with aggregate stats, SIGPIPE handling. WP13: Session persistence to .kasmos/session.json with debounced atomic writes, --attach flag for session restore, orphan detection (mark running workers as killed when PID dead), worker counter reset, output tail preservation (last 200 lines), full test suite. --- cmd/kasmos/main.go | 80 ++++++++- internal/persist/schema.go | 119 +++++++++++++ internal/persist/schema_test.go | 162 ++++++++++++++++++ internal/persist/session.go | 126 ++++++++++++++ internal/persist/session_test.go | 135 +++++++++++++++ internal/tui/daemon.go | 120 +++++++++++++ internal/tui/model.go | 103 +++++++++-- internal/tui/update.go | 101 +++++++++++ .../tasks/WP12-daemon-mode.md | 45 ++--- .../tasks/WP13-session-persistence.md | 47 ++--- 10 files changed, 984 insertions(+), 54 deletions(-) create mode 100644 internal/persist/schema.go create mode 100644 internal/persist/schema_test.go create mode 100644 internal/persist/session.go create mode 100644 internal/persist/session_test.go create mode 100644 internal/tui/daemon.go diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index 5248278..6220508 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -6,11 +6,14 @@ import ( "log" "os" "os/signal" + "strings" "syscall" + "time" tea "github.com/charmbracelet/bubbletea/v2" "github.com/spf13/cobra" + "github.com/user/kasmos/internal/persist" "github.com/user/kasmos/internal/setup" "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/tui" @@ -26,6 +29,10 @@ func main() { func newRootCmd() *cobra.Command { var showVersion bool + var daemon bool + var format string + var spawnAll bool + var attach bool cmd := &cobra.Command{ Use: "kasmos", @@ -50,24 +57,91 @@ func newRootCmd() *cobra.Command { log.Printf("warning: failed to load task source %q (%s): %v", source.Path(), source.Type(), err) } + if !daemon { + if info, err := os.Stdout.Stat(); err == nil { + if (info.Mode() & os.ModeCharDevice) == 0 { + daemon = true + } + } + } + + format = strings.TrimSpace(strings.ToLower(format)) + if format == "" { + format = "default" + } + if format != "default" && format != "json" { + return fmt.Errorf("invalid --format %q: expected default or json", format) + } + if daemon { + signal.Ignore(syscall.SIGPIPE) + } + backend, err := worker.NewSubprocessBackend() if err != nil { return err } + persister := persist.NewSessionPersister(".") + sessionID := persist.NewSessionID() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() model := tui.NewModel(backend, source) - program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx)) + if daemon { + model.SetDaemonMode(true, format, spawnAll) + } + if attach { + state, err := persister.Load() + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("no session found. Start a new session with: kasmos") + } + return fmt.Errorf("load session: %w", err) + } + if persist.IsPIDAlive(state.PID) { + return fmt.Errorf("session already active (PID %d)", state.PID) + } + + for _, snap := range state.Workers { + w := persist.SnapshotToWorker(snap) + if w.State == worker.StateRunning || w.State == worker.StateSpawning { + w.State = worker.StateKilled + w.ExitedAt = time.Now() + } + model.RestoreWorker(w) + } + model.ResetWorkerCounter(state.NextWorkerNum) + sessionID = state.SessionID + } + model.SetPersister(persister, sessionID) + opts := []tea.ProgramOption{tea.WithContext(ctx)} + if daemon { + opts = append(opts, tea.WithInput(nil)) + } else { + opts = append(opts, tea.WithAltScreen()) + } + program := tea.NewProgram(model, opts...) model.SetProgram(program) - _, err = program.Run() - return err + finalModel, err := program.Run() + if err != nil { + return err + } + if daemon { + if final, ok := finalModel.(*tui.Model); ok && final.DaemonExitCode() != 0 { + return fmt.Errorf("daemon finished with failures") + } + } + return nil }, } cmd.Flags().BoolVar(&showVersion, "version", false, "print version and exit") + cmd.Flags().BoolVarP(&daemon, "daemon", "d", false, "run in headless daemon mode") + cmd.Flags().StringVar(&format, "format", "default", "daemon output format: default or json") + cmd.Flags().BoolVar(&spawnAll, "spawn-all", false, "spawn workers for all unblocked tasks immediately") + cmd.Flags().BoolVar(&attach, "attach", false, "restore session from .kasmos/session.json") setupCmd := &cobra.Command{ Use: "setup", diff --git a/internal/persist/schema.go b/internal/persist/schema.go new file mode 100644 index 0000000..72ea92f --- /dev/null +++ b/internal/persist/schema.go @@ -0,0 +1,119 @@ +package persist + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/user/kasmos/internal/worker" +) + +type SessionState struct { + Version int `json:"version"` + SessionID string `json:"session_id"` + StartedAt time.Time `json:"started_at"` + TaskSource *TaskSourceConfig `json:"task_source,omitempty"` + Workers []WorkerSnapshot `json:"workers"` + NextWorkerNum int64 `json:"next_worker_num"` + PID int `json:"pid"` +} + +type TaskSourceConfig struct { + Type string `json:"type"` + Path string `json:"path"` +} + +type WorkerSnapshot struct { + ID string `json:"id"` + Role string `json:"role"` + Prompt string `json:"prompt"` + Files []string `json:"files,omitempty"` + State string `json:"state"` + ExitCode *int `json:"exit_code,omitempty"` + SpawnedAt time.Time `json:"spawned_at"` + ExitedAt *time.Time `json:"exited_at,omitempty"` + DurationMs *int64 `json:"duration_ms,omitempty"` + SessionID string `json:"session_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + PID *int `json:"pid,omitempty"` + OutputTail string `json:"output_tail,omitempty"` +} + +func NewSessionID() string { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("ks-%d-0000", time.Now().Unix()) + } + return fmt.Sprintf("ks-%d-%s", time.Now().Unix(), hex.EncodeToString(b)[:4]) +} + +func WorkerToSnapshot(w *worker.Worker) WorkerSnapshot { + s := WorkerSnapshot{ + ID: w.ID, + Role: w.Role, + Prompt: w.Prompt, + Files: w.Files, + State: string(w.State), + SpawnedAt: w.SpawnedAt, + SessionID: w.SessionID, + ParentID: w.ParentID, + TaskID: w.TaskID, + } + if w.State == worker.StateExited || w.State == worker.StateFailed || w.State == worker.StateKilled { + code := w.ExitCode + s.ExitCode = &code + if !w.ExitedAt.IsZero() { + s.ExitedAt = &w.ExitedAt + dur := w.ExitedAt.Sub(w.SpawnedAt).Milliseconds() + s.DurationMs = &dur + } + } + if w.Handle != nil { + pid := w.Handle.PID() + if pid > 0 { + s.PID = &pid + } + } + if w.Output != nil { + content := w.Output.Content() + s.OutputTail = splitTail(content, 200) + } + return s +} + +func SnapshotToWorker(s WorkerSnapshot) *worker.Worker { + w := &worker.Worker{ + ID: s.ID, + Role: s.Role, + Prompt: s.Prompt, + Files: s.Files, + State: worker.WorkerState(s.State), + SpawnedAt: s.SpawnedAt, + SessionID: s.SessionID, + ParentID: s.ParentID, + TaskID: s.TaskID, + } + if s.ExitCode != nil { + w.ExitCode = *s.ExitCode + } + if s.ExitedAt != nil { + w.ExitedAt = *s.ExitedAt + } + if s.OutputTail != "" { + w.Output = worker.NewOutputBuffer(worker.DefaultMaxLines) + w.Output.Append(s.OutputTail) + } + return w +} + +// splitTail returns the last n lines of content. +func splitTail(content string, n int) string { + lines := strings.Split(content, "\n") + if len(lines) <= n { + return content + } + return strings.Join(lines[len(lines)-n:], "\n") +} diff --git a/internal/persist/schema_test.go b/internal/persist/schema_test.go new file mode 100644 index 0000000..34f6222 --- /dev/null +++ b/internal/persist/schema_test.go @@ -0,0 +1,162 @@ +package persist + +import ( + "encoding/json" + "io" + "reflect" + "regexp" + "testing" + "time" + + "github.com/user/kasmos/internal/worker" +) + +type fakeHandle struct { + pid int +} + +func (h fakeHandle) Stdout() io.Reader { return nil } +func (h fakeHandle) Wait() worker.ExitResult { return worker.ExitResult{} } +func (h fakeHandle) Kill(gracePeriod time.Duration) error { return nil } +func (h fakeHandle) PID() int { return h.pid } + +func TestSessionStateJSONRoundTrip(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + exitCode := 1 + dur := int64(1500) + state := SessionState{ + Version: 1, + SessionID: "ks-123-abcd", + StartedAt: now, + TaskSource: &TaskSourceConfig{ + Type: "spec-kitty", + Path: "kitty-specs/016-kasmos-agent-orchestrator", + }, + Workers: []WorkerSnapshot{{ + ID: "w-001", + Role: "coder", + Prompt: "do work", + State: "failed", + ExitCode: &exitCode, + SpawnedAt: now, + DurationMs: &dur, + OutputTail: "line1\nline2", + }}, + NextWorkerNum: 2, + PID: 42, + } + + b, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got SessionState + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if !reflect.DeepEqual(state, got) { + t.Fatalf("roundtrip mismatch\nwant: %#v\ngot: %#v", state, got) + } +} + +func TestWorkerSnapshotRoundTrip(t *testing.T) { + spawned := time.Now().Add(-2 * time.Minute).UTC().Truncate(time.Second) + exited := spawned.Add(42 * time.Second) + out := worker.NewOutputBuffer(worker.DefaultMaxLines) + out.Append("line1\nline2\nline3") + + w := &worker.Worker{ + ID: "w-007", + Role: "reviewer", + Prompt: "review", + Files: []string{"a.go", "b.go"}, + State: worker.StateExited, + ExitCode: 0, + SpawnedAt: spawned, + ExitedAt: exited, + SessionID: "oc-123", + ParentID: "w-005", + TaskID: "WP13", + Handle: fakeHandle{pid: 12345}, + Output: out, + } + + snap := WorkerToSnapshot(w) + if snap.PID == nil || *snap.PID != 12345 { + t.Fatalf("expected pid 12345, got %+v", snap.PID) + } + if snap.OutputTail != "line1\nline2\nline3" { + t.Fatalf("unexpected output tail: %q", snap.OutputTail) + } + + restored := SnapshotToWorker(snap) + if restored.ID != w.ID || restored.Role != w.Role || restored.Prompt != w.Prompt { + t.Fatalf("identity mismatch after restore") + } + if restored.State != w.State || restored.ExitCode != w.ExitCode { + t.Fatalf("state mismatch after restore") + } + if !reflect.DeepEqual(restored.Files, w.Files) { + t.Fatalf("files mismatch: want %v got %v", w.Files, restored.Files) + } + if restored.Output == nil || restored.Output.Content() != w.Output.Content() { + t.Fatalf("output mismatch") + } + if restored.Handle != nil { + t.Fatalf("restored handle should be nil") + } +} + +func TestWorkerToSnapshotOptionalFields(t *testing.T) { + w := &worker.Worker{ + ID: "w-001", + Role: "coder", + Prompt: "prompt", + State: worker.StateRunning, + SpawnedAt: time.Now(), + } + + s := WorkerToSnapshot(w) + if s.ExitCode != nil { + t.Fatalf("exit code should be nil for running worker") + } + if s.ExitedAt != nil { + t.Fatalf("exited_at should be nil for running worker") + } + if s.DurationMs != nil { + t.Fatalf("duration should be nil for running worker") + } +} + +func TestNewSessionIDFormat(t *testing.T) { + id := NewSessionID() + pattern := regexp.MustCompile(`^ks-\d+-[0-9a-f]{4}$`) + if !pattern.MatchString(id) { + t.Fatalf("invalid session id format: %q", id) + } +} + +func TestSplitTail(t *testing.T) { + tests := []struct { + name string + content string + n int + want string + }{ + {name: "empty", content: "", n: 200, want: ""}, + {name: "fewer than n", content: "a\nb\nc", n: 5, want: "a\nb\nc"}, + {name: "exact", content: "a\nb\nc", n: 3, want: "a\nb\nc"}, + {name: "trim to tail", content: "1\n2\n3\n4\n5", n: 2, want: "4\n5"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := splitTail(tc.content, tc.n) + if got != tc.want { + t.Fatalf("splitTail() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/persist/session.go b/internal/persist/session.go new file mode 100644 index 0000000..6d5a9e9 --- /dev/null +++ b/internal/persist/session.go @@ -0,0 +1,126 @@ +package persist + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "syscall" + "time" +) + +type SessionPersister struct { + Path string + debounce time.Duration + mu sync.Mutex + dirty bool + timer *time.Timer + pending *SessionState +} + +func NewSessionPersister(dir string) *SessionPersister { + path := filepath.Join(dir, ".kasmos", "session.json") + return &SessionPersister{ + Path: path, + debounce: time.Second, + } +} + +func (p *SessionPersister) Save(state SessionState) { + p.mu.Lock() + defer p.mu.Unlock() + + p.dirty = true + stateCopy := state + p.pending = &stateCopy + + if p.timer == nil { + p.timer = time.AfterFunc(p.debounce, p.flush) + } +} + +func (p *SessionPersister) SaveSync(state SessionState) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.timer != nil { + p.timer.Stop() + p.timer = nil + } + p.dirty = false + p.pending = nil + + return p.writeAtomic(state) +} + +func (p *SessionPersister) flush() { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.dirty || p.pending == nil { + return + } + + _ = p.writeAtomic(*p.pending) + p.dirty = false + p.pending = nil + p.timer = nil +} + +func (p *SessionPersister) writeAtomic(state SessionState) error { + dir := filepath.Dir(p.Path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create session dir: %w", err) + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + + tmpPath := p.Path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return fmt.Errorf("write temp: %w", err) + } + if err := os.Rename(tmpPath, p.Path); err != nil { + return fmt.Errorf("rename temp: %w", err) + } + + return nil +} + +func (p *SessionPersister) Load() (*SessionState, error) { + data, err := os.ReadFile(p.Path) + if err != nil { + if os.IsNotExist(err) { + return nil, err + } + return nil, fmt.Errorf("read session: %w", err) + } + + var state SessionState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("unmarshal session: %w", err) + } + + if state.Version != 1 { + return nil, fmt.Errorf("unsupported session version: %d", state.Version) + } + + return &state, nil +} + +// IsPIDAlive checks if a process is still running. +func IsPIDAlive(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + + err = proc.Signal(syscall.Signal(0)) + return err == nil +} diff --git a/internal/persist/session_test.go b/internal/persist/session_test.go new file mode 100644 index 0000000..f7d8896 --- /dev/null +++ b/internal/persist/session_test.go @@ -0,0 +1,135 @@ +package persist + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func testState() SessionState { + return SessionState{ + Version: 1, + SessionID: "ks-123-abcd", + StartedAt: time.Now().UTC().Truncate(time.Second), + Workers: []WorkerSnapshot{}, + NextWorkerNum: 1, + PID: os.Getpid(), + } +} + +func TestWriteAtomicCreatesSessionFile(t *testing.T) { + dir := t.TempDir() + p := NewSessionPersister(dir) + + if err := p.writeAtomic(testState()); err != nil { + t.Fatalf("writeAtomic: %v", err) + } + + data, err := os.ReadFile(p.Path) + if err != nil { + t.Fatalf("read session file: %v", err) + } + if !strings.Contains(string(data), `"session_id": "ks-123-abcd"`) { + t.Fatalf("session file missing expected data: %s", string(data)) + } + + if _, err := os.Stat(p.Path + ".tmp"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("temporary file should not remain, stat err=%v", err) + } +} + +func TestSaveSyncWritesImmediately(t *testing.T) { + dir := t.TempDir() + p := NewSessionPersister(dir) + + if err := p.SaveSync(testState()); err != nil { + t.Fatalf("SaveSync: %v", err) + } + + if _, err := os.Stat(p.Path); err != nil { + t.Fatalf("expected session file to exist: %v", err) + } +} + +func TestLoadValidFile(t *testing.T) { + dir := t.TempDir() + p := NewSessionPersister(dir) + + if err := p.SaveSync(testState()); err != nil { + t.Fatalf("SaveSync: %v", err) + } + + state, err := p.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if state.Version != 1 || state.SessionID != "ks-123-abcd" { + t.Fatalf("loaded unexpected state: %+v", state) + } +} + +func TestLoadMissingFile(t *testing.T) { + dir := t.TempDir() + p := NewSessionPersister(dir) + + _, err := p.Load() + if err == nil { + t.Fatalf("expected load error for missing file") + } + if !os.IsNotExist(err) { + t.Fatalf("expected not exist error, got: %v", err) + } +} + +func TestLoadInvalidJSON(t *testing.T) { + dir := t.TempDir() + p := NewSessionPersister(dir) + + if err := os.MkdirAll(filepath.Dir(p.Path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(p.Path, []byte("{not-json"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + _, err := p.Load() + if err == nil { + t.Fatalf("expected invalid json error") + } + if !strings.Contains(err.Error(), "unmarshal session") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLoadWrongVersion(t *testing.T) { + dir := t.TempDir() + p := NewSessionPersister(dir) + + if err := os.MkdirAll(filepath.Dir(p.Path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + data := []byte(`{"version":2,"session_id":"ks-1-abcd","started_at":"2026-01-01T00:00:00Z","workers":[],"next_worker_num":1,"pid":1}`) + if err := os.WriteFile(p.Path, data, 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + _, err := p.Load() + if err == nil { + t.Fatalf("expected version error") + } + if !strings.Contains(err.Error(), "unsupported session version") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsPIDAlive(t *testing.T) { + if !IsPIDAlive(os.Getpid()) { + t.Fatalf("current pid should be alive") + } + if IsPIDAlive(0) { + t.Fatalf("pid 0 should not be alive") + } +} diff --git a/internal/tui/daemon.go b/internal/tui/daemon.go new file mode 100644 index 0000000..b298d93 --- /dev/null +++ b/internal/tui/daemon.go @@ -0,0 +1,120 @@ +package tui + +import ( + "encoding/json" + "fmt" + "time" +) + +// DaemonEvent represents a structured event for daemon mode output. +type DaemonEvent struct { + Timestamp time.Time `json:"ts"` + Event string `json:"event"` + Fields map[string]string `json:"fields,omitempty"` +} + +func sessionStartEvent(mode, sourcePath string, taskCount int) DaemonEvent { + return DaemonEvent{ + Timestamp: time.Now(), + Event: "session_start", + Fields: map[string]string{ + "mode": mode, + "source": sourcePath, + "task_count": fmt.Sprintf("%d", taskCount), + }, + } +} + +func workerSpawnEvent(id, role, taskRef string) DaemonEvent { + return DaemonEvent{ + Timestamp: time.Now(), + Event: "worker_spawn", + Fields: map[string]string{ + "id": id, + "role": role, + "task": taskRef, + }, + } +} + +func workerExitEvent(id string, exitCode int, duration, sessionID string) DaemonEvent { + return DaemonEvent{ + Timestamp: time.Now(), + Event: "worker_exit", + Fields: map[string]string{ + "id": id, + "code": fmt.Sprintf("%d", exitCode), + "duration": duration, + "session": sessionID, + }, + } +} + +func workerKillEvent(id string) DaemonEvent { + return DaemonEvent{ + Timestamp: time.Now(), + Event: "worker_kill", + Fields: map[string]string{"id": id}, + } +} + +func sessionEndEvent(total, passed, failed int, duration time.Duration, exitCode int) DaemonEvent { + return DaemonEvent{ + Timestamp: time.Now(), + Event: "session_end", + Fields: map[string]string{ + "total": fmt.Sprintf("%d", total), + "passed": fmt.Sprintf("%d", passed), + "failed": fmt.Sprintf("%d", failed), + "duration": duration.Truncate(time.Second).String(), + "exit_code": fmt.Sprintf("%d", exitCode), + }, + } +} + +// JSONString returns the event as a single-line JSON string (NDJSON). +func (e DaemonEvent) JSONString() string { + obj := map[string]interface{}{ + "ts": e.Timestamp.Format(time.RFC3339), + "event": e.Event, + } + for k, v := range e.Fields { + obj[k] = v + } + b, _ := json.Marshal(obj) + return string(b) +} + +// HumanString returns a human-readable log line. +func (e DaemonEvent) HumanString() string { + ts := e.Timestamp.Format("15:04:05") + switch e.Event { + case "session_start": + return fmt.Sprintf("[%s] session started mode=%s source=%s tasks=%s", + ts, e.Fields["mode"], e.Fields["source"], e.Fields["task_count"]) + case "worker_spawn": + return fmt.Sprintf("[%s] %s spawned %-9s %q", + ts, e.Fields["id"], e.Fields["role"], e.Fields["task"]) + case "worker_exit": + return fmt.Sprintf("[%s] %s exited(%s) %s %s", + ts, e.Fields["id"], e.Fields["code"], e.Fields["duration"], e.Fields["session"]) + case "worker_kill": + return fmt.Sprintf("[%s] %s killed", ts, e.Fields["id"]) + case "session_end": + return fmt.Sprintf("[%s] session ended: %s passed, %s failed (%s) exit=%s", + ts, e.Fields["passed"], e.Fields["failed"], e.Fields["duration"], e.Fields["exit_code"]) + default: + return fmt.Sprintf("[%s] %s %v", ts, e.Event, e.Fields) + } +} + +func (m *Model) logDaemonEvent(e DaemonEvent) { + if !m.daemon { + return + } + if m.daemonFormat == "json" { + fmt.Println(e.JSONString()) + return + } + fmt.Println(e.HumanString()) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index c5eae5d..4aa1ef2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,7 +2,9 @@ package tui import ( "fmt" + "os" "strings" + "time" "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/spinner" @@ -11,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/user/kasmos/internal/persist" "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -26,15 +29,17 @@ type Model struct { fullScreen bool autoFollow bool - keys keyMap - help help.Model - table table.Model - viewport viewport.Model - spinner spinner.Model - backend worker.WorkerBackend - manager *worker.WorkerManager - workers []*worker.Worker - program *tea.Program + keys keyMap + help help.Model + table table.Model + viewport viewport.Model + spinner spinner.Model + backend worker.WorkerBackend + manager *worker.WorkerManager + workers []*worker.Worker + program *tea.Program + persister *persist.SessionPersister + sessionID string showSpawnDialog bool spawnForm *spawnDialogModel @@ -75,6 +80,13 @@ type Model struct { taskSource task.Source loadedTasks []task.Task selectedTaskIdx int + + daemon bool + daemonFormat string + spawnAll bool + sessionStart time.Time + daemonDone bool + daemonExitCode int } type AnalysisResult struct { @@ -130,11 +142,82 @@ func (m *Model) SetProgram(program *tea.Program) { m.program = program } +func (m *Model) SetPersister(p *persist.SessionPersister, sessionID string) { + m.persister = p + m.sessionID = sessionID +} + +func (m *Model) RestoreWorker(w *worker.Worker) { + m.manager.Add(w) + m.workers = m.manager.All() +} + +func (m *Model) ResetWorkerCounter(n int64) { + m.manager.ResetWorkerCounter(n) +} + +func (m *Model) buildSessionState() persist.SessionState { + workers := m.manager.All() + snapshots := make([]persist.WorkerSnapshot, 0, len(workers)) + for _, w := range workers { + snapshots = append(snapshots, persist.WorkerToSnapshot(w)) + } + + var ts *persist.TaskSourceConfig + if m.taskSource != nil && m.taskSource.Type() != "ad-hoc" { + ts = &persist.TaskSourceConfig{ + Type: m.taskSource.Type(), + Path: m.taskSource.Path(), + } + } + + return persist.SessionState{ + Version: 1, + SessionID: m.sessionID, + StartedAt: time.Now(), + TaskSource: ts, + Workers: snapshots, + NextWorkerNum: int64(len(workers) + 1), + PID: os.Getpid(), + } +} + +func (m *Model) triggerPersist() { + if m.persister == nil { + return + } + m.persister.Save(m.buildSessionState()) +} + +func (m *Model) SetDaemonMode(daemon bool, format string, spawnAll bool) { + m.daemon = daemon + m.daemonFormat = format + m.spawnAll = spawnAll + m.sessionStart = time.Now() +} + +func (m *Model) DaemonExitCode() int { + return m.daemonExitCode +} + func (m *Model) Init() (tea.Model, tea.Cmd) { - return m, tea.Batch(tickCmd(), m.spinner.Tick) + cmds := []tea.Cmd{tickCmd(), m.spinner.Tick} + if m.daemon { + m.logDaemonEvent(sessionStartEvent(m.modeName(), m.taskSourcePath, len(m.loadedTasks))) + } + if m.daemon && m.spawnAll { + if cmd := m.spawnAllTasks(); cmd != nil { + cmds = append(cmds, cmd) + } + } + return m, tea.Batch(cmds...) } func (m *Model) View() string { + if m.daemon { + return "" + } + if !m.ready { return "" } diff --git a/internal/tui/update.go b/internal/tui/update.go index 72e9e38..6530bd7 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -153,6 +153,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.updateKeyStates() + m.triggerPersist() cfg := worker.SpawnConfig{ID: w.ID, Role: w.Role, Prompt: w.Prompt, Files: w.Files} return m, spawnWorkerCmd(m.backend, cfg) @@ -181,6 +182,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedWorkerID = w.ID m.refreshTableRows() m.refreshViewportFromSelected(true) + m.triggerPersist() cfg := worker.SpawnConfig{ ID: w.ID, @@ -200,6 +202,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = w.Handle.Kill(3 * time.Second) } } + if m.persister != nil { + _ = m.persister.SaveSync(m.buildSessionState()) + } return m, tea.Quit case quitCancelledMsg: @@ -220,8 +225,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if w.SpawnedAt.IsZero() { w.SpawnedAt = time.Now() } + m.logDaemonEvent(workerSpawnEvent(w.ID, w.Role, w.TaskID)) m.workers = m.manager.All() m.refreshTableRows() + m.triggerPersist() readWorkerOutput(w.ID, w.Handle.Stdout(), m.program) return m, waitWorkerCmd(w.ID, w.Handle) @@ -263,6 +270,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { w.SessionID = worker.ExtractSessionID(w.Output.Content()) } w.Handle = nil + m.logDaemonEvent(workerExitEvent(w.ID, w.ExitCode, w.FormatDuration(), w.SessionID)) if w.TaskID != "" { for i := range m.loadedTasks { @@ -286,6 +294,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if w.ID == m.selectedWorkerID { m.refreshViewportFromSelected(true) } + m.triggerPersist() + if m.daemon { + if cmd := m.checkDaemonComplete(); cmd != nil { + return m, cmd + } + } return m, nil case workerKilledMsg: @@ -293,10 +307,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { w.State = worker.StateKilled w.ExitedAt = time.Now() w.Handle = nil + m.logDaemonEvent(workerKillEvent(msg.WorkerID)) m.refreshTableRows() if w.ID == m.selectedWorkerID { m.refreshViewportFromSelected(true) } + m.triggerPersist() } return m, nil @@ -769,6 +785,91 @@ func (m *Model) openSpawnDialogWithTaskPrefill(role, prompt string, files []stri return m.spawnForm.focusCurrentField() } +func (m *Model) spawnAllTasks() tea.Cmd { + cmds := make([]tea.Cmd, 0) + for _, t := range m.loadedTasks { + if t.State != task.TaskUnassigned { + continue + } + + role := t.SuggestedRole + if role == "" { + role = "coder" + } + + id := m.manager.NextWorkerID() + w := &worker.Worker{ + ID: id, + Role: role, + Prompt: strings.TrimSpace(t.Description), + TaskID: t.ID, + State: worker.StateSpawning, + SpawnedAt: time.Now(), + Output: worker.NewOutputBuffer(worker.DefaultMaxLines), + } + m.manager.Add(w) + + for i := range m.loadedTasks { + if m.loadedTasks[i].ID == t.ID { + m.loadedTasks[i].State = task.TaskInProgress + m.loadedTasks[i].WorkerID = w.ID + break + } + } + + cfg := worker.SpawnConfig{ID: w.ID, Role: w.Role, Prompt: w.Prompt} + cmds = append(cmds, spawnWorkerCmd(m.backend, cfg)) + } + + m.workers = m.manager.All() + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) checkDaemonComplete() tea.Cmd { + workers := m.manager.All() + if len(workers) == 0 { + return nil + } + + for _, w := range workers { + if w.State == worker.StateRunning || w.State == worker.StateSpawning { + return nil + } + } + + if m.spawnAll { + m.resolveTaskDependencies() + if cmd := m.spawnAllTasks(); cmd != nil { + return cmd + } + } + + total := 0 + passed := 0 + failed := 0 + for _, w := range workers { + total++ + if w.State == worker.StateExited { + passed++ + } else { + failed++ + } + } + + exitCode := 0 + if failed > 0 { + exitCode = 1 + } + + m.daemonDone = true + m.daemonExitCode = exitCode + m.logDaemonEvent(sessionEndEvent(total, passed, failed, time.Since(m.sessionStart), exitCode)) + return tea.Quit +} + func (m *Model) resolveTaskDependencies() { doneIDs := make(map[string]bool, len(m.loadedTasks)) for _, t := range m.loadedTasks { diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md index 8270e62..ce1dcd4 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md @@ -1,27 +1,32 @@ --- -work_package_id: "WP12" -title: "Daemon Mode (Headless Operation)" -lane: "planned" +work_package_id: WP12 +title: Daemon Mode (Headless Operation) +lane: doing dependencies: - - "WP04" +- WP04 subtasks: - - "internal/tui/daemon.go - Daemon event logging (NDJSON + human-readable)" - - "cmd/kasmos/main.go - -d flag, --format flag, TTY detection" - - "tea.WithoutRenderer() setup for daemon mode" - - "DaemonEvent types and formatting" - - "Exit code logic (0 if all pass, 1 if any fail)" - - "Integration with --spawn-all for batch execution" -phase: "Wave 3 - Daemon Mode + Persistence" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- internal/tui/daemon.go - Daemon event logging (NDJSON + human-readable) +- cmd/kasmos/main.go - -d flag, --format flag, TTY detection +- tea.WithoutRenderer() setup for daemon mode +- DaemonEvent types and formatting +- Exit code logic (0 if all pass, 1 if any fail) +- Integration with --spawn-all for batch execution +phase: Wave 3 - Daemon Mode + Persistence +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:52:47.582546632+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP12 coder - daemon mode) --- # Work Package Prompt: WP12 - Daemon Mode (Headless Operation) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md index 55e33d0..d074116 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md @@ -1,28 +1,33 @@ --- -work_package_id: "WP13" -title: "Session Persistence + Reattach" -lane: "planned" +work_package_id: WP13 +title: Session Persistence + Reattach +lane: doing dependencies: - - "WP04" +- WP04 subtasks: - - "internal/persist/schema.go - SessionState struct (maps to JSON schema)" - - "internal/persist/session.go - SessionPersister (save/load, atomic write, debounce)" - - "cmd/kasmos/main.go - --attach flag" - - "Reattach logic: detect running session, restore state" - - "Orphan detection (PID dead, mark workers killed)" - - "Output tail preservation (last 200 lines per worker)" - - "Unit tests" -phase: "Wave 3 - Daemon Mode + Persistence" -assignee: "" -agent: "" -shell_pid: "" -review_status: "" -reviewed_by: "" +- internal/persist/schema.go - SessionState struct (maps to JSON schema) +- internal/persist/session.go - SessionPersister (save/load, atomic write, debounce) +- cmd/kasmos/main.go - --attach flag +- 'Reattach logic: detect running session, restore state' +- Orphan detection (PID dead, mark workers killed) +- Output tail preservation (last 200 lines per worker) +- Unit tests +phase: Wave 3 - Daemon Mode + Persistence +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' history: - - timestamp: "2026-02-17T00:00:00Z" - lane: "planned" - agent: "planner" - action: "Prompt generated via /spec-kitty.tasks" +- timestamp: '2026-02-17T00:00:00Z' + lane: planned + agent: planner + action: Prompt generated via /spec-kitty.tasks +- timestamp: '2026-02-18T14:52:48.643090054+00:00' + lane: doing + actor: manager + shell_pid: '472734' + action: transition active (Launching WP13 coder - session persistence + reattach) --- # Work Package Prompt: WP13 - Session Persistence + Reattach From 4651d64f830af69307a3371b631cc3f59977b90f Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 09:43:14 -0600 Subject: [PATCH 14/26] fix(016): cleanup review items, add TUI tests, rewrite README for v2.0.0 - Fix NextWorkerNum using manager.Counter() instead of len(workers)+1 - Remove 6 dead message types (tasksLoaded, taskStateChanged, etc.) - Extract handleAnalysisModeKeys() to deduplicate 3 identical blocks - Add analysis mode key disabling in updateKeyStates() - Lower tooSmall layout threshold from 80x24 to 60x15 - Add TUI test suite (model defaults, layout breakpoints, key states, daemon formatting) - Rewrite README to reflect Go/bubbletea rewrite (was Rust/Zellij) - Bump version to v2.0.0 --- README.md | 262 ++++++-------- cmd/kasmos/main.go | 2 +- internal/tui/keys.go | 29 ++ internal/tui/layout.go | 4 +- internal/tui/messages.go | 36 -- internal/tui/model.go | 2 +- internal/tui/tui_test.go | 337 ++++++++++++++++++ internal/tui/update.go | 90 ++--- internal/worker/manager.go | 4 + .../tasks/WP12-daemon-mode.md | 7 +- .../tasks/WP13-session-persistence.md | 7 +- 11 files changed, 519 insertions(+), 261 deletions(-) create mode 100644 internal/tui/tui_test.go diff --git a/README.md b/README.md index 0fa2822..df27160 100644 --- a/README.md +++ b/README.md @@ -1,187 +1,135 @@ # kasmos -Kasmos is an MCP-first orchestration CLI that runs AI agent swarms -- planner, coder, reviewer, and release roles -- inside a [Zellij](https://zellij.dev) terminal session. A manager agent coordinates work packages through an MCP server while each worker agent operates in its own pane and git worktree. +Go/bubbletea TUI for orchestrating concurrent OpenCode agent sessions from one terminal dashboard. -## Getting Started +## Features -### How It Works +- Full-screen terminal UI built with Bubble Tea v2 and Lip Gloss v2 +- Responsive 4-mode layout: `tooSmall`, `narrow`, `standard`, `wide` +- Spawn, kill, restart, and continue OpenCode worker sessions +- Multi-worker dashboard with live output viewport and fullscreen mode +- Worker chain tree rendering (parent/child session relationships) +- Task-aware orchestration from three sources: spec-kitty, GSD markdown, and ad-hoc +- Batch spawn for unassigned tasks +- AI helpers: failure analysis (`a`) and prompt generation (`g`) +- Session persistence to `.kasmos/session.json` with debounced atomic writes +- Resume previous sessions with `--attach` +- Headless daemon mode (`-d`) with human or NDJSON output +- `kasmos setup` for dependency validation and agent scaffolding -Kasmos ties together several tools into a single orchestration flow: - -| Component | Role | -|-----------|------| -| **kasmos** | Orchestrator CLI -- launches sessions, spawns agents, exposes MCP tools | -| **Zellij** | Terminal multiplexer that hosts the pane layout (manager, message log, dashboard, workers) | -| **OpenCode** | AI coding agent runtime that drives each pane (manager + workers) | -| **spec-kitty** | Feature specification and task lifecycle tool -- produces the specs, plans, and work packages that kasmos orchestrates | -| **zellij-pane-tracker** | Zellij plugin for pane metadata tracking used by agent coordination | -| **git** | Worktree isolation -- each work package runs in its own checkout | - -When you run `kasmos 011`, it resolves the feature spec, acquires a lock, generates a KDL layout, and opens a Zellij session with a manager agent that uses `kasmos serve` as its MCP server. The manager then spawns worker agents (planner, coder, reviewer, release) into separate panes, each scoped to their own git worktree and constrained to role-specific context boundaries. - -### Prerequisites - -Install the following before running kasmos: - -| Dependency | Install | -|------------|---------| -| **Rust toolchain** | [rustup.rs](https://rustup.rs) | -| **Zellij** | `cargo install zellij` or your package manager | -| **OpenCode** | [opencode.ai](https://opencode.ai) -- ensure the `opencode` binary is on PATH | -| **spec-kitty** | Install and ensure `spec-kitty` is on PATH | -| **git** | Your package manager (likely already installed) | -| **just** | `cargo install just` (optional -- for convenience recipes) | -| **zellij-pane-tracker** | See [plugin install](#pane-tracker-plugin) below | - -#### Pane Tracker Plugin +## Installation ```sh -git clone https://github.com/theslyprofessor/zellij-pane-tracker -cd zellij-pane-tracker -rustup target add wasm32-wasip1 -cargo build --release -mkdir -p ~/.config/zellij/plugins -cp target/wasm32-wasip1/release/zellij-pane-tracker.wasm ~/.config/zellij/plugins/ +go install ./cmd/kasmos +# or +go build ./cmd/kasmos ``` -Then add to `load_plugins { }` in `~/.config/zellij/config.kdl` (or run `kasmos setup` to add it automatically): - -```kdl -"file:~/.config/zellij/plugins/zellij-pane-tracker.wasm" -``` - -### Install +## Quick Start ```sh -git clone https://github.com/kastheco/kasmos.git -cd kasmos -cargo install --path crates/kasmos -``` - -Or with `just`: - -```sh -just build && just install -``` - -### First Run - -Run `kasmos setup` from your project repository. It validates every dependency, generates baseline config assets, and walks you through interactive OpenCode configuration: - -``` -$ kasmos setup +# validate deps and scaffold .opencode/agents/*.md kasmos setup -[PASS] zellij /usr/bin/zellij -[PASS] opencode /usr/local/bin/opencode -[PASS] spec-kitty /usr/local/bin/spec-kitty -[PASS] pane-tracker ~/.config/zellij/plugins/zellij-pane-tracker.wasm -[PASS] oc-config .opencode/opencode.jsonc -[PASS] oc-agents .opencode/agents/ (5 roles) -[PASS] git /usr/bin/git (in git repo /home/you/project) -[PASS] config /home/you/project/kasmos.toml -``` - -Setup generates: -- `kasmos.toml` -- project-level configuration (session layout, agent limits, polling intervals) -- `.opencode/opencode.jsonc` -- per-project OpenCode config with model/reasoning selections per agent role, MCP server definitions, and file permissions -- `.opencode/agents/*.md` -- agent role definitions (manager, planner, coder, reviewer, release) -- `config/profiles/kasmos/` -- baseline profile templates - -If `.opencode/opencode.jsonc` already exists, setup will ask whether to reconfigure. In non-interactive environments (no TTY), template defaults are applied automatically. -### Usage +# run interactive dashboard (ad-hoc mode) +kasmos -**List available feature specs:** +# run with a spec-kitty feature directory +kasmos path/to/spec-kitty-feature-dir -```sh -kasmos list +# run with a GSD markdown task file +kasmos path/to/tasks.md ``` -**Launch orchestration for a feature:** +Basic flow: -```sh -kasmos 011 # resolves spec prefix to kitty-specs/011-*/ -``` +1. Start kasmos, optionally pointing at a task source. +2. Spawn workers with `s`, monitor output in the viewport. +3. Continue completed/failed sessions with `c`, restart failed ones with `r`. +4. Reattach later with `kasmos --attach`. -This opens a Zellij session with the layout: +## Usage ``` -+---manager(60%)---+--msg-log(20%)--+--dashboard(20%)--+ <- top row -| | -| worker-area | <- dynamic panes -| | -+-------------------------------------------------------+ +kasmos [task-source-path] [flags] ``` -The manager agent reads the spec and plan, determines the workflow phase, and spawns workers wave-by-wave based on work package dependencies. +| Command / Flag | Description | +| --- | --- | +| `kasmos` | Interactive TUI, ad-hoc mode | +| `kasmos ` | Auto-detect source (spec-kitty dir or GSD `.md` file) | +| `kasmos setup` | Dependency checks + agent scaffolding | +| `kasmos -d` | Headless daemon mode (human logs) | +| `kasmos -d --format json` | Daemon mode with NDJSON output | +| `kasmos -d --spawn-all ` | Spawn all unblocked tasks, exit when complete | +| `kasmos --attach` | Restore session from `.kasmos/session.json` | +| `kasmos --version` | Print version | + +## Keybindings + +| Key | Action | Context | +| --- | --- | --- | +| `j`/`k`, `down`/`up` | Move selection / scroll | table, tasks, viewport | +| `tab` / `shift+tab` | Next / previous panel | main view | +| `s` | Spawn worker | table / tasks | +| `x` | Kill running worker | table | +| `c` | Continue session | table / fullscreen (exited/failed worker with session ID) | +| `r` | Restart worker | table / fullscreen (failed/killed worker) | +| `b` | Batch spawn dialog | task source mode with unassigned tasks | +| `f` | Toggle fullscreen output | table / viewport | +| `d` / `u` | Half-page down / up | viewport | +| `G` / `g` | Go to bottom / top | viewport | +| `a` | Analyze failed worker (AI) | table (failed worker selected) | +| `g` | Generate task prompt (AI) | table (task source mode) | +| `enter` | Select / confirm | context-dependent | +| `?` | Toggle help | global | +| `q` | Quit (confirms if workers running) | global | +| `ctrl+c` | Force quit | global | +| `esc` | Back / close overlay | global / dialogs | +| `space` | Toggle task selection | batch dialog | + +## Task Sources + +kasmos detects task sources from the positional argument: + +| Input | Source type | Detection | +| --- | --- | --- | +| Directory with `tasks/*.md` | spec-kitty | YAML frontmatter with dependencies, roles | +| `.md` file | GSD | Checkbox lines (`- [ ] task` / `- [x] task`) | +| No argument | ad-hoc | Manual worker spawning only | + +## Session Persistence + +- File: `.kasmos/session.json` +- Writes are debounced (~1s) and atomic (write to temp, rename) +- Restore with `kasmos --attach` to reload workers and session metadata +- Orphan detection via PID checking on attach + +## Daemon Mode + +Run headless with `-d` for CI/automation: + +- `--format default` (or omit): human-readable log lines +- `--format json`: one JSON object per line (NDJSON) +- `--spawn-all`: auto-spawn all unblocked tasks and exit when all workers complete + +Events: `session_start`, `worker_spawn`, `worker_exit`, `worker_kill`, `session_end` -**Monitor progress:** +## Architecture -```sh -kasmos status 011 +``` +cmd/kasmos/ Cobra CLI entry point, flags, setup subcommand +internal/tui/ Bubble Tea model/update/view, layout, keymap, dialogs, daemon logging +internal/worker/ Backend interface, subprocess backend (opencode), output buffering +internal/task/ Source interface + spec-kitty, GSD, ad-hoc adapters +internal/persist/ Session snapshot model and persistence +internal/setup/ Dependency validation and agent scaffold generation ``` -**Run as MCP server (used internally by the manager agent):** +## Build and Test ```sh -kasmos serve +go build ./cmd/kasmos +go test ./... +go vet ./... ``` - -### Configuration - -Kasmos loads config from `kasmos.toml` in the repo root, with `KASMOS_*` environment variable overrides. Key sections: - -| Section | Controls | -|---------|----------| -| `[agent]` | `max_parallel_workers`, `opencode_binary`, `opencode_profile`, `review_rejection_cap` | -| `[session]` | `session_name`, `manager_width_pct`, `message_log_width_pct` | -| `[paths]` | `zellij_binary`, `spec_kitty_binary`, `specs_root` | -| `[communication]` | `poll_interval_secs`, `event_timeout_secs` | -| `[audit]` | `metadata_only`, `debug_full_payload`, `max_bytes`, `max_age_days` | -| `[lock]` | `stale_timeout_minutes` | - -## Architecture - -- Zellij hosts the session/tab/pane layout -- `kasmos` provides launch, setup, status, and MCP tool handlers -- Manager/worker agents communicate through the message log protocol -- Workflow and lock state are derived from spec-kitty artifacts plus lock files - -## Dependencies - -kasmos requires these external tools at runtime. `kasmos setup` validates most of these automatically. - -### Required binaries - -| Tool | Purpose | Install | -|------|---------|---------| -| `zellij` | Terminal multiplexer hosting all sessions/panes | [zellij.dev](https://zellij.dev/documentation/installation) | -| `ocx` / OpenCode | AI agent launcher | Project docs | -| `spec-kitty` | Feature/task lifecycle management | [spec-kitty docs](https://github.com/theslyprofessor/spec-kitty) | -| `git` | Repository and worktree management | System package manager | -| `bun` | Runs the pane-tracker MCP server | [bun.sh](https://bun.sh) | - -### Required Zellij plugins - -Install to `~/.config/zellij/plugins/`: - -| Plugin | Purpose | Source | -|--------|---------|--------| -| `zjstatus.wasm` | Status bar in generated layouts | [github.com/dj95/zjstatus](https://github.com/dj95/zjstatus/releases) | -| `zellij-pane-tracker.wasm` | Pane metadata tracking for agent coordination | [github.com/theslyprofessor/zellij-pane-tracker](https://github.com/theslyprofessor/zellij-pane-tracker) | - -### Companion projects - -| Project | Purpose | Default location | -|---------|---------|-----------------| -| zellij-pane-tracker (repo checkout) | MCP server for inter-agent pane communication | Configurable via `kasmos.toml` `[paths].pane_tracker_dir` | - -> **Note:** `kasmos setup` auto-detects the pane-tracker installation directory and writes it into `.opencode/opencode.jsonc`. Override with `[paths].pane_tracker_dir` in `kasmos.toml` or `KASMOS_PATHS_PANE_TRACKER_DIR` env var. - -## Build And Test - -- `cargo build` -- `cargo test` -- `cargo clippy -p kasmos -- -D warnings` - -For feature-specific flow examples, see `kitty-specs/011-mcp-agent-swarm-orchestration/quickstart.md`. diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index 6220508..ee2af71 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -40,7 +40,7 @@ func newRootCmd() *cobra.Command { Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if showVersion { - fmt.Fprintln(cmd.OutOrStdout(), "kasmos v0.1.0") + fmt.Fprintln(cmd.OutOrStdout(), "kasmos v2.0.0") return nil } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 82ec9e5..2120118 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -1,6 +1,8 @@ package tui import ( + "strings" + "github.com/charmbracelet/bubbles/v2/key" "github.com/user/kasmos/internal/task" @@ -175,6 +177,33 @@ func (m *Model) updateKeyStates() { m.keys.Down.SetEnabled(true) m.keys.Back.SetEnabled(true) + if m.analysisMode { + m.keys.Spawn.SetEnabled(false) + m.keys.Kill.SetEnabled(false) + m.keys.Continue.SetEnabled(false) + m.keys.Batch.SetEnabled(false) + m.keys.Fullscreen.SetEnabled(false) + m.keys.ScrollDown.SetEnabled(false) + m.keys.ScrollUp.SetEnabled(false) + m.keys.HalfDown.SetEnabled(false) + m.keys.HalfUp.SetEnabled(false) + m.keys.GotoBottom.SetEnabled(false) + m.keys.GotoTop.SetEnabled(false) + m.keys.Search.SetEnabled(false) + m.keys.GenPrompt.SetEnabled(false) + m.keys.Analyze.SetEnabled(false) + m.keys.Filter.SetEnabled(false) + m.keys.Select.SetEnabled(false) + m.keys.NextPanel.SetEnabled(false) + m.keys.PrevPanel.SetEnabled(false) + m.keys.Up.SetEnabled(false) + m.keys.Down.SetEnabled(false) + m.keys.Restart.SetEnabled( + m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "", + ) + return + } + selected := m.selectedWorker() // Worker action keys diff --git a/internal/tui/layout.go b/internal/tui/layout.go index 60f389e..9e3748f 100644 --- a/internal/tui/layout.go +++ b/internal/tui/layout.go @@ -20,7 +20,7 @@ const ( ) func (m *Model) recalculateLayout() { - if m.width < 80 || m.height < 24 { + if m.width < 60 || m.height < 15 { m.layoutMode = layoutTooSmall return } @@ -54,7 +54,7 @@ func (m *Model) recalculateLayout() { m.tasksOuterWidth = 0 m.tasksOuterHeight = 0 - case m.width >= 80: + case m.width >= 60: m.layoutMode = layoutNarrow m.tableOuterWidth = m.width diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 87754de..7f6385d 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -5,7 +5,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" - "github.com/user/kasmos/internal/task" "github.com/user/kasmos/internal/worker" ) @@ -66,10 +65,6 @@ type quitConfirmedMsg struct{} type quitCancelledMsg struct{} -type analyzeStartedMsg struct { - WorkerID string -} - type analyzeCompletedMsg struct { WorkerID string RootCause string @@ -77,43 +72,12 @@ type analyzeCompletedMsg struct { Err error } -type genPromptStartedMsg struct { - TaskID string -} - type genPromptCompletedMsg struct { TaskID string Prompt string Err error } -// tasksLoadedMsg is sent when a task source finishes loading. -type tasksLoadedMsg struct { - Source string - Path string - Tasks []task.Task - Err error -} - -// taskStateChangedMsg is sent when a task's state changes. -type taskStateChangedMsg struct { - TaskID string - NewState task.TaskState - WorkerID string -} - -// sessionSavedMsg is sent when session persistence completes. -type sessionSavedMsg struct { - Path string - Err error -} - -// sessionLoadedMsg is sent when a session is restored from disk. -type sessionLoadedMsg struct { - Path string - Err error -} - func tickCmd() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) diff --git a/internal/tui/model.go b/internal/tui/model.go index 4aa1ef2..c9243cd 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -177,7 +177,7 @@ func (m *Model) buildSessionState() persist.SessionState { StartedAt: time.Now(), TaskSource: ts, Workers: snapshots, - NextWorkerNum: int64(len(workers) + 1), + NextWorkerNum: m.manager.Counter(), PID: os.Getpid(), } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..358e3df --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,337 @@ +package tui + +import ( + "encoding/json" + "testing" + "time" + + "github.com/user/kasmos/internal/worker" +) + +func TestNewModelDefaults(t *testing.T) { + m := NewModel(nil, nil) + + if m == nil { + t.Fatal("NewModel returned nil") + } + if m.focused != panelTable { + t.Fatalf("focused panel mismatch: got=%v want=%v", m.focused, panelTable) + } + if m.layoutMode != layoutTooSmall { + t.Fatalf("layout mode mismatch: got=%v want=%v", m.layoutMode, layoutTooSmall) + } + if len(m.workers) != 0 { + t.Fatalf("expected empty workers, got=%d", len(m.workers)) + } + if m.manager == nil { + t.Fatal("manager was not initialized") + } + if len(m.keys.Spawn.Keys()) == 0 || len(m.keys.Kill.Keys()) == 0 || len(m.keys.Help.Keys()) == 0 { + t.Fatal("expected key bindings to be initialized") + } +} + +func TestRecalculateLayoutBreakpoints(t *testing.T) { + tests := []struct { + name string + width int + height int + taskSourceType string + wantMode layoutMode + assert func(t *testing.T, m *Model) + }{ + { + name: "too small", + width: 59, + height: 20, + wantMode: layoutTooSmall, + }, + { + name: "narrow", + width: 60, + height: 20, + wantMode: layoutNarrow, + assert: func(t *testing.T, m *Model) { + t.Helper() + contentHeight := max(0, m.height-m.chromeHeight()) + if m.tableOuterWidth != m.width { + t.Fatalf("table outer width mismatch: got=%d want=%d", m.tableOuterWidth, m.width) + } + if m.viewportOuterWidth != m.width { + t.Fatalf("viewport outer width mismatch: got=%d want=%d", m.viewportOuterWidth, m.width) + } + if m.tableOuterHeight != int(float64(contentHeight)*0.45) { + t.Fatalf("table outer height mismatch: got=%d", m.tableOuterHeight) + } + if m.viewportOuterHeight != contentHeight-m.tableOuterHeight { + t.Fatalf("viewport outer height mismatch: got=%d want=%d", m.viewportOuterHeight, contentHeight-m.tableOuterHeight) + } + }, + }, + { + name: "standard", + width: 120, + height: 20, + wantMode: layoutStandard, + assert: func(t *testing.T, m *Model) { + t.Helper() + if m.tableOuterWidth != int(float64(m.width)*0.40) { + t.Fatalf("table outer width mismatch: got=%d", m.tableOuterWidth) + } + if m.viewportOuterWidth != m.width-m.tableOuterWidth-1 { + t.Fatalf("viewport outer width mismatch: got=%d want=%d", m.viewportOuterWidth, m.width-m.tableOuterWidth-1) + } + }, + }, + { + name: "wide with task source", + width: 180, + height: 24, + taskSourceType: "spec-kitty", + wantMode: layoutWide, + assert: func(t *testing.T, m *Model) { + t.Helper() + available := max(0, m.width-2) + wantTasks := int(float64(available) * 0.25) + wantTable := int(float64(available) * 0.35) + wantViewport := available - wantTasks - wantTable + if m.tasksOuterWidth != wantTasks { + t.Fatalf("tasks outer width mismatch: got=%d want=%d", m.tasksOuterWidth, wantTasks) + } + if m.tableOuterWidth != wantTable { + t.Fatalf("table outer width mismatch: got=%d want=%d", m.tableOuterWidth, wantTable) + } + if m.viewportOuterWidth != wantViewport { + t.Fatalf("viewport outer width mismatch: got=%d want=%d", m.viewportOuterWidth, wantViewport) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(nil, nil) + m.width = tt.width + m.height = tt.height + m.taskSourceType = tt.taskSourceType + + m.recalculateLayout() + + if m.layoutMode != tt.wantMode { + t.Fatalf("layout mode mismatch: got=%v want=%v", m.layoutMode, tt.wantMode) + } + + if m.layoutMode != layoutTooSmall { + if m.tableOuterWidth < 0 || m.tableOuterHeight < 0 || m.viewportOuterWidth < 0 || m.viewportOuterHeight < 0 || m.tasksOuterWidth < 0 || m.tasksOuterHeight < 0 { + t.Fatal("expected non-negative outer dimensions") + } + if m.tableInnerWidth < 1 || m.tableInnerHeight < 1 || m.viewportInnerWidth < 1 || m.viewportInnerHeight < 1 { + t.Fatal("expected positive inner dimensions for table and viewport") + } + if m.tasksInnerWidth < 0 || m.tasksInnerHeight < 0 { + t.Fatal("expected non-negative tasks inner dimensions") + } + } + + if tt.assert != nil { + tt.assert(t, m) + } + }) + } +} + +func TestUpdateKeyStates(t *testing.T) { + tests := []struct { + name string + setup func(*Model) + assert func(*testing.T, *Model) + }{ + { + name: "no selected worker", + setup: func(m *Model) { + m.selectedWorkerID = "" + }, + assert: func(t *testing.T, m *Model) { + t.Helper() + if !m.keys.Spawn.Enabled() { + t.Fatal("spawn should be enabled") + } + if m.keys.Kill.Enabled() || m.keys.Continue.Enabled() || m.keys.Restart.Enabled() || m.keys.Analyze.Enabled() { + t.Fatal("kill/continue/restart/analyze should be disabled") + } + }, + }, + { + name: "running worker selected", + setup: func(m *Model) { + m.manager.Add(&worker.Worker{ID: "w-001", State: worker.StateRunning}) + m.selectedWorkerID = "w-001" + }, + assert: func(t *testing.T, m *Model) { + t.Helper() + if !m.keys.Kill.Enabled() { + t.Fatal("kill should be enabled for running worker") + } + if m.keys.Restart.Enabled() { + t.Fatal("restart should be disabled for running worker") + } + }, + }, + { + name: "failed worker selected", + setup: func(m *Model) { + m.manager.Add(&worker.Worker{ID: "w-002", State: worker.StateFailed}) + m.selectedWorkerID = "w-002" + }, + assert: func(t *testing.T, m *Model) { + t.Helper() + if !m.keys.Analyze.Enabled() { + t.Fatal("analyze should be enabled for failed worker") + } + if !m.keys.Restart.Enabled() { + t.Fatal("restart should be enabled for failed worker") + } + }, + }, + { + name: "analysis mode", + setup: func(m *Model) { + m.manager.Add(&worker.Worker{ID: "w-003", State: worker.StateFailed}) + m.selectedWorkerID = "w-003" + m.analysisMode = true + m.analysisResult = &AnalysisResult{WorkerID: "w-003", RootCause: "failure"} + }, + assert: func(t *testing.T, m *Model) { + t.Helper() + if !m.keys.Back.Enabled() { + t.Fatal("back should be enabled in analysis mode") + } + if m.keys.Spawn.Enabled() || m.keys.Kill.Enabled() || m.keys.Continue.Enabled() || m.keys.Analyze.Enabled() || m.keys.Fullscreen.Enabled() || m.keys.NextPanel.Enabled() || m.keys.PrevPanel.Enabled() { + t.Fatal("most non-back actions should be disabled in analysis mode") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewModel(nil, nil) + tt.setup(m) + m.updateKeyStates() + tt.assert(t, m) + }) + } +} + +func TestSelectionAndViewportNoPanicOnEmptyState(t *testing.T) { + m := NewModel(nil, nil) + + mustNotPanic(t, "syncSelectionFromTable", func() { + m.syncSelectionFromTable() + }) + + mustNotPanic(t, "refreshViewportFromSelected", func() { + m.refreshViewportFromSelected(false) + }) +} + +func TestBuildSessionStateUsesManagerCounter(t *testing.T) { + m := NewModel(nil, nil) + m.sessionID = "ks-test" + m.manager.ResetWorkerCounter(41) + m.manager.Add(&worker.Worker{ID: "w-003", Role: "coder", State: worker.StateRunning}) + m.manager.Add(&worker.Worker{ID: "w-010", Role: "reviewer", State: worker.StateFailed, ExitCode: 1}) + + state := m.buildSessionState() + + if state.SessionID != "ks-test" { + t.Fatalf("session id mismatch: got=%q want=%q", state.SessionID, "ks-test") + } + if state.NextWorkerNum != 41 { + t.Fatalf("next worker number mismatch: got=%d want=%d", state.NextWorkerNum, 41) + } + if len(state.Workers) != 2 { + t.Fatalf("workers length mismatch: got=%d want=%d", len(state.Workers), 2) + } +} + +func TestDaemonEventFormatting(t *testing.T) { + ts := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + + t.Run("ndjson", func(t *testing.T) { + e := DaemonEvent{ + Timestamp: ts, + Event: "worker_exit", + Fields: map[string]string{ + "id": "w-001", + "code": "1", + "duration": "12s", + "session": "sess-abc", + }, + } + + var got map[string]string + if err := json.Unmarshal([]byte(e.JSONString()), &got); err != nil { + t.Fatalf("unmarshal JSONString: %v", err) + } + + if got["ts"] != ts.Format(time.RFC3339) { + t.Fatalf("timestamp mismatch: got=%q want=%q", got["ts"], ts.Format(time.RFC3339)) + } + if got["event"] != "worker_exit" || got["id"] != "w-001" || got["code"] != "1" || got["duration"] != "12s" || got["session"] != "sess-abc" { + t.Fatalf("unexpected JSON fields: %#v", got) + } + }) + + humanTests := []struct { + name string + e DaemonEvent + want string + }{ + { + name: "session start", + e: DaemonEvent{ + Timestamp: ts, + Event: "session_start", + Fields: map[string]string{ + "mode": "spec-kitty", + "source": "kitty-specs/feature.md", + "task_count": "3", + }, + }, + want: "[03:04:05] session started mode=spec-kitty source=kitty-specs/feature.md tasks=3", + }, + { + name: "worker exit", + e: DaemonEvent{ + Timestamp: ts, + Event: "worker_exit", + Fields: map[string]string{ + "id": "w-007", + "code": "2", + "duration": "33s", + "session": "sess-42", + }, + }, + want: "[03:04:05] w-007 exited(2) 33s sess-42", + }, + } + + for _, tt := range humanTests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.e.HumanString(); got != tt.want { + t.Fatalf("human format mismatch:\ngot: %q\nwant: %q", got, tt.want) + } + }) + } +} + +func mustNotPanic(t *testing.T, name string, fn func()) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Fatalf("%s panicked: %v", name, r) + } + }() + fn() +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 6530bd7..aabf31f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -479,29 +479,35 @@ func (m *Model) setViewportContent(content string, autoFollow bool) { } } -func (m *Model) updateFullScreenKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.analysisMode { - if key.Matches(msg, m.keys.Back) { - m.analysisMode = false - m.analysisResult = nil - m.updateKeyStates() - m.refreshViewportFromSelected(false) - return m, nil - } +// handleAnalysisModeKeys handles key events when analysis mode is active. +// Returns the model, command, and whether the key was consumed. +func (m *Model) handleAnalysisModeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, m.keys.Back) { + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + m.refreshViewportFromSelected(false) + return m, nil + } - if key.Matches(msg, m.keys.Restart) && m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "" { - role := "coder" - if w := m.manager.Get(m.analysisResult.WorkerID); w != nil { - role = w.Role - } - suggestedPrompt := m.analysisResult.SuggestedPrompt - m.analysisMode = false - m.analysisResult = nil - m.updateKeyStates() - return m, m.openSpawnDialogWithPrefill(role, suggestedPrompt, nil) + if key.Matches(msg, m.keys.Restart) && m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "" { + role := "coder" + if w := m.manager.Get(m.analysisResult.WorkerID); w != nil { + role = w.Role } + suggestedPrompt := m.analysisResult.SuggestedPrompt + m.analysisMode = false + m.analysisResult = nil + m.updateKeyStates() + return m, m.openSpawnDialogWithPrefill(role, suggestedPrompt, nil) + } - return m, nil + return m, nil +} + +func (m *Model) updateFullScreenKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.analysisMode { + return m.handleAnalysisModeKeys(msg) } if key.Matches(msg, m.keys.Back) { @@ -534,27 +540,7 @@ func (m *Model) updateFullScreenKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) updateTableKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.analysisMode { - if key.Matches(msg, m.keys.Back) { - m.analysisMode = false - m.analysisResult = nil - m.updateKeyStates() - m.refreshViewportFromSelected(false) - return m, nil - } - - if key.Matches(msg, m.keys.Restart) && m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "" { - role := "coder" - if w := m.manager.Get(m.analysisResult.WorkerID); w != nil { - role = w.Role - } - suggestedPrompt := m.analysisResult.SuggestedPrompt - m.analysisMode = false - m.analysisResult = nil - m.updateKeyStates() - return m, m.openSpawnDialogWithPrefill(role, suggestedPrompt, nil) - } - - return m, nil + return m.handleAnalysisModeKeys(msg) } if key.Matches(msg, m.keys.Spawn) { @@ -680,27 +666,7 @@ func (m *Model) updateTaskPanelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) updateViewportKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.analysisMode { - if key.Matches(msg, m.keys.Back) { - m.analysisMode = false - m.analysisResult = nil - m.updateKeyStates() - m.refreshViewportFromSelected(false) - return m, nil - } - - if key.Matches(msg, m.keys.Restart) && m.analysisResult != nil && strings.TrimSpace(m.analysisResult.SuggestedPrompt) != "" { - role := "coder" - if w := m.manager.Get(m.analysisResult.WorkerID); w != nil { - role = w.Role - } - suggestedPrompt := m.analysisResult.SuggestedPrompt - m.analysisMode = false - m.analysisResult = nil - m.updateKeyStates() - return m, m.openSpawnDialogWithPrefill(role, suggestedPrompt, nil) - } - - return m, nil + return m.handleAnalysisModeKeys(msg) } if key.Matches(msg, m.keys.Fullscreen) { diff --git a/internal/worker/manager.go b/internal/worker/manager.go index 8a124f7..0995cd6 100644 --- a/internal/worker/manager.go +++ b/internal/worker/manager.go @@ -21,6 +21,10 @@ func (m *WorkerManager) NextWorkerID() string { return fmt.Sprintf("w-%03d", n) } +func (m *WorkerManager) Counter() int64 { + return m.counter.Load() +} + func (m *WorkerManager) ResetWorkerCounter(n int64) { m.counter.Store(n) } diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md index ce1dcd4..f5077d2 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP12-daemon-mode.md @@ -1,7 +1,7 @@ --- work_package_id: WP12 title: Daemon Mode (Headless Operation) -lane: doing +lane: done dependencies: - WP04 subtasks: @@ -27,6 +27,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP12 coder - daemon mode) +- timestamp: '2026-02-18T14:59:22.317571864+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented - daemon mode with event logging + batch execution) --- # Work Package Prompt: WP12 - Daemon Mode (Headless Operation) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md index d074116..86a6815 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP13-session-persistence.md @@ -1,7 +1,7 @@ --- work_package_id: WP13 title: Session Persistence + Reattach -lane: doing +lane: done dependencies: - WP04 subtasks: @@ -28,6 +28,11 @@ history: actor: manager shell_pid: '472734' action: transition active (Launching WP13 coder - session persistence + reattach) +- timestamp: '2026-02-18T14:59:23.490309483+00:00' + lane: done + actor: manager + shell_pid: '472734' + action: transition done (Implemented - session persistence + reattach + orphan detection) --- # Work Package Prompt: WP13 - Session Persistence + Reattach From 8dc1e68e65c874f171f917027172bc7c07dd12a5 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 09:51:49 -0600 Subject: [PATCH 15/26] fix: update TUI header version to v2.0.0 --- internal/tui/panels.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 7900437..6d7a42e 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -10,7 +10,7 @@ import ( "github.com/user/kasmos/internal/worker" ) -const appVersion = "v0.1.0" +const appVersion = "v2.0.0" func (m *Model) renderHeader() string { title := " " + renderGradientTitle("kasmos") + " " + dimSubtitleStyle.Render("agent orchestrator") From 52f925d253349532c2be5284eed5cd25b03d6cb5 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 09:55:27 -0600 Subject: [PATCH 16/26] fix: complete help view keybindings (add HalfDown/HalfUp, trim ShortHelp) --- internal/tui/keys.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2120118..e2a1dd2 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -149,10 +149,9 @@ func defaultKeyMap() keyMap { func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{ - k.Spawn, k.Kill, k.Continue, k.Restart, - k.Fullscreen, k.ScrollDown, k.ScrollUp, - k.GotoBottom, k.GotoTop, - k.NextPanel, k.Help, k.Quit, + k.Spawn, k.Kill, k.Restart, k.Continue, + k.NextPanel, k.Fullscreen, + k.Help, k.Quit, } } @@ -160,8 +159,8 @@ func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Up, k.Down, k.NextPanel, k.PrevPanel, k.Select, k.Back}, {k.Spawn, k.Kill, k.Continue, k.Restart, k.Batch, k.GenPrompt, k.Analyze}, - {k.Fullscreen, k.ScrollDown, k.ScrollUp, k.GotoBottom, k.GotoTop, k.Search}, - {k.Help, k.Quit, k.ForceQuit, k.Filter}, + {k.Fullscreen, k.ScrollDown, k.ScrollUp, k.HalfDown, k.HalfUp, k.GotoBottom, k.GotoTop}, + {k.Help, k.Quit, k.ForceQuit}, } } From fdcc7a5e1f335ea0892fcafe01892d27e9357817 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:05:18 -0600 Subject: [PATCH 17/26] tasks(016): WP14 new spec/plan dialog (n key) + WP15 history view (h key) --- .../tasks/WP14-new-spec-dialog.md | 295 +++++++++++++++ .../tasks/WP15-history-view.md | 355 ++++++++++++++++++ 2 files changed, 650 insertions(+) create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP15-history-view.md diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md new file mode 100644 index 0000000..e152a44 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md @@ -0,0 +1,295 @@ +--- +work_package_id: WP14 +title: New Spec/Plan Dialog (n key) +lane: planned +dependencies: +- WP03 +- WP08 +subtasks: +- Add n key binding to keyMap +- Type picker overlay (Feature Spec / GSD / Planning) +- Feature Spec form (slug, mission) -> spec-kitty agent feature create-feature +- GSD form (filename, initial tasks) -> write checkbox .md file +- Planning form (title, description) -> create freeform planning doc +- Auto-load new source into dashboard after creation +- specCreateCmd for subprocess execution of spec-kitty +phase: Wave 4 - Dashboard Enhancements +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' +history: +- timestamp: '2026-02-18T00:00:00Z' + lane: planned + agent: planner + action: Specified by user request +--- + +# Work Package Prompt: WP14 - New Spec/Plan Dialog (`n` key) + +## Mission + +Implement a two-stage dialog triggered by the `n` key that lets users create new +specs and plans without leaving the dashboard. Stage 1 is a type picker (Feature +Spec, GSD Task List, Planning). Stage 2 is a type-specific form that creates the +artifact and optionally loads it as the active task source. + +## Scope + +### Files to Create + +``` +internal/tui/newdialog.go # Type picker model + type-specific form models +``` + +### Files to Modify + +``` +internal/tui/keys.go # Add New key binding (n) +internal/tui/model.go # New dialog state fields +internal/tui/update.go # New dialog message handlers, key routing +internal/tui/messages.go # New dialog messages +internal/tui/commands.go # specCreateCmd, gsdCreateCmd +internal/tui/styles.go # Picker/form styles (reuse existing dialog palette) +``` + +### External Dependencies + +- `spec-kitty agent feature create-feature --mission --json` + Creates a feature directory under `kitty-specs/` and returns JSON with the path. +- Available missions (from `spec-kitty mission list`): + - `software-dev` — Software Dev Kitty + - `documentation` — Documentation Kitty + - `research` — Deep Research Kitty + +## Implementation + +### Key Binding + +Add `New` to keyMap: + +```go +New: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "new spec/plan"), +), +``` + +Enable when no overlay is active. Add to ShortHelp and FullHelp. + +### Stage 1: Type Picker Overlay + +A simple vertical list of 3 options rendered as a centered overlay (same pattern +as quit confirm dialog): + +``` + New Spec/Plan + ───────────── + > Feature Spec create a spec-kitty feature with research + planning + GSD Task List create a checkbox task markdown file + Planning Doc create a freeform planning document + + ↑/↓ select · enter confirm · esc cancel +``` + +Model fields: + +```go +showNewDialog bool +newDialogStage int // 0 = picker, 1 = form +newDialogType string // "feature-spec", "gsd", "planning" +newDialogPicker int // selected index in picker (0-2) +newForm *newFormModel // stage 2 form (type depends on newDialogType) +``` + +Key handling in picker: +- `up`/`down` or `j`/`k` — move selection +- `enter` — advance to stage 2 form +- `esc` — cancel and close + +### Stage 2a: Feature Spec Form + +Uses raw textinput components (same pattern as spawn dialog): + +``` + New Feature Spec + ──────────────── + Slug: [my-new-feature_____________] + Mission: [software-dev_______________] + + Slug is the feature identifier (e.g. "user-auth", "api-refactor"). + Mission: software-dev | documentation | research + + enter submit · tab next field · esc cancel +``` + +Fields: +- **Slug** (required): textinput, validated non-empty, kebab-case +- **Mission** (required): textinput with default "software-dev", validated against + known missions (`software-dev`, `documentation`, `research`) + +On submit: +1. Run `spec-kitty agent feature create-feature --mission --json` + via a tea.Cmd (subprocess, like analyzeCmd pattern) +2. Parse JSON response for the created feature path +3. Emit `specCreatedMsg{Path, Slug, Err}` +4. On success: load the new feature dir as a spec-kitty task source, replacing + the current source. Update `m.taskSource`, `m.taskSourceType`, `m.taskSourcePath`, + `m.loadedTasks`. Recalculate layout (task panel may appear/disappear). +5. On error: show error in viewport + +### Stage 2b: GSD Task List Form + +``` + New GSD Task List + ───────────────── + Filename: [tasks.md______________________] + Tasks: (one per line, blank to finish) + ┌──────────────────────────────────────────┐ + │ Set up database schema │ + │ Implement user authentication │ + │ Write API endpoint tests │ + │ │ + └──────────────────────────────────────────┘ + + enter submit · tab next field · esc cancel +``` + +Fields: +- **Filename** (required): textinput, default "tasks.md" +- **Tasks** (required): textarea, one task per line + +On submit: +1. Write the file to disk as checkbox markdown: + ```markdown + - [ ] Set up database schema + - [ ] Implement user authentication + - [ ] Write API endpoint tests + ``` +2. Emit `gsdCreatedMsg{Path, TaskCount, Err}` +3. On success: load the new file as a GSD task source + +### Stage 2c: Planning Doc Form + +``` + New Planning Doc + ──────────────── + Filename: [plan.md_______________________] + Title: [API Refactor Plan_____________] + Content: (freeform planning notes) + ┌──────────────────────────────────────────┐ + │ Goals: │ + │ - Migrate to v3 API endpoints │ + │ - Deprecate legacy handlers │ + │ │ + └──────────────────────────────────────────┘ + + enter submit · tab next field · esc cancel +``` + +Fields: +- **Filename** (required): textinput, default "plan.md" +- **Title** (required): textinput +- **Content** (optional): textarea + +On submit: +1. Write a markdown file with `# {Title}` header and content body +2. Emit `planCreatedMsg{Path, Err}` +3. Planning docs do NOT auto-load as task source (they're freeform reference docs) +4. Show confirmation in viewport: "Created plan.md" + +### Messages + +```go +type newDialogPickedMsg struct{ Type string } +type newDialogCancelledMsg struct{} + +type specCreatedMsg struct { + Slug string + Path string + Err error +} + +type gsdCreatedMsg struct { + Path string + TaskCount int + Err error +} + +type planCreatedMsg struct { + Path string + Err error +} +``` + +### Commands + +```go +func specCreateCmd(slug, mission string) tea.Cmd { + // exec: spec-kitty agent feature create-feature --mission --json + // parse JSON output for feature path +} + +func gsdCreateCmd(path string, tasks []string) tea.Cmd { + // write checkbox markdown to path +} + +func planCreateCmd(path, title, content string) tea.Cmd { + // write markdown to path +} +``` + +### Source Hot-Swap + +When a new source is created and loaded, the Model needs to: +1. Replace `m.taskSource` with the new Source +2. Reset `m.loadedTasks`, `m.selectedTaskIdx`, `m.taskSourceType`, `m.taskSourcePath` +3. Call `m.recalculateLayout()` (task panel may now appear in wide mode) +4. Call `m.updateKeyStates()` +5. Trigger persist + +Add a helper method: + +```go +func (m *Model) swapTaskSource(source task.Source) { + m.taskSource = source + m.taskSourceType = source.Type() + m.taskSourcePath = source.Path() + m.selectedTaskIdx = 0 + if source.Type() != "ad-hoc" { + if tasks, err := source.Load(); err == nil { + m.loadedTasks = tasks + } + } else { + m.loadedTasks = nil + } + m.recalculateLayout() + m.updateKeyStates() + m.triggerPersist() +} +``` + +## What NOT to Do + +- Do NOT call `spec-kitty specify` interactively — use `agent feature create-feature` + which is the programmatic/non-interactive API +- Do NOT implement mission switching after creation — just set it at creation time +- Do NOT validate slug format beyond non-empty (spec-kitty handles validation) +- Do NOT block the TUI during subprocess execution — use tea.Cmd async pattern +- Do NOT auto-run `spec-kitty research` after feature creation — that's a separate + workflow the user triggers manually or via a worker + +## Acceptance Criteria + +1. Press `n` — type picker overlay appears with 3 options +2. Select "Feature Spec", fill slug + mission, submit — spec-kitty creates the feature + dir and it loads as task source +3. Select "GSD Task List", fill filename + tasks, submit — markdown file created and + loaded as GSD source +4. Select "Planning Doc", fill fields, submit — markdown file created, confirmation shown +5. `esc` at any stage cancels and closes the dialog +6. Error from spec-kitty CLI shown in viewport (not a crash) +7. `n` disabled when any other overlay is active +8. `go test ./...` passes +9. `go build ./cmd/kasmos` passes diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP15-history-view.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP15-history-view.md new file mode 100644 index 0000000..b992a20 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP15-history-view.md @@ -0,0 +1,355 @@ +--- +work_package_id: WP15 +title: History View (h key) +lane: planned +dependencies: +- WP03 +- WP08 +- WP13 +subtasks: +- Add h key binding to keyMap +- Session archiving on exit (move session.json to sessions/{id}.json) +- History scanner (kitty-specs, GSD files, archived sessions) +- History overlay with unified list +- Detail view for selected history entry +- Load from history as active task source +phase: Wave 4 - Dashboard Enhancements +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' +history: +- timestamp: '2026-02-18T00:00:00Z' + lane: planned + agent: planner + action: Specified by user request +--- + +# Work Package Prompt: WP15 - History View (`h` key) + +## Mission + +Implement a history overlay triggered by the `h` key that shows all past work +across all three source types: spec-kitty features, GSD task files, and archived +ad-hoc sessions. Users can browse history, view details, and reload a past source +into the active dashboard. + +## Scope + +### Files to Create + +``` +internal/tui/history.go # History overlay model, scanning, rendering +internal/history/scanner.go # History entry scanning across all source types +internal/history/scanner_test.go +``` + +### Files to Modify + +``` +internal/tui/keys.go # Add History key binding (h) +internal/tui/model.go # History overlay state fields +internal/tui/update.go # History message handlers, key routing +internal/tui/messages.go # History messages +internal/tui/styles.go # History entry styles (type badges, status colors) +internal/persist/session.go # Archive session on close, list archived sessions +internal/persist/schema.go # FinishedAt field for archive metadata +``` + +## Implementation + +### Session Archiving + +Currently `.kasmos/session.json` is a single file overwritten each save. To +support ad-hoc history, archive sessions when they end. + +Add to `SessionPersister`: + +```go +func (p *SessionPersister) Archive(state SessionState) error { + // Write to .kasmos/sessions/{session_id}.json + // Add FinishedAt timestamp to the state +} + +func (p *SessionPersister) ListArchived() ([]SessionState, error) { + // Glob .kasmos/sessions/*.json, parse each, return sorted by StartedAt desc +} +``` + +Add `FinishedAt` to `SessionState`: + +```go +type SessionState struct { + // ... existing fields ... + FinishedAt *time.Time `json:"finished_at,omitempty"` +} +``` + +Archive trigger: when the TUI exits cleanly (quit confirmed or daemon complete), +call `p.Archive(finalState)` before exit. This preserves the session for history. + +Directory structure: + +``` +.kasmos/ + session.json # current/active session (existing) + sessions/ + ks-1708123456-a1b2.json # archived sessions + ks-1708234567-c3d4.json +``` + +### History Scanner (`internal/history/`) + +A standalone package that scans all three source types and returns a unified list: + +```go +package history + +type EntryType string + +const ( + EntrySpecKitty EntryType = "spec-kitty" + EntryGSD EntryType = "gsd" + EntryAdHoc EntryType = "ad-hoc" +) + +type Entry struct { + Type EntryType + Name string // feature slug, filename, or session ID + Path string // directory or file path + Date time.Time // creation or last activity date + Status string // "complete", "in-progress", "planned", etc. + TaskCount int // total tasks/WPs + DoneCount int // completed tasks/WPs + WorkerCount int // workers spawned (ad-hoc sessions) + Summary string // one-line description +} + +func Scan(specsRoot string, kasmosDir string) ([]Entry, error) +``` + +**Spec-kitty scanning**: +- Glob `kitty-specs/*/tasks/WP*.md` +- Group by parent feature directory +- For each feature: read WP frontmatter to count total/done tasks +- Feature name: directory basename (e.g. `016-kasmos-agent-orchestrator`) +- Status: "complete" if all WPs are `lane: done`, "in-progress" if any are + `lane: doing`, "planned" otherwise +- Date: use git commit date of the feature directory or file mtime as fallback + +**GSD scanning**: +- Glob common locations: `*.md`, `tasks/*.md`, `todo/*.md` +- For each file: attempt to parse as GSD (look for `- [ ]` / `- [x]` lines) +- Skip files with 0 checkbox lines (not a GSD file) +- Count total/done from checkboxes +- Date: file mtime +- Skip the file currently loaded as active task source (it's "current", not history) + +**Ad-hoc scanning**: +- List `.kasmos/sessions/*.json` +- Parse each for session metadata +- Name: session ID +- Worker count / status from worker snapshots +- Status: "complete" if all workers exited/done, "partial" otherwise +- Date: `started_at` from session state +- Skip active session (matching current PID) + +### Key Binding + +Add `History` to keyMap: + +```go +History: key.NewBinding( + key.WithKeys("h"), + key.WithHelp("h", "history"), +), +``` + +Enable when no overlay is active. Disable in fullscreen mode. +Add to ShortHelp and FullHelp. + +### History Overlay + +Full-screen overlay (like help, but with interactive list): + +``` + History + ─────── + + TYPE NAME DATE STATUS PROGRESS + > spec-kitty 016-kasmos-agent-orchestrator Feb 18 complete 13/13 WPs + gsd api-tasks.md Feb 15 in-progress 4/7 tasks + ad-hoc ks-1708123456-a1b2 Feb 12 complete 3 workers + spec-kitty 015-auth-refactor Feb 10 planned 0/8 WPs + gsd bugfixes.md Feb 08 complete 5/5 tasks + ad-hoc ks-1708012345-e5f6 Feb 05 partial 2 workers + + ↑/↓ select · enter load as source · d detail · esc close +``` + +Model fields: + +```go +showHistory bool +historyEntries []history.Entry +historySelected int +historyDetail bool // showing detail view for selected entry +historyLoading bool // scanning in progress +``` + +### Rendering + +Each entry row: +- **Type badge**: colored label using existing `roleBadge` pattern + - `spec-kitty` = magenta + - `gsd` = cyan + - `ad-hoc` = yellow +- **Name**: truncated to fit column width +- **Date**: relative or short format (e.g. "Feb 18", "2d ago") +- **Status badge**: colored like `taskStatusBadge` + - `complete` = green + - `in-progress` = blue + - `planned` = gray + - `partial` = yellow +- **Progress**: "N/M WPs", "N/M tasks", or "N workers" + +Use `j`/`k` or `up`/`down` to navigate. + +### Detail View + +Press `d` or `enter` on a selected entry to show details: + +**Spec-kitty detail**: +``` + 016-kasmos-agent-orchestrator + ───────────────────────────── + Type: spec-kitty + Path: kitty-specs/016-kasmos-agent-orchestrator + Status: complete (13/13 WPs) + + Work Packages: + WP01 Project Bootstrap done + WP02 Worker Backend done + WP03 TUI Foundation done + ... + + enter load as source · esc back +``` + +**GSD detail**: +``` + api-tasks.md + ──────────── + Type: gsd + Path: tasks/api-tasks.md + Status: in-progress (4/7 tasks) + + Tasks: + [x] Set up database schema + [x] Implement user auth + [ ] Write API tests + ... + + enter load as source · esc back +``` + +**Ad-hoc detail**: +``` + ks-1708123456-a1b2 + ─────────────────── + Type: ad-hoc session + Started: Feb 12, 2026 14:30 + Workers: 3 (2 exited, 1 failed) + + Workers: + w-001 coder exited 2m30s + w-002 reviewer exited 1m15s + w-003 coder failed 0m45s + + enter load as source · esc back +``` + +### Load from History + +Press `enter` on a history entry to load it: + +- **Spec-kitty**: call `m.swapTaskSource(&task.SpecKittySource{Dir: entry.Path})` + (uses the `swapTaskSource` helper from WP14) +- **GSD**: call `m.swapTaskSource(&task.GsdSource{FilePath: entry.Path})` +- **Ad-hoc**: load the archived session via `--attach` flow — restore workers from + the session file. This is more complex: call `persist.Load()` on the archived + session, restore worker snapshots, reset counter. + +Close the history overlay after loading. + +If WP14's `swapTaskSource` is not yet implemented, add it here with the same logic. + +### Messages + +```go +type historyScanCompleteMsg struct { + Entries []history.Entry + Err error +} + +type historyLoadMsg struct { + Entry history.Entry +} +``` + +### Commands + +```go +func historyScanCmd(specsRoot, kasmosDir string) tea.Cmd { + return func() tea.Msg { + entries, err := history.Scan(specsRoot, kasmosDir) + return historyScanCompleteMsg{Entries: entries, Err: err} + } +} +``` + +Scanning runs async via tea.Cmd so it doesn't block the TUI. + +### Integration with Update Loop + +In the main `Update()`: +- If `m.showHistory`, route keys to `updateHistoryKeys(msg)` +- Handle `historyScanCompleteMsg` to populate entries +- Handle `historyLoadMsg` to swap source or restore session + +Key routing for history overlay: +```go +if m.showHistory { + return m.updateHistoryKeys(msg) +} +``` + +Place this check early in `Update()`, after quit confirm and before spawn dialog, +since history is a full-screen overlay that captures all input. + +## What NOT to Do + +- Do NOT deep-scan the entire filesystem for GSD files — only check common + locations relative to the project root (`.`, `tasks/`, `todo/`, `docs/`) +- Do NOT parse git history for dates if mtime is available — keep it fast +- Do NOT delete archived sessions from the history view +- Do NOT show the currently active session/source in history (it's already loaded) +- Do NOT make history scanning blocking — always async via tea.Cmd +- Do NOT implement search/filter in the first version — just a scrollable list + +## Acceptance Criteria + +1. Press `h` — spinner shows briefly, then history overlay appears with entries + from all three source types +2. Spec-kitty features show correct WP counts and completion status +3. GSD files show checkbox task counts +4. Archived ad-hoc sessions show worker counts and status +5. Press `enter` on a spec-kitty entry — loads as task source, history closes +6. Press `enter` on a GSD entry — loads as task source, history closes +7. Press `d` on any entry — shows detail view +8. `esc` closes detail or history overlay +9. `h` disabled when other overlays are active +10. Sessions are archived to `.kasmos/sessions/` on clean exit +11. `go test ./...` passes (including history scanner tests) +12. `go build ./cmd/kasmos` passes From 21bcedfc79c6b7832139e60afe0012cd6e511375 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:05:39 -0600 Subject: [PATCH 18/26] fix(016): WP14 picker uses direct keys (n->s/g/r) instead of arrow nav --- .../tasks/WP14-new-spec-dialog.md | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md index e152a44..67dffba 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md @@ -80,17 +80,17 @@ Enable when no overlay is active. Add to ShortHelp and FullHelp. ### Stage 1: Type Picker Overlay -A simple vertical list of 3 options rendered as a centered overlay (same pattern -as quit confirm dialog): +A compact key-driven picker rendered as a centered overlay (same pattern as quit +confirm dialog). No cursor navigation — direct key dispatch: ``` New Spec/Plan ───────────── - > Feature Spec create a spec-kitty feature with research + planning - GSD Task List create a checkbox task markdown file - Planning Doc create a freeform planning document + [s] Feature Spec create a spec-kitty feature with research + planning + [g] GSD Task List create a checkbox task markdown file + [r] Planning Doc create a freeform planning document - ↑/↓ select · enter confirm · esc cancel + s/g/r select · esc cancel ``` Model fields: @@ -99,15 +99,18 @@ Model fields: showNewDialog bool newDialogStage int // 0 = picker, 1 = form newDialogType string // "feature-spec", "gsd", "planning" -newDialogPicker int // selected index in picker (0-2) newForm *newFormModel // stage 2 form (type depends on newDialogType) ``` Key handling in picker: -- `up`/`down` or `j`/`k` — move selection -- `enter` — advance to stage 2 form +- `s` — select Feature Spec, advance to stage 2 form +- `g` — select GSD Task List, advance to stage 2 form +- `r` — select Planning Doc, advance to stage 2 form - `esc` — cancel and close +This is intentionally zero-navigation. Press `n` then immediately `s`, `g`, or `r` +to get to the form in two keystrokes. + ### Stage 2a: Feature Spec Form Uses raw textinput components (same pattern as spawn dialog): From dbc9b89e5f10553cb0cda7d3aabde22fd401c1a9 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:12:36 -0600 Subject: [PATCH 19/26] tasks(016): WP16 release workflow (just release X.Y.Z) --- .../tasks/WP16-release-workflow.md | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md new file mode 100644 index 0000000..bc80c45 --- /dev/null +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md @@ -0,0 +1,271 @@ +--- +work_package_id: WP16 +title: Release Workflow (just release X.Y.Z) +lane: planned +dependencies: +- WP01 +subtasks: +- Replace hardcoded version with ldflags injection +- Rewrite Justfile for Go project (build, test, install, release) +- Build matrix (linux/amd64, darwin/amd64, darwin/arm64) +- Git tag + GitHub release + artifact upload via gh CLI +- Version bump in source files before tagging +phase: Wave 4 - Dashboard Enhancements +assignee: '' +agent: '' +shell_pid: '' +review_status: '' +reviewed_by: '' +history: +- timestamp: '2026-02-18T00:00:00Z' + lane: planned + agent: planner + action: Specified by user request +--- + +# Work Package Prompt: WP16 - Release Workflow (`just release X.Y.Z`) + +## Mission + +Implement a one-command release workflow: `just release 2.0.1` updates the version +in source, builds cross-platform binaries, creates a git tag, publishes a GitHub +release, and attaches the artifacts. Also rewrite the Justfile from the legacy +Rust/cargo recipes to Go-native commands. + +## Scope + +### Files to Create + +None — all changes are to existing files. + +### Files to Modify + +``` +Justfile # Full rewrite: Go build/test/install/release recipes +cmd/kasmos/main.go # Use ldflags-injected version variable +internal/tui/panels.go # Use shared version variable instead of hardcoded const +``` + +### Possible New File + +``` +version.go # Single source of truth for version (root package or internal/) +``` + +## Implementation + +### Version Injection via ldflags + +Currently the version is hardcoded in two places: +- `cmd/kasmos/main.go`: `fmt.Fprintln(cmd.OutOrStdout(), "kasmos v2.0.0")` +- `internal/tui/panels.go`: `const appVersion = "v2.0.0"` + +Replace with a single injectable variable. Create a minimal version package or +use a package-level var in `cmd/kasmos/`: + +**Option A — version var in main (simplest)**: + +In `cmd/kasmos/main.go`: +```go +// Set via ldflags: -ldflags "-X main.version=2.0.1" +var version = "dev" +``` + +Then reference `version` in the `--version` flag handler and pass it to the TUI +model. Add a `Version` field or param to `NewModel` so `panels.go` can read it +instead of a hardcoded const. + +The `appVersion` const in `panels.go` becomes a field on Model: +```go +// In model.go +type Model struct { + // ... + version string +} + +// In NewModel +func NewModel(backend worker.WorkerBackend, source task.Source, version string) *Model { + // ... + m.version = version + // ... +} +``` + +And `panels.go` uses `m.version` instead of `appVersion`. + +Build command becomes: +``` +go build -ldflags "-X main.version=2.0.1" ./cmd/kasmos +``` + +### Justfile Rewrite + +Replace the entire Justfile (currently Rust/cargo recipes) with Go-native recipes. +Use `set shell := ["bash", "-cu"]` for compatibility with the release script logic. + +```just +set shell := ["bash", "-cu"] + +version := "dev" + +# Build kasmos binary +build: + go build ./cmd/kasmos + +# Build with version +build-version v: + go build -ldflags "-X main.version={{v}}" -o kasmos ./cmd/kasmos + +# Install to GOPATH/bin +install: + go install ./cmd/kasmos + +# Run tests +test: + go test ./... + +# Run linter +lint: + go vet ./... + +# Run kasmos (pass-through args) +run *ARGS: + go run ./cmd/kasmos {{ARGS}} + +# Full release: just release 2.0.1 +release v: + #!/usr/bin/env bash + set -euo pipefail + + VERSION="{{v}}" + TAG="v${VERSION}" + + echo "==> Releasing kasmos ${TAG}" + + # 1. Ensure clean working tree + if [[ -n "$(git status --porcelain)" ]]; then + echo "ERROR: working tree is dirty, commit or stash first" + exit 1 + fi + + # 2. Ensure on main or charm branch + BRANCH=$(git branch --show-current) + echo " branch: ${BRANCH}" + + # 3. Update version in source files + sed -i "s/var version = \".*\"/var version = \"${VERSION}\"/" cmd/kasmos/main.go + echo " updated cmd/kasmos/main.go" + + # 4. Commit version bump if changed + if [[ -n "$(git status --porcelain)" ]]; then + git add cmd/kasmos/main.go + git commit -m "release: v${VERSION}" + echo " committed version bump" + fi + + # 5. Build artifacts + echo "==> Building artifacts" + mkdir -p dist + + GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" \ + -o "dist/kasmos-${TAG}-linux-amd64" ./cmd/kasmos + echo " built linux/amd64" + + GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" \ + -o "dist/kasmos-${TAG}-darwin-amd64" ./cmd/kasmos + echo " built darwin/amd64" + + GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" \ + -o "dist/kasmos-${TAG}-darwin-arm64" ./cmd/kasmos + echo " built darwin/arm64" + + # 6. Generate checksums + cd dist + sha256sum kasmos-${TAG}-* > kasmos-${TAG}-checksums.txt + cd .. + echo " generated checksums" + + # 7. Create git tag + git tag -a "${TAG}" -m "kasmos ${TAG}" + echo " tagged ${TAG}" + + # 8. Push commit + tag + git push origin "${BRANCH}" + git push origin "${TAG}" + echo " pushed to origin" + + # 9. Create GitHub release with artifacts + gh release create "${TAG}" \ + --title "kasmos ${TAG}" \ + --generate-notes \ + dist/kasmos-${TAG}-linux-amd64 \ + dist/kasmos-${TAG}-darwin-amd64 \ + dist/kasmos-${TAG}-darwin-arm64 \ + dist/kasmos-${TAG}-checksums.txt + echo " created GitHub release" + + # 10. Cleanup + rm -rf dist + echo "==> Done: https://github.com/kastheco/kasmos/releases/tag/${TAG}" +``` + +### .gitignore + +Add `dist/` to `.gitignore` so build artifacts aren't accidentally committed: + +``` +/kasmos +/dist/ +``` + +### Release Artifact Naming + +Artifacts follow the standard convention: +``` +kasmos-v2.0.1-linux-amd64 +kasmos-v2.0.1-darwin-amd64 +kasmos-v2.0.1-darwin-arm64 +kasmos-v2.0.1-checksums.txt +``` + +No `.tar.gz` wrapping — ship raw binaries since kasmos is a single static binary +with zero runtime dependencies. Users download and `chmod +x`. + +The checksums file uses `sha256sum` format: +``` +abc123... kasmos-v2.0.1-linux-amd64 +def456... kasmos-v2.0.1-darwin-amd64 +789abc... kasmos-v2.0.1-darwin-arm64 +``` + +### GitHub Release Notes + +`gh release create` with `--generate-notes` auto-generates notes from commits +since the previous tag. Since this is the first release, it will include all +commits on the branch. + +For subsequent releases, the auto-generated notes will show the diff between tags. + +## What NOT to Do + +- Do NOT use goreleaser — it's overkill for 3 build targets and adds a dependency +- Do NOT build Windows artifacts — kasmos requires a Unix terminal (bubbletea) +- Do NOT create a GitHub Actions workflow — releases are manual via `just release` +- Do NOT strip binaries (`-s -w` ldflags) unless size becomes a concern +- Do NOT create `.tar.gz` archives — ship raw binaries +- Do NOT modify go.mod or go.sum as part of the release + +## Acceptance Criteria + +1. `just build` compiles kasmos, `just test` runs tests, `just install` installs +2. `just release 2.0.1` with clean tree: bumps version in source, builds 3 binaries, + creates tag `v2.0.1`, pushes, creates GH release with artifacts attached +3. `just release 2.0.1` with dirty tree: exits with error +4. Built binary reports correct version: `./dist/kasmos-v2.0.1-linux-amd64 --version` + prints `kasmos v2.0.1` +5. TUI header shows the ldflags-injected version +6. `dist/` is in `.gitignore` +7. Checksums file is correct and attached to release +8. Old Rust/cargo recipes are fully replaced +9. `go test ./...` passes +10. `go build ./cmd/kasmos` passes From bfc425c7de79b13fe65bbb73d2cc50954bf16f41 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:15:22 -0600 Subject: [PATCH 20/26] tasks(016): WP16 updated to use goreleaser with homebrew tap --- .../tasks/WP16-release-workflow.md | 316 +++++++++++------- 1 file changed, 202 insertions(+), 114 deletions(-) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md index bc80c45..e2c1ee3 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP16-release-workflow.md @@ -7,9 +7,9 @@ dependencies: subtasks: - Replace hardcoded version with ldflags injection - Rewrite Justfile for Go project (build, test, install, release) -- Build matrix (linux/amd64, darwin/amd64, darwin/arm64) -- Git tag + GitHub release + artifact upload via gh CLI -- Version bump in source files before tagging +- Create .goreleaser.yaml (linux/amd64, darwin/amd64+arm64, homebrew tap) +- Create kastheco/homebrew-tap repo for brew install +- Version bump in source + goreleaser release in one command phase: Wave 4 - Dashboard Enhancements assignee: '' agent: '' @@ -21,6 +21,10 @@ history: lane: planned agent: planner action: Specified by user request +- timestamp: '2026-02-18T00:00:00Z' + lane: planned + agent: planner + action: Updated to use goreleaser with homebrew tap --- # Work Package Prompt: WP16 - Release Workflow (`just release X.Y.Z`) @@ -28,15 +32,18 @@ history: ## Mission Implement a one-command release workflow: `just release 2.0.1` updates the version -in source, builds cross-platform binaries, creates a git tag, publishes a GitHub -release, and attaches the artifacts. Also rewrite the Justfile from the legacy +in source, tags, and runs goreleaser to build cross-platform binaries, publish a +GitHub release with artifacts and changelog, and push a Homebrew formula so users +can `brew install kastheco/tap/kasmos`. Also rewrite the Justfile from legacy Rust/cargo recipes to Go-native commands. ## Scope ### Files to Create -None — all changes are to existing files. +``` +.goreleaser.yaml # goreleaser config (builds, archives, homebrew, changelog) +``` ### Files to Modify @@ -44,13 +51,21 @@ None — all changes are to existing files. Justfile # Full rewrite: Go build/test/install/release recipes cmd/kasmos/main.go # Use ldflags-injected version variable internal/tui/panels.go # Use shared version variable instead of hardcoded const +internal/tui/model.go # Add version field to Model, update NewModel signature +.gitignore # Add dist/ ``` -### Possible New File +### External Setup (manual, not automated) +Before the first release, create the Homebrew tap repo: + +```sh +gh repo create kastheco/homebrew-tap --public --description "Homebrew formulae for kastheco projects" ``` -version.go # Single source of truth for version (root package or internal/) -``` + +And ensure a `GH_PAT` environment variable is available with `repo` scope for +goreleaser to push the formula. A fine-grained PAT scoped to `kastheco/homebrew-tap` +with Contents read/write is sufficient. ## Implementation @@ -60,30 +75,24 @@ Currently the version is hardcoded in two places: - `cmd/kasmos/main.go`: `fmt.Fprintln(cmd.OutOrStdout(), "kasmos v2.0.0")` - `internal/tui/panels.go`: `const appVersion = "v2.0.0"` -Replace with a single injectable variable. Create a minimal version package or -use a package-level var in `cmd/kasmos/`: +Replace with a single injectable variable in `cmd/kasmos/main.go`: -**Option A — version var in main (simplest)**: - -In `cmd/kasmos/main.go`: ```go -// Set via ldflags: -ldflags "-X main.version=2.0.1" +// Set at build time: -ldflags "-X main.version=2.0.1" var version = "dev" ``` -Then reference `version` in the `--version` flag handler and pass it to the TUI -model. Add a `Version` field or param to `NewModel` so `panels.go` can read it -instead of a hardcoded const. +Then pass it through to the TUI model: -The `appVersion` const in `panels.go` becomes a field on Model: ```go -// In model.go -type Model struct { - // ... - version string -} +// cmd/kasmos/main.go — in RunE +model := tui.NewModel(backend, source, version) +``` + +Update `NewModel` signature: -// In NewModel +```go +// internal/tui/model.go func NewModel(backend worker.WorkerBackend, source task.Source, version string) *Model { // ... m.version = version @@ -91,31 +100,131 @@ func NewModel(backend worker.WorkerBackend, source task.Source, version string) } ``` -And `panels.go` uses `m.version` instead of `appVersion`. +Add `version string` field to the Model struct. + +In `internal/tui/panels.go`, delete the `appVersion` const and use `m.version`: -Build command becomes: +```go +func (m *Model) renderHeader() string { + v := m.version + if v != "" && v[0] != 'v' { + v = "v" + v + } + version := versionStyle.Render(v) + // ... +} ``` -go build -ldflags "-X main.version=2.0.1" ./cmd/kasmos + +### .goreleaser.yaml + +```yaml +version: 2 + +env: + - CGO_ENABLED=0 + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: kasmos + main: ./cmd/kasmos + binary: kasmos + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.ShortCommit}} + - -X main.date={{.CommitDate}} + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: linux + goarch: arm64 + +archives: + - id: default + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format: tar.gz + files: + - LICENSE* + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + sort: asc + use: github + groups: + - title: Features + regexp: '^.*feat[\w)]*:.*$' + order: 0 + - title: Bug Fixes + regexp: '^.*fix[\w)]*:.*$' + order: 1 + - title: Tasks + regexp: '^.*tasks[\w)]*:.*$' + order: 2 + - title: Others + order: 999 + filters: + exclude: + - "^docs:" + - "^test:" + - "Merge pull request" + +release: + github: + owner: kastheco + name: kasmos + draft: false + prerelease: auto + name_template: "kasmos v{{.Version}}" + +brews: + - repository: + owner: kastheco + name: homebrew-tap + token: "{{ .Env.GH_PAT }}" + directory: Formula + homepage: "https://github.com/kastheco/kasmos" + description: "TUI agent orchestrator for concurrent OpenCode sessions" + license: "MIT" + install: | + bin.install "kasmos" + test: | + system "#{bin}/kasmos", "--version" ``` +Key config decisions: +- **linux/amd64 + darwin/amd64 + darwin/arm64**: no Windows (bubbletea needs Unix + terminal), no linux/arm64 (uncommon for dev tooling) +- **tar.gz archives**: goreleaser convention, includes README and LICENSE +- **Changelog groups**: matches our commit convention (`feat(016):`, `fix(016):`, `tasks(016):`) +- **Homebrew tap**: pushes formula to `kastheco/homebrew-tap` repo so users get + `brew install kastheco/tap/kasmos` +- **`-trimpath -s -w`**: reproducible builds, stripped debug symbols (smaller binary) + ### Justfile Rewrite -Replace the entire Justfile (currently Rust/cargo recipes) with Go-native recipes. -Use `set shell := ["bash", "-cu"]` for compatibility with the release script logic. +Replace the entire Justfile (currently Rust/cargo recipes) with Go-native recipes: ```just set shell := ["bash", "-cu"] -version := "dev" - # Build kasmos binary build: go build ./cmd/kasmos -# Build with version -build-version v: - go build -ldflags "-X main.version={{v}}" -o kasmos ./cmd/kasmos - # Install to GOPATH/bin install: go install ./cmd/kasmos @@ -132,6 +241,15 @@ lint: run *ARGS: go run ./cmd/kasmos {{ARGS}} +# Dry-run release (no publish) +release-dry v: + #!/usr/bin/env bash + set -euo pipefail + VERSION="{{v}}" + echo "==> Dry run for kasmos v${VERSION}" + goreleaser release --snapshot --clean + echo "==> Artifacts in dist/" + # Full release: just release 2.0.1 release v: #!/usr/bin/env bash @@ -148,124 +266,94 @@ release v: exit 1 fi - # 2. Ensure on main or charm branch BRANCH=$(git branch --show-current) echo " branch: ${BRANCH}" - # 3. Update version in source files + # 2. Update version in source sed -i "s/var version = \".*\"/var version = \"${VERSION}\"/" cmd/kasmos/main.go - echo " updated cmd/kasmos/main.go" - - # 4. Commit version bump if changed if [[ -n "$(git status --porcelain)" ]]; then git add cmd/kasmos/main.go git commit -m "release: v${VERSION}" echo " committed version bump" fi - # 5. Build artifacts - echo "==> Building artifacts" - mkdir -p dist - - GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" \ - -o "dist/kasmos-${TAG}-linux-amd64" ./cmd/kasmos - echo " built linux/amd64" - - GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" \ - -o "dist/kasmos-${TAG}-darwin-amd64" ./cmd/kasmos - echo " built darwin/amd64" - - GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" \ - -o "dist/kasmos-${TAG}-darwin-arm64" ./cmd/kasmos - echo " built darwin/arm64" - - # 6. Generate checksums - cd dist - sha256sum kasmos-${TAG}-* > kasmos-${TAG}-checksums.txt - cd .. - echo " generated checksums" - - # 7. Create git tag + # 3. Tag git tag -a "${TAG}" -m "kasmos ${TAG}" echo " tagged ${TAG}" - # 8. Push commit + tag + # 4. Push commit + tag git push origin "${BRANCH}" git push origin "${TAG}" echo " pushed to origin" - # 9. Create GitHub release with artifacts - gh release create "${TAG}" \ - --title "kasmos ${TAG}" \ - --generate-notes \ - dist/kasmos-${TAG}-linux-amd64 \ - dist/kasmos-${TAG}-darwin-amd64 \ - dist/kasmos-${TAG}-darwin-arm64 \ - dist/kasmos-${TAG}-checksums.txt - echo " created GitHub release" - - # 10. Cleanup - rm -rf dist + # 5. Goreleaser builds, creates GH release, pushes homebrew formula + goreleaser release --clean echo "==> Done: https://github.com/kastheco/kasmos/releases/tag/${TAG}" ``` ### .gitignore -Add `dist/` to `.gitignore` so build artifacts aren't accidentally committed: +Add `dist/` (goreleaser output directory): ``` /kasmos /dist/ ``` -### Release Artifact Naming +### What Users Get -Artifacts follow the standard convention: -``` -kasmos-v2.0.1-linux-amd64 -kasmos-v2.0.1-darwin-amd64 -kasmos-v2.0.1-darwin-arm64 -kasmos-v2.0.1-checksums.txt -``` - -No `.tar.gz` wrapping — ship raw binaries since kasmos is a single static binary -with zero runtime dependencies. Users download and `chmod +x`. +After a release, users can install kasmos via: -The checksums file uses `sha256sum` format: +**Homebrew (macOS/Linux):** +```sh +brew install kastheco/tap/kasmos ``` -abc123... kasmos-v2.0.1-linux-amd64 -def456... kasmos-v2.0.1-darwin-amd64 -789abc... kasmos-v2.0.1-darwin-arm64 + +**Direct download:** +```sh +# macOS Apple Silicon +curl -Lo kasmos.tar.gz https://github.com/kastheco/kasmos/releases/latest/download/kasmos_X.Y.Z_darwin_arm64.tar.gz +tar xzf kasmos.tar.gz +sudo mv kasmos /usr/local/bin/ + +# Linux +curl -Lo kasmos.tar.gz https://github.com/kastheco/kasmos/releases/latest/download/kasmos_X.Y.Z_linux_amd64.tar.gz +tar xzf kasmos.tar.gz +sudo mv kasmos /usr/local/bin/ ``` -### GitHub Release Notes +**From source:** +```sh +go install github.com/kastheco/kasmos/cmd/kasmos@latest +``` -`gh release create` with `--generate-notes` auto-generates notes from commits -since the previous tag. Since this is the first release, it will include all -commits on the branch. +## Dependencies -For subsequent releases, the auto-generated notes will show the diff between tags. +- `goreleaser` — `go install github.com/goreleaser/goreleaser/v2@latest` +- `gh` — GitHub CLI (already used in project) +- `GH_PAT` env var — GitHub PAT with `repo` scope for homebrew tap push +- `just` — task runner (already used in project) ## What NOT to Do -- Do NOT use goreleaser — it's overkill for 3 build targets and adds a dependency -- Do NOT build Windows artifacts — kasmos requires a Unix terminal (bubbletea) - Do NOT create a GitHub Actions workflow — releases are manual via `just release` -- Do NOT strip binaries (`-s -w` ldflags) unless size becomes a concern -- Do NOT create `.tar.gz` archives — ship raw binaries -- Do NOT modify go.mod or go.sum as part of the release +- Do NOT build Windows artifacts — kasmos requires a Unix terminal +- Do NOT include `commit` or `date` ldflags vars unless you also add them to + `main.go` (goreleaser sets them but they need corresponding `var` declarations) +- Do NOT use goreleaser Pro features (`--split`, `--merge`) — free tier is sufficient +- Do NOT modify go.mod or go.sum as part of the release recipe ## Acceptance Criteria 1. `just build` compiles kasmos, `just test` runs tests, `just install` installs -2. `just release 2.0.1` with clean tree: bumps version in source, builds 3 binaries, - creates tag `v2.0.1`, pushes, creates GH release with artifacts attached -3. `just release 2.0.1` with dirty tree: exits with error -4. Built binary reports correct version: `./dist/kasmos-v2.0.1-linux-amd64 --version` - prints `kasmos v2.0.1` -5. TUI header shows the ldflags-injected version -6. `dist/` is in `.gitignore` -7. Checksums file is correct and attached to release -8. Old Rust/cargo recipes are fully replaced -9. `go test ./...` passes -10. `go build ./cmd/kasmos` passes +2. `just release-dry 2.0.1` builds all artifacts in `dist/` without publishing +3. `just release 2.0.1` with clean tree: bumps version, tags, pushes, goreleaser + publishes GH release with 3 platform archives + checksums + changelog +4. `just release 2.0.1` with dirty tree: exits with error +5. Built binary reports correct version: `kasmos --version` prints `kasmos v2.0.1` +6. TUI header shows the ldflags-injected version +7. Homebrew formula pushed to `kastheco/homebrew-tap` — `brew install kastheco/tap/kasmos` works +8. `dist/` is in `.gitignore` +9. Old Rust/cargo recipes fully replaced in Justfile +10. `go test ./...` passes +11. `go build ./cmd/kasmos` passes From 771a339516c571c0d8c73812543d31d8163135b1 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:23:04 -0600 Subject: [PATCH 21/26] tasks(016): WP14 add task source autodiscovery on bare launch --- .../tasks/WP14-new-spec-dialog.md | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md index 67dffba..d8edc39 100644 --- a/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md +++ b/kitty-specs/016-kasmos-agent-orchestrator/tasks/WP14-new-spec-dialog.md @@ -13,6 +13,7 @@ subtasks: - Planning form (title, description) -> create freeform planning doc - Auto-load new source into dashboard after creation - specCreateCmd for subprocess execution of spec-kitty +- Task source autodiscovery (scan kitty-specs/ on bare launch) phase: Wave 4 - Dashboard Enhancements assignee: '' agent: '' @@ -46,8 +47,10 @@ internal/tui/newdialog.go # Type picker model + type-specific form models ### Files to Modify ``` +cmd/kasmos/main.go # Autodiscovery when no arg given +internal/task/source.go # AutoDetect() function internal/tui/keys.go # Add New key binding (n) -internal/tui/model.go # New dialog state fields +internal/tui/model.go # New dialog state fields + swapTaskSource helper internal/tui/update.go # New dialog message handlers, key routing internal/tui/messages.go # New dialog messages internal/tui/commands.go # specCreateCmd, gsdCreateCmd @@ -273,6 +276,57 @@ func (m *Model) swapTaskSource(source task.Source) { } ``` +### Task Source Autodiscovery + +When kasmos launches with no CLI argument, it currently defaults to ad-hoc mode. +Add autodiscovery so bare `kasmos` finds the most relevant source automatically. + +Add to `internal/task/source.go`: + +```go +// AutoDetect scans the current project for task sources. +// Priority: kitty-specs (most recent by mtime) > root .md files > ad-hoc. +func AutoDetect() Source { + // 1. Scan kitty-specs/*/ for spec-kitty feature directories + // Sort by mtime descending, pick the most recent one that has + // at least one non-done WP (i.e. active work). If all features + // are fully done, pick the most recent anyway. + matches, _ := filepath.Glob("kitty-specs/*/tasks/WP*.md") + if len(matches) > 0 { + // Group by parent feature dir, find most recent + // Return &SpecKittySource{Dir: featureDir} + } + + // 2. Check for common GSD files at project root + for _, candidate := range []string{"tasks.md", "todo.md", "TODO.md"} { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return &GsdSource{FilePath: candidate} + } + } + + // 3. Fall back to ad-hoc + return &AdHocSource{} +} +``` + +Update `cmd/kasmos/main.go` to use autodiscovery: + +```go +var source task.Source +if len(args) > 0 { + detected, err := task.DetectSourceType(args[0]) + if err != nil { + return err + } + source = detected +} else { + source = task.AutoDetect() +} +``` + +This means running bare `kasmos` in the kasmos repo auto-discovers +`kitty-specs/016-kasmos-agent-orchestrator` and loads WP14/15/16 as planned tasks. + ## What NOT to Do - Do NOT call `spec-kitty specify` interactively — use `agent feature create-feature` @@ -294,5 +348,8 @@ func (m *Model) swapTaskSource(source task.Source) { 5. `esc` at any stage cancels and closes the dialog 6. Error from spec-kitty CLI shown in viewport (not a crash) 7. `n` disabled when any other overlay is active -8. `go test ./...` passes -9. `go build ./cmd/kasmos` passes +8. Bare `kasmos` (no arg) autodiscovers kitty-specs and loads most recent feature +9. Bare `kasmos` in a dir with `tasks.md` autodiscovers GSD source +10. Bare `kasmos` with nothing to discover falls back to ad-hoc +11. `go test ./...` passes +12. `go build ./cmd/kasmos` passes From 2cc7322e8546b9e4e0962cc78728677321f11264 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:28:28 -0600 Subject: [PATCH 22/26] feat(016): WP16 release workflow - goreleaser, ldflags version, Justfile rewrite --- .gitignore | 1 + .goreleaser.yaml | 86 +++++++++++++++++++++++++++++++++++ Justfile | 97 ++++++++++++++++++++++++---------------- cmd/kasmos/main.go | 7 ++- go.mod | 2 +- go.sum | 1 + internal/tui/model.go | 5 ++- internal/tui/panels.go | 8 ++-- internal/tui/tui_test.go | 10 ++--- 9 files changed, 166 insertions(+), 51 deletions(-) create mode 100644 .goreleaser.yaml diff --git a/.gitignore b/.gitignore index d227a9f..eb5b89c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ SPEC_REVIEW_PLAN.md kasmos-collect.sh kasmos-config.sh /kasmos +/dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..38fecdd --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,86 @@ +version: 2 + +env: + - CGO_ENABLED=0 + +before: + hooks: + - go mod tidy + - go test ./... + +builds: + - id: kasmos + main: ./cmd/kasmos + binary: kasmos + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{.Version}} + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ignore: + - goos: linux + goarch: arm64 + +archives: + - id: default + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz + files: + - LICENSE* + - README.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +changelog: + sort: asc + use: github + groups: + - title: Features + regexp: '^.*feat[\w)]*:.*$' + order: 0 + - title: Bug Fixes + regexp: '^.*fix[\w)]*:.*$' + order: 1 + - title: Tasks + regexp: '^.*tasks[\w)]*:.*$' + order: 2 + - title: Others + order: 999 + filters: + exclude: + - "^docs:" + - "^test:" + - "Merge pull request" + +release: + github: + owner: kastheco + name: kasmos + draft: false + prerelease: auto + name_template: "kasmos v{{.Version}}" + +homebrew_casks: + - repository: + owner: kastheco + name: homebrew-tap + token: "{{ .Env.GH_PAT }}" + directory: Casks + homepage: "https://github.com/kastheco/kasmos" + description: "TUI agent orchestrator for concurrent OpenCode sessions" + license: "MIT" + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/kasmos"] + end diff --git a/Justfile b/Justfile index d12a013..10856e3 100644 --- a/Justfile +++ b/Justfile @@ -1,51 +1,70 @@ set shell := ["bash", "-cu"] set dotenv-load := true -# Spec Kitty manual swarm lifecycle -# Usage: just swarm [flags...] -# just swarm 001 # start next wave -# just swarm 003 --status # show board + waves -# just swarm 001 --cleanup # start with orphan cleanup -# just swarm 001 --review WP02 -# just swarm 001 --done WP01 -# just swarm 001 --reject WP02 --feedback /tmp/fb.md - -# just swarm 001 --dry-run -swarm +ARGS: - @scripts/sk-start.sh {{ ARGS }} - -# Install kasmos binary to ~/.cargo/bin +# Build kasmos binary +build: + go build ./cmd/kasmos + +# Install to GOPATH/bin install: - cargo install --path crates/kasmos + go install ./cmd/kasmos -# Build -build: - cargo build +# Run tests +test: + go test ./... -# Run (pass-through args) -run +ARGS: - cargo run -p kasmos -- {{ ARGS }} +# Run linter +lint: + go vet ./... -# Launch orchestration by feature path or prefix (e.g. 001) -launch feature mode="continuous": - cargo run -p kasmos -- launch {{ feature }} --mode {{ mode }} +# Run kasmos (pass-through args) +run *ARGS: + go run ./cmd/kasmos {{ARGS}} -# Show orchestration status (current dir if feature omitted) -status feature="": - if [ -n "{{ feature }}" ]; then cargo run -p kasmos -- status {{ feature }}; else cargo run -p kasmos -- status; fi +# Dry-run release (no publish) +release-dry v: + #!/usr/bin/env bash + set -euo pipefail + echo "==> Dry run for kasmos v{{v}}" + goreleaser release --snapshot --clean + echo "==> Artifacts in dist/" -# Attach to running orchestration by feature path or prefix (e.g. 001) -attach feature: - cargo run -p kasmos -- attach {{ feature }} +# Full release: just release 2.0.1 +release v: + #!/usr/bin/env bash + set -euo pipefail -# Stop orchestration (current dir if feature omitted) -stop feature="": - if [ -n "{{ feature }}" ]; then cargo run -p kasmos -- stop {{ feature }}; else cargo run -p kasmos -- stop; fi + VERSION="{{v}}" + TAG="v${VERSION}" -# Test -test: - cargo test + echo "==> Releasing kasmos ${TAG}" -# Lint -lint: - cargo clippy --all-targets --all-features -- -D warnings + # 1. Ensure clean working tree + if [[ -n "$(git status --porcelain)" ]]; then + echo "ERROR: working tree is dirty, commit or stash first" + exit 1 + fi + + BRANCH=$(git branch --show-current) + echo " branch: ${BRANCH}" + + # 2. Update version in source + sed -i "s/var version = \".*\"/var version = \"${VERSION}\"/" cmd/kasmos/main.go + if [[ -n "$(git status --porcelain)" ]]; then + git add cmd/kasmos/main.go + git commit -m "release: v${VERSION}" + echo " committed version bump" + fi + + # 3. Tag + git tag -a "${TAG}" -m "kasmos ${TAG}" + echo " tagged ${TAG}" + + # 4. Push commit + tag + git push origin "${BRANCH}" + git push origin "${TAG}" + echo " pushed to origin" + + # 5. Goreleaser builds, creates GH release, pushes homebrew formula + goreleaser release --clean + echo "==> Done: https://github.com/kastheco/kasmos/releases/tag/${TAG}" diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index ee2af71..4e9f1f2 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -20,6 +20,9 @@ import ( "github.com/user/kasmos/internal/worker" ) +// Set at build time: -ldflags "-X main.version=2.0.0" +var version = "dev" + func main() { if err := newRootCmd().Execute(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -40,7 +43,7 @@ func newRootCmd() *cobra.Command { Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if showVersion { - fmt.Fprintln(cmd.OutOrStdout(), "kasmos v2.0.0") + fmt.Fprintf(cmd.OutOrStdout(), "kasmos v%s\n", version) return nil } @@ -87,7 +90,7 @@ func newRootCmd() *cobra.Command { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - model := tui.NewModel(backend, source) + model := tui.NewModel(backend, source, version) if daemon { model.SetDaemonMode(true, format, spawnAll) } diff --git a/go.mod b/go.mod index 6c65126..5a63e2c 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2 github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2 github.com/spf13/cobra v1.8.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -19,7 +20,6 @@ require ( github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/go.sum b/go.sum index a217e89..05919a9 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,7 @@ golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tui/model.go b/internal/tui/model.go index c9243cd..58fa723 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -87,6 +87,8 @@ type Model struct { sessionStart time.Time daemonDone bool daemonExitCode int + + version string } type AnalysisResult struct { @@ -95,7 +97,7 @@ type AnalysisResult struct { SuggestedPrompt string } -func NewModel(backend worker.WorkerBackend, source task.Source) *Model { +func NewModel(backend worker.WorkerBackend, source task.Source, version string) *Model { t := table.New( table.WithColumns([]table.Column{ {Title: "ID", Width: 10}, @@ -123,6 +125,7 @@ func NewModel(backend worker.WorkerBackend, source task.Source) *Model { backend: backend, manager: worker.NewWorkerManager(), workers: make([]*worker.Worker, 0), + version: version, } if source != nil { m.taskSource = source diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 6d7a42e..872f3d0 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -10,11 +10,13 @@ import ( "github.com/user/kasmos/internal/worker" ) -const appVersion = "v2.0.0" - func (m *Model) renderHeader() string { title := " " + renderGradientTitle("kasmos") + " " + dimSubtitleStyle.Render("agent orchestrator") - version := versionStyle.Render(appVersion) + v := m.version + if v != "" && v[0] != 'v' { + v = "v" + v + } + version := versionStyle.Render(v) gap := strings.Repeat(" ", max(1, m.width-lipgloss.Width(title)-lipgloss.Width(version))) line := title + gap + version if !m.hasTaskSource() { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 358e3df..483f3f3 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -9,7 +9,7 @@ import ( ) func TestNewModelDefaults(t *testing.T) { - m := NewModel(nil, nil) + m := NewModel(nil, nil, "test") if m == nil { t.Fatal("NewModel returned nil") @@ -110,7 +110,7 @@ func TestRecalculateLayoutBreakpoints(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := NewModel(nil, nil) + m := NewModel(nil, nil, "test") m.width = tt.width m.height = tt.height m.taskSourceType = tt.taskSourceType @@ -215,7 +215,7 @@ func TestUpdateKeyStates(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := NewModel(nil, nil) + m := NewModel(nil, nil, "test") tt.setup(m) m.updateKeyStates() tt.assert(t, m) @@ -224,7 +224,7 @@ func TestUpdateKeyStates(t *testing.T) { } func TestSelectionAndViewportNoPanicOnEmptyState(t *testing.T) { - m := NewModel(nil, nil) + m := NewModel(nil, nil, "test") mustNotPanic(t, "syncSelectionFromTable", func() { m.syncSelectionFromTable() @@ -236,7 +236,7 @@ func TestSelectionAndViewportNoPanicOnEmptyState(t *testing.T) { } func TestBuildSessionStateUsesManagerCounter(t *testing.T) { - m := NewModel(nil, nil) + m := NewModel(nil, nil, "test") m.sessionID = "ks-test" m.manager.ResetWorkerCounter(41) m.manager.Add(&worker.Worker{ID: "w-003", Role: "coder", State: worker.StateRunning}) From 1fb6b74691be7ca3da8f41a1b06be101cc0ab7de Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:35:17 -0600 Subject: [PATCH 23/26] release: v2.0.0 --- cmd/kasmos/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index 4e9f1f2..408fc4e 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -21,7 +21,7 @@ import ( ) // Set at build time: -ldflags "-X main.version=2.0.0" -var version = "dev" +var version = "2.0.0" func main() { if err := newRootCmd().Execute(); err != nil { From 618af20f8b7f77763456ae9c9c8b87883984ea09 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:36:08 -0600 Subject: [PATCH 24/26] release: v2.0.1 --- cmd/kasmos/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index 408fc4e..a378417 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -21,7 +21,7 @@ import ( ) // Set at build time: -ldflags "-X main.version=2.0.0" -var version = "2.0.0" +var version = "2.0.1" func main() { if err := newRootCmd().Execute(); err != nil { From a80f3f60e5c9daedb4dd8281713cea7fdf1dd957 Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:36:57 -0600 Subject: [PATCH 25/26] fix: pass GITHUB_TOKEN to goreleaser from GH_PAT --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 10856e3..6f64e89 100644 --- a/Justfile +++ b/Justfile @@ -66,5 +66,5 @@ release v: echo " pushed to origin" # 5. Goreleaser builds, creates GH release, pushes homebrew formula - goreleaser release --clean + GITHUB_TOKEN="${GH_PAT}" goreleaser release --clean echo "==> Done: https://github.com/kastheco/kasmos/releases/tag/${TAG}" From 8d0a947b40ceb217adba9f6ca30f150533613ddf Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 18 Feb 2026 10:39:31 -0600 Subject: [PATCH 26/26] release: v2.0.0 --- cmd/kasmos/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kasmos/main.go b/cmd/kasmos/main.go index a378417..408fc4e 100644 --- a/cmd/kasmos/main.go +++ b/cmd/kasmos/main.go @@ -21,7 +21,7 @@ import ( ) // Set at build time: -ldflags "-X main.version=2.0.0" -var version = "2.0.1" +var version = "2.0.0" func main() { if err := newRootCmd().Execute(); err != nil {