diff --git a/.planning/phases/04-agent-lifecycle/04-01-PLAN.md b/.planning/phases/04-agent-lifecycle/04-01-PLAN.md new file mode 100644 index 0000000..1a9a2cc --- /dev/null +++ b/.planning/phases/04-agent-lifecycle/04-01-PLAN.md @@ -0,0 +1,161 @@ +--- +phase: 04-agent-lifecycle +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [src/db/schema.ts, src/db/repositories/agent-repository.ts, src/db/repositories/drizzle/agent.ts, src/db/repositories/drizzle/agent.test.ts, src/db/repositories/drizzle/index.ts, src/db/repositories/index.ts] +autonomous: true +--- + + +Add agents table to database schema and create AgentRepository for persistence. + +Purpose: Enable agent state persistence for session resumption (AGENT-04) and listing (AGENT-03). +Output: Database schema with agents table, AgentRepository port and Drizzle adapter with tests. + + + +@~/.claude/get-shit-done/workflows/execute-plan.md +@~/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-agent-lifecycle/DISCOVERY.md + +@src/db/schema.ts +@src/db/repositories/task-repository.ts +@src/db/repositories/drizzle/task.ts +@src/db/repositories/drizzle/test-helpers.ts + + + + + + Task 1: Add agents table to database schema + src/db/schema.ts + +Add agents table to schema.ts following existing patterns: + +```typescript +export const agents = sqliteTable('agents', { + id: text('id').primaryKey(), + taskId: text('task_id') + .references(() => tasks.id, { onDelete: 'set null' }), // Task may be deleted + sessionId: text('session_id').notNull(), // Claude SDK session ID for resumption + worktreeId: text('worktree_id').notNull(), // WorktreeManager worktree ID + status: text('status', { enum: ['idle', 'running', 'stopped', 'crashed'] }) + .notNull() + .default('idle'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); +``` + +Add relations: +```typescript +export const agentsRelations = relations(agents, ({ one }) => ({ + task: one(tasks, { + fields: [agents.taskId], + references: [tasks.id], + }), +})); +``` + +Export types: +```typescript +export type Agent = InferSelectModel; +export type NewAgent = InferInsertModel; +``` + +Note: taskId is nullable with onDelete: 'set null' because agent may outlive task. + + npm run build passes with no TypeScript errors + agents table defined in schema with relations and exported types + + + + Task 2: Create AgentRepository port interface + src/db/repositories/agent-repository.ts, src/db/repositories/index.ts + +Create AgentRepository port interface following same pattern as TaskRepository: + +```typescript +// src/db/repositories/agent-repository.ts +import type { Agent, NewAgent } from '../schema.js'; + +export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed'; + +export interface AgentRepository { + create(agent: Omit): Promise; + findById(id: string): Promise; + findByTaskId(taskId: string): Promise; + findBySessionId(sessionId: string): Promise; + findAll(): Promise; + findByStatus(status: AgentStatus): Promise; + updateStatus(id: string, status: AgentStatus): Promise; + updateSessionId(id: string, sessionId: string): Promise; + delete(id: string): Promise; +} +``` + +Export from index.ts barrel file. + + npm run build passes + AgentRepository interface exported from src/db/repositories/ + + + + Task 3: Create DrizzleAgentRepository adapter with tests + src/db/repositories/drizzle/agent.ts, src/db/repositories/drizzle/agent.test.ts, src/db/repositories/drizzle/index.ts + +Create DrizzleAgentRepository following existing patterns (see drizzle/task.ts): + +1. Implement all AgentRepository methods using Drizzle ORM +2. Use eq() for queries, set() for updates +3. Fetch after insert to ensure schema defaults applied +4. Throw on not found for update/delete operations + +Tests should cover: +- create() returns agent with timestamps +- findById() returns null for non-existent +- findByTaskId() finds agent by task +- findBySessionId() finds agent by session +- findAll() returns all agents +- findByStatus() filters correctly +- updateStatus() changes status and updatedAt +- updateSessionId() changes sessionId +- delete() removes agent + +Use createTestDatabase() helper from test-helpers.ts for isolated test databases. + +Export DrizzleAgentRepository from drizzle/index.ts. + + npm test -- src/db/repositories/drizzle/agent.test.ts passes all tests + DrizzleAgentRepository implemented with passing tests, exported from barrel file + + + + + +Before declaring plan complete: +- [ ] npm run build succeeds without errors +- [ ] npm test passes all agent repository tests +- [ ] agents table has correct columns and relations +- [ ] AgentRepository interface is exported +- [ ] DrizzleAgentRepository is exported from drizzle/index.ts + + + +- All tasks completed +- All verification checks pass +- No errors or warnings introduced +- Agent persistence layer ready for AgentManager adapter + + + +After completion, create `.planning/phases/04-agent-lifecycle/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-agent-lifecycle/04-02-PLAN.md b/.planning/phases/04-agent-lifecycle/04-02-PLAN.md new file mode 100644 index 0000000..d38f67f --- /dev/null +++ b/.planning/phases/04-agent-lifecycle/04-02-PLAN.md @@ -0,0 +1,255 @@ +--- +phase: 04-agent-lifecycle +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: [src/agent/types.ts, src/agent/index.ts, src/events/types.ts, src/events/index.ts] +autonomous: true +--- + + +Define AgentManager port interface and agent lifecycle domain events. + +Purpose: Establish the contract for agent operations following hexagonal architecture. +Output: AgentManager port interface and AgentSpawned/AgentStopped/AgentCrashed events. + + + +@~/.claude/get-shit-done/workflows/execute-plan.md +@~/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-agent-lifecycle/DISCOVERY.md + +@src/events/types.ts +@src/events/index.ts +@src/git/types.ts + + + + + + Task 1: Define AgentManager port interface and domain types + src/agent/types.ts, src/agent/index.ts + +Create new agent module with port interface following WorktreeManager pattern: + +```typescript +// src/agent/types.ts + +/** + * Agent Module Types + * + * Port interface for agent lifecycle management. + * AgentManager is the PORT. Implementations are ADAPTERS. + */ + +export type AgentStatus = 'idle' | 'running' | 'stopped' | 'crashed'; + +/** + * Options for spawning a new agent + */ +export interface SpawnAgentOptions { + /** Task ID to assign to agent */ + taskId: string; + /** Initial prompt/instruction for the agent */ + prompt: string; + /** Optional working directory (defaults to worktree path) */ + cwd?: string; +} + +/** + * Represents a Claude agent instance + */ +export interface AgentInfo { + /** Unique identifier for this agent */ + id: string; + /** Task this agent is working on */ + taskId: string; + /** Claude SDK session ID for resumption */ + sessionId: string; + /** WorktreeManager worktree ID */ + worktreeId: string; + /** Current status */ + status: AgentStatus; + /** When the agent was created */ + createdAt: Date; + /** Last activity timestamp */ + updatedAt: Date; +} + +/** + * Result from agent execution + */ +export interface AgentResult { + /** Whether the task completed successfully */ + success: boolean; + /** Result message or error description */ + message: string; + /** Files modified during execution */ + filesModified?: string[]; +} + +/** + * AgentManager Port Interface + * + * Manages Claude agent lifecycle - spawn, stop, list, resume. + * + * Covers requirements: + * - AGENT-01: Spawn new agent with task assignment + * - AGENT-02: Stop running agent + * - AGENT-03: List all agents with status + * - AGENT-04: Resume agent session + * - AGENT-05: Background mode (implementation detail) + */ +export interface AgentManager { + /** + * Spawn a new agent to work on a task. + * + * Creates isolated worktree, starts Claude SDK session, + * and begins executing the prompt. + * + * @param options - Spawn configuration + * @returns Agent info with session ID for later resumption + */ + spawn(options: SpawnAgentOptions): Promise; + + /** + * Stop a running agent. + * + * Gracefully stops the agent's work. Worktree is preserved + * for potential resumption. + * + * @param agentId - Agent to stop + */ + stop(agentId: string): Promise; + + /** + * List all agents with their current status. + * + * @returns Array of all agents + */ + list(): Promise; + + /** + * Get a specific agent by ID. + * + * @param agentId - Agent ID + * @returns Agent if found, null otherwise + */ + get(agentId: string): Promise; + + /** + * Resume an idle agent with a new prompt. + * + * Uses stored session ID to continue with full context. + * Agent must be in 'idle' status. + * + * @param agentId - Agent to resume + * @param prompt - New instruction for the agent + */ + resume(agentId: string, prompt: string): Promise; + + /** + * Get the result of an agent's work. + * + * Only available after agent completes or stops. + * + * @param agentId - Agent ID + * @returns Result if available, null if agent still running + */ + getResult(agentId: string): Promise; +} +``` + +Create barrel export: +```typescript +// src/agent/index.ts +export * from './types.js'; +``` + + npm run build passes with no TypeScript errors + AgentManager port interface and types exported from src/agent/ + + + + Task 2: Add agent lifecycle events to events module + src/events/types.ts, src/events/index.ts + +Add agent lifecycle events following existing patterns (ProcessSpawned, WorktreeCreated, etc.): + +```typescript +// Add to src/events/types.ts + +// Agent Events +export interface AgentSpawnedEvent extends DomainEvent { + type: 'agent:spawned'; + payload: { + agentId: string; + taskId: string; + sessionId: string; + worktreeId: string; + }; +} + +export interface AgentStoppedEvent extends DomainEvent { + type: 'agent:stopped'; + payload: { + agentId: string; + taskId: string; + reason: 'user_requested' | 'task_complete' | 'error'; + }; +} + +export interface AgentCrashedEvent extends DomainEvent { + type: 'agent:crashed'; + payload: { + agentId: string; + taskId: string; + error: string; + }; +} + +export interface AgentResumedEvent extends DomainEvent { + type: 'agent:resumed'; + payload: { + agentId: string; + taskId: string; + sessionId: string; + }; +} +``` + +Update the DomainEventType union to include new event types. + +Export new event types from index.ts if not already using * export. + + npm run build passes + Agent lifecycle events defined and exported from events module + + + + + +Before declaring plan complete: +- [ ] npm run build succeeds without errors +- [ ] AgentManager interface exported from src/agent/ +- [ ] Agent lifecycle events (spawned, stopped, crashed, resumed) in events module +- [ ] All types properly exported from barrel files + + + +- All tasks completed +- All verification checks pass +- No errors or warnings introduced +- Port interface ready for adapter implementation + + + +After completion, create `.planning/phases/04-agent-lifecycle/04-02-SUMMARY.md` + diff --git a/.planning/phases/04-agent-lifecycle/04-03-PLAN.md b/.planning/phases/04-agent-lifecycle/04-03-PLAN.md new file mode 100644 index 0000000..26a56fe --- /dev/null +++ b/.planning/phases/04-agent-lifecycle/04-03-PLAN.md @@ -0,0 +1,652 @@ +--- +phase: 04-agent-lifecycle +plan: 03 +type: execute +wave: 2 +depends_on: ["04-01", "04-02"] +files_modified: [package.json, src/agent/manager.ts, src/agent/manager.test.ts, src/agent/index.ts] +autonomous: true +--- + + +Implement ClaudeAgentManager adapter using the Claude Agent SDK. + +Purpose: Provide concrete implementation of AgentManager that spawns real Claude agents. +Output: ClaudeAgentManager adapter with comprehensive tests. + + + +@~/.claude/get-shit-done/workflows/execute-plan.md +@~/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-agent-lifecycle/DISCOVERY.md +@.planning/phases/04-agent-lifecycle/04-01-SUMMARY.md +@.planning/phases/04-agent-lifecycle/04-02-SUMMARY.md + +@src/agent/types.ts +@src/git/types.ts +@src/git/manager.ts +@src/db/repositories/agent-repository.ts +@src/events/types.ts + + + + + + Task 1: Install Claude Agent SDK + package.json + +Install the Claude Agent SDK: + +```bash +npm install @anthropic-ai/claude-agent-sdk +``` + +Verify installation by checking package.json includes the dependency. + +Note: SDK requires Node.js 18+ (already satisfied by project). + + npm ls @anthropic-ai/claude-agent-sdk shows package installed + @anthropic-ai/claude-agent-sdk added to dependencies + + + + Task 2: Implement ClaudeAgentManager adapter + src/agent/manager.ts, src/agent/index.ts + +Create ClaudeAgentManager implementing AgentManager port: + +```typescript +// src/agent/manager.ts +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { randomUUID } from 'crypto'; +import type { AgentManager, AgentInfo, SpawnAgentOptions, AgentResult, AgentStatus } from './types.js'; +import type { AgentRepository } from '../db/repositories/agent-repository.js'; +import type { WorktreeManager } from '../git/types.js'; +import type { EventBus, AgentSpawnedEvent, AgentStoppedEvent, AgentCrashedEvent, AgentResumedEvent } from '../events/index.js'; + +interface ActiveAgent { + abortController: AbortController; + result?: AgentResult; +} + +export class ClaudeAgentManager implements AgentManager { + private activeAgents: Map = new Map(); + + constructor( + private repository: AgentRepository, + private worktreeManager: WorktreeManager, + private eventBus?: EventBus + ) {} + + async spawn(options: SpawnAgentOptions): Promise { + const { taskId, prompt, cwd } = options; + const agentId = randomUUID(); + const branchName = `agent/${agentId}`; + + // 1. Create isolated worktree + const worktree = await this.worktreeManager.create(agentId, branchName); + + // 2. Create agent record (session ID set after SDK init) + const agent = await this.repository.create({ + id: agentId, + taskId, + sessionId: '', // Updated after SDK init + worktreeId: worktree.id, + status: 'running', + }); + + // 3. Start agent execution + const abortController = new AbortController(); + this.activeAgents.set(agentId, { abortController }); + + // Run agent in background (non-blocking) + this.runAgent(agentId, prompt, cwd ?? worktree.path, abortController.signal) + .catch(error => this.handleAgentError(agentId, error)); + + return this.toAgentInfo(agent); + } + + private async runAgent( + agentId: string, + prompt: string, + cwd: string, + signal: AbortSignal + ): Promise { + try { + let sessionId: string | undefined; + + for await (const message of query({ + prompt, + options: { + allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], + permissionMode: 'bypassPermissions', + cwd, + } + })) { + // Check for abort + if (signal.aborted) { + throw new Error('Agent stopped by user'); + } + + // Capture session ID from init message + if (message.type === 'system' && message.subtype === 'init') { + sessionId = message.session_id; + await this.repository.updateSessionId(agentId, sessionId); + + // Emit spawned event now that we have session ID + if (this.eventBus) { + const agent = await this.repository.findById(agentId); + if (agent) { + const event: AgentSpawnedEvent = { + type: 'agent:spawned', + timestamp: new Date(), + payload: { + agentId, + taskId: agent.taskId ?? '', + sessionId, + worktreeId: agent.worktreeId, + }, + }; + this.eventBus.emit(event); + } + } + } + + // Handle result + if (message.type === 'result') { + const active = this.activeAgents.get(agentId); + if (active) { + active.result = { + success: message.subtype === 'success', + message: message.subtype === 'success' + ? 'Task completed successfully' + : 'Task failed', + }; + } + } + } + + // Agent completed successfully + await this.repository.updateStatus(agentId, 'idle'); + + if (this.eventBus) { + const agent = await this.repository.findById(agentId); + if (agent) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId, + taskId: agent.taskId ?? '', + reason: 'task_complete', + }, + }; + this.eventBus.emit(event); + } + } + } catch (error) { + throw error; + } + } + + private async handleAgentError(agentId: string, error: unknown): Promise { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if this was a user-requested stop + if (errorMessage === 'Agent stopped by user') { + await this.repository.updateStatus(agentId, 'stopped'); + return; + } + + // Crashed + await this.repository.updateStatus(agentId, 'crashed'); + + if (this.eventBus) { + const agent = await this.repository.findById(agentId); + if (agent) { + const event: AgentCrashedEvent = { + type: 'agent:crashed', + timestamp: new Date(), + payload: { + agentId, + taskId: agent.taskId ?? '', + error: errorMessage, + }, + }; + this.eventBus.emit(event); + } + } + + // Store error result + const active = this.activeAgents.get(agentId); + if (active) { + active.result = { + success: false, + message: errorMessage, + }; + } + } + + async stop(agentId: string): Promise { + const agent = await this.repository.findById(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + const active = this.activeAgents.get(agentId); + if (active) { + active.abortController.abort(); + this.activeAgents.delete(agentId); + } + + await this.repository.updateStatus(agentId, 'stopped'); + + if (this.eventBus) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId, + taskId: agent.taskId ?? '', + reason: 'user_requested', + }, + }; + this.eventBus.emit(event); + } + } + + async list(): Promise { + const agents = await this.repository.findAll(); + return agents.map(a => this.toAgentInfo(a)); + } + + async get(agentId: string): Promise { + const agent = await this.repository.findById(agentId); + return agent ? this.toAgentInfo(agent) : null; + } + + async resume(agentId: string, prompt: string): Promise { + const agent = await this.repository.findById(agentId); + if (!agent) { + throw new Error(`Agent '${agentId}' not found`); + } + + if (agent.status !== 'idle') { + throw new Error(`Agent '${agentId}' is not idle (status: ${agent.status})`); + } + + if (!agent.sessionId) { + throw new Error(`Agent '${agentId}' has no session to resume`); + } + + // Get worktree path + const worktree = await this.worktreeManager.get(agent.worktreeId); + if (!worktree) { + throw new Error(`Worktree '${agent.worktreeId}' not found`); + } + + // Update status to running + await this.repository.updateStatus(agentId, 'running'); + + // Create new abort controller + const abortController = new AbortController(); + this.activeAgents.set(agentId, { abortController }); + + // Emit resumed event + if (this.eventBus) { + const event: AgentResumedEvent = { + type: 'agent:resumed', + timestamp: new Date(), + payload: { + agentId, + taskId: agent.taskId ?? '', + sessionId: agent.sessionId, + }, + }; + this.eventBus.emit(event); + } + + // Run with resume option + this.runAgentResume(agentId, prompt, worktree.path, agent.sessionId, abortController.signal) + .catch(error => this.handleAgentError(agentId, error)); + } + + private async runAgentResume( + agentId: string, + prompt: string, + cwd: string, + sessionId: string, + signal: AbortSignal + ): Promise { + try { + for await (const message of query({ + prompt, + options: { + allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'], + permissionMode: 'bypassPermissions', + cwd, + resume: sessionId, + } + })) { + if (signal.aborted) { + throw new Error('Agent stopped by user'); + } + + if (message.type === 'result') { + const active = this.activeAgents.get(agentId); + if (active) { + active.result = { + success: message.subtype === 'success', + message: message.subtype === 'success' + ? 'Task completed successfully' + : 'Task failed', + }; + } + } + } + + await this.repository.updateStatus(agentId, 'idle'); + + if (this.eventBus) { + const agent = await this.repository.findById(agentId); + if (agent) { + const event: AgentStoppedEvent = { + type: 'agent:stopped', + timestamp: new Date(), + payload: { + agentId, + taskId: agent.taskId ?? '', + reason: 'task_complete', + }, + }; + this.eventBus.emit(event); + } + } + } catch (error) { + throw error; + } + } + + async getResult(agentId: string): Promise { + const active = this.activeAgents.get(agentId); + return active?.result ?? null; + } + + private toAgentInfo(agent: { id: string; taskId: string | null; sessionId: string; worktreeId: string; status: string; createdAt: Date; updatedAt: Date }): AgentInfo { + return { + id: agent.id, + taskId: agent.taskId ?? '', + sessionId: agent.sessionId, + worktreeId: agent.worktreeId, + status: agent.status as AgentStatus, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + }; + } +} +``` + +Export from index.ts: +```typescript +export * from './types.js'; +export { ClaudeAgentManager } from './manager.js'; +``` + + npm run build passes with no TypeScript errors + ClaudeAgentManager adapter implemented and exported + + + + Task 3: Write tests for AgentManager + src/agent/manager.test.ts + +Create unit tests for ClaudeAgentManager. Since we can't actually spawn Claude agents in tests, mock the SDK: + +```typescript +// src/agent/manager.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClaudeAgentManager } from './manager.js'; +import type { AgentRepository } from '../db/repositories/agent-repository.js'; +import type { WorktreeManager, Worktree } from '../git/types.js'; +import { EventEmitterBus } from '../events/index.js'; + +// Mock the Claude Agent SDK +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: vi.fn(), +})); + +import { query } from '@anthropic-ai/claude-agent-sdk'; + +const mockQuery = vi.mocked(query); + +describe('ClaudeAgentManager', () => { + let manager: ClaudeAgentManager; + let mockRepository: AgentRepository; + let mockWorktreeManager: WorktreeManager; + let eventBus: EventEmitterBus; + + const mockWorktree: Worktree = { + id: 'worktree-123', + branch: 'agent/test', + path: '/tmp/worktree', + isMainWorktree: false, + }; + + const mockAgent = { + id: 'agent-123', + taskId: 'task-456', + sessionId: 'session-789', + worktreeId: 'worktree-123', + status: 'idle' as const, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + mockRepository = { + create: vi.fn().mockResolvedValue(mockAgent), + findById: vi.fn().mockResolvedValue(mockAgent), + findByTaskId: vi.fn().mockResolvedValue(mockAgent), + findBySessionId: vi.fn().mockResolvedValue(mockAgent), + findAll: vi.fn().mockResolvedValue([mockAgent]), + findByStatus: vi.fn().mockResolvedValue([mockAgent]), + updateStatus: vi.fn().mockResolvedValue({ ...mockAgent, status: 'running' }), + updateSessionId: vi.fn().mockResolvedValue({ ...mockAgent, sessionId: 'new-session' }), + delete: vi.fn().mockResolvedValue(undefined), + }; + + mockWorktreeManager = { + create: vi.fn().mockResolvedValue(mockWorktree), + remove: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([mockWorktree]), + get: vi.fn().mockResolvedValue(mockWorktree), + diff: vi.fn().mockResolvedValue({ files: [], summary: '' }), + merge: vi.fn().mockResolvedValue({ success: true, message: 'ok' }), + }; + + eventBus = new EventEmitterBus(); + manager = new ClaudeAgentManager(mockRepository, mockWorktreeManager, eventBus); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('spawn', () => { + it('creates worktree and agent record', async () => { + // Mock query to complete immediately + mockQuery.mockImplementation(async function* () { + yield { type: 'system', subtype: 'init', session_id: 'sess-123' }; + yield { type: 'result', subtype: 'success' }; + }); + + const result = await manager.spawn({ + taskId: 'task-456', + prompt: 'Test task', + }); + + expect(mockWorktreeManager.create).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('agent/') + ); + expect(mockRepository.create).toHaveBeenCalled(); + expect(result.taskId).toBe('task-456'); + }); + + it('emits AgentSpawned event', async () => { + const events: any[] = []; + eventBus.subscribe((event) => events.push(event)); + + mockQuery.mockImplementation(async function* () { + yield { type: 'system', subtype: 'init', session_id: 'sess-123' }; + yield { type: 'result', subtype: 'success' }; + }); + + await manager.spawn({ taskId: 'task-456', prompt: 'Test' }); + + // Wait for async event + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(events.some(e => e.type === 'agent:spawned')).toBe(true); + }); + }); + + describe('stop', () => { + it('stops running agent', async () => { + // First spawn an agent + mockQuery.mockImplementation(async function* () { + yield { type: 'system', subtype: 'init', session_id: 'sess-123' }; + // Hang here - never yield result + await new Promise(() => {}); + }); + + const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' }); + await manager.stop(agent.id); + + expect(mockRepository.updateStatus).toHaveBeenCalledWith(agent.id, 'stopped'); + }); + + it('throws for non-existent agent', async () => { + mockRepository.findById = vi.fn().mockResolvedValue(null); + + await expect(manager.stop('not-found')).rejects.toThrow("Agent 'not-found' not found"); + }); + }); + + describe('list', () => { + it('returns all agents', async () => { + const agents = await manager.list(); + + expect(mockRepository.findAll).toHaveBeenCalled(); + expect(agents).toHaveLength(1); + expect(agents[0].id).toBe('agent-123'); + }); + }); + + describe('get', () => { + it('returns agent by id', async () => { + const agent = await manager.get('agent-123'); + + expect(mockRepository.findById).toHaveBeenCalledWith('agent-123'); + expect(agent?.id).toBe('agent-123'); + }); + + it('returns null for non-existent agent', async () => { + mockRepository.findById = vi.fn().mockResolvedValue(null); + + const agent = await manager.get('not-found'); + + expect(agent).toBeNull(); + }); + }); + + describe('resume', () => { + it('resumes idle agent with existing session', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'result', subtype: 'success' }; + }); + + await manager.resume('agent-123', 'Continue work'); + + expect(mockRepository.updateStatus).toHaveBeenCalledWith('agent-123', 'running'); + }); + + it('throws if agent not idle', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'running', + }); + + await expect(manager.resume('agent-123', 'Continue')).rejects.toThrow('is not idle'); + }); + + it('throws if no session to resume', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + sessionId: '', + }); + + await expect(manager.resume('agent-123', 'Continue')).rejects.toThrow('no session to resume'); + }); + }); + + describe('getResult', () => { + it('returns result after completion', async () => { + mockQuery.mockImplementation(async function* () { + yield { type: 'system', subtype: 'init', session_id: 'sess-123' }; + yield { type: 'result', subtype: 'success' }; + }); + + const agent = await manager.spawn({ taskId: 'task-456', prompt: 'Test' }); + + // Wait for completion + await new Promise(resolve => setTimeout(resolve, 10)); + + const result = await manager.getResult(agent.id); + expect(result?.success).toBe(true); + }); + + it('returns null for unknown agent', async () => { + const result = await manager.getResult('unknown'); + expect(result).toBeNull(); + }); + }); +}); +``` + +Tests mock the Claude Agent SDK since we can't spawn real agents in tests. + + npm test -- src/agent/manager.test.ts passes all tests + ClaudeAgentManager tests pass, verifying spawn, stop, list, get, resume, and getResult + + + + + +Before declaring plan complete: +- [ ] npm run build succeeds without errors +- [ ] npm test passes all agent manager tests +- [ ] @anthropic-ai/claude-agent-sdk installed +- [ ] ClaudeAgentManager implements all AgentManager methods +- [ ] Events emitted on spawn, stop, crash, resume + + + +- All tasks completed +- All verification checks pass +- No errors or warnings introduced +- AgentManager ready for tRPC integration + + + +After completion, create `.planning/phases/04-agent-lifecycle/04-03-SUMMARY.md` + diff --git a/.planning/phases/04-agent-lifecycle/04-04-PLAN.md b/.planning/phases/04-agent-lifecycle/04-04-PLAN.md new file mode 100644 index 0000000..4c5518b --- /dev/null +++ b/.planning/phases/04-agent-lifecycle/04-04-PLAN.md @@ -0,0 +1,326 @@ +--- +phase: 04-agent-lifecycle +plan: 04 +type: execute +wave: 3 +depends_on: ["04-03"] +files_modified: [src/trpc/router.ts, src/trpc/context.ts, src/cli/index.ts, src/cli/trpc-client.ts] +autonomous: true +--- + + +Add agent procedures to tRPC router and CLI commands for agent management. + +Purpose: Enable users to spawn, stop, list, and manage agents via CLI (AGENT-01, 02, 03). +Output: tRPC procedures and CLI commands for full agent lifecycle management. + + + +@~/.claude/get-shit-done/workflows/execute-plan.md +@~/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-agent-lifecycle/DISCOVERY.md +@.planning/phases/04-agent-lifecycle/04-03-SUMMARY.md + +@src/trpc/router.ts +@src/trpc/context.ts +@src/cli/index.ts +@src/cli/trpc-client.ts + + + + + + Task 1: Add AgentManager to tRPC context + src/trpc/context.ts + +Update tRPC context to include AgentManager: + +1. Import AgentManager and ClaudeAgentManager +2. Add agentManager to context type +3. Create agentManager in context factory (requires repository and worktreeManager from context) + +The context should wire up: +- AgentRepository (from database) +- WorktreeManager (from git module) +- EventBus (optional, for event emission) + +Example pattern from existing context: +```typescript +export interface Context { + // ... existing + agentManager: AgentManager; +} + +export function createContext(): Context { + // ... existing setup + const agentRepository = new DrizzleAgentRepository(db); + const agentManager = new ClaudeAgentManager( + agentRepository, + worktreeManager, + eventBus + ); + return { + // ... existing + agentManager, + }; +} +``` + +Note: Context may need to be async if database/worktree setup is async. + + npm run build passes + AgentManager available in tRPC context + + + + Task 2: Add agent procedures to tRPC router + src/trpc/router.ts + +Add agent procedures following existing patterns: + +```typescript +// Add to router.ts + +import { z } from 'zod'; + +// Input schemas +const spawnAgentInput = z.object({ + taskId: z.string(), + prompt: z.string(), + cwd: z.string().optional(), +}); + +const stopAgentInput = z.object({ + agentId: z.string(), +}); + +const resumeAgentInput = z.object({ + agentId: z.string(), + prompt: z.string(), +}); + +const getAgentInput = z.object({ + agentId: z.string(), +}); + +// Add to router +export const appRouter = router({ + // ... existing procedures + + // Agent procedures + spawnAgent: procedure + .input(spawnAgentInput) + .mutation(async ({ ctx, input }) => { + const agent = await ctx.agentManager.spawn({ + taskId: input.taskId, + prompt: input.prompt, + cwd: input.cwd, + }); + return agent; + }), + + stopAgent: procedure + .input(stopAgentInput) + .mutation(async ({ ctx, input }) => { + await ctx.agentManager.stop(input.agentId); + return { success: true }; + }), + + listAgents: procedure + .query(async ({ ctx }) => { + return ctx.agentManager.list(); + }), + + getAgent: procedure + .input(getAgentInput) + .query(async ({ ctx, input }) => { + return ctx.agentManager.get(input.agentId); + }), + + resumeAgent: procedure + .input(resumeAgentInput) + .mutation(async ({ ctx, input }) => { + await ctx.agentManager.resume(input.agentId, input.prompt); + return { success: true }; + }), + + getAgentResult: procedure + .input(getAgentInput) + .query(async ({ ctx, input }) => { + return ctx.agentManager.getResult(input.agentId); + }), +}); +``` + +Export updated AppRouter type for client. + + npm run build passes + Agent tRPC procedures added: spawn, stop, list, get, resume, getResult + + + + Task 3: Add agent CLI commands + src/cli/index.ts + +Add CLI commands for agent management using existing tRPC client pattern: + +```typescript +// Add commands to CLI + +// cw agent spawn +// Spawns a new agent for the given task +program + .command('agent spawn ') + .description('Spawn a new agent to work on a task') + .option('--cwd ', 'Working directory for agent') + .action(async (taskId: string, prompt: string, options: { cwd?: string }) => { + const client = await getTrpcClient(); + try { + const agent = await client.spawnAgent.mutate({ + taskId, + prompt, + cwd: options.cwd, + }); + console.log(`Agent spawned: ${agent.id}`); + console.log(` Task: ${agent.taskId}`); + console.log(` Status: ${agent.status}`); + console.log(` Worktree: ${agent.worktreeId}`); + } catch (error) { + console.error('Failed to spawn agent:', error); + process.exit(1); + } + }); + +// cw agent stop +program + .command('agent stop ') + .description('Stop a running agent') + .action(async (agentId: string) => { + const client = await getTrpcClient(); + try { + await client.stopAgent.mutate({ agentId }); + console.log(`Agent ${agentId} stopped`); + } catch (error) { + console.error('Failed to stop agent:', error); + process.exit(1); + } + }); + +// cw agent list +program + .command('agent list') + .description('List all agents') + .action(async () => { + const client = await getTrpcClient(); + try { + const agents = await client.listAgents.query(); + if (agents.length === 0) { + console.log('No agents found'); + return; + } + console.log('Agents:'); + for (const agent of agents) { + console.log(` ${agent.id} [${agent.status}] - Task: ${agent.taskId}`); + } + } catch (error) { + console.error('Failed to list agents:', error); + process.exit(1); + } + }); + +// cw agent get +program + .command('agent get ') + .description('Get agent details') + .action(async (agentId: string) => { + const client = await getTrpcClient(); + try { + const agent = await client.getAgent.query({ agentId }); + if (!agent) { + console.log(`Agent ${agentId} not found`); + return; + } + console.log(`Agent: ${agent.id}`); + console.log(` Task: ${agent.taskId}`); + console.log(` Session: ${agent.sessionId}`); + console.log(` Worktree: ${agent.worktreeId}`); + console.log(` Status: ${agent.status}`); + console.log(` Created: ${agent.createdAt}`); + console.log(` Updated: ${agent.updatedAt}`); + } catch (error) { + console.error('Failed to get agent:', error); + process.exit(1); + } + }); + +// cw agent resume +program + .command('agent resume ') + .description('Resume an idle agent with a new prompt') + .action(async (agentId: string, prompt: string) => { + const client = await getTrpcClient(); + try { + await client.resumeAgent.mutate({ agentId, prompt }); + console.log(`Agent ${agentId} resumed`); + } catch (error) { + console.error('Failed to resume agent:', error); + process.exit(1); + } + }); + +// cw agent result +program + .command('agent result ') + .description('Get agent execution result') + .action(async (agentId: string) => { + const client = await getTrpcClient(); + try { + const result = await client.getAgentResult.query({ agentId }); + if (!result) { + console.log('No result available (agent may still be running)'); + return; + } + console.log(`Result: ${result.success ? 'SUCCESS' : 'FAILED'}`); + console.log(` Message: ${result.message}`); + if (result.filesModified?.length) { + console.log(` Files modified: ${result.filesModified.join(', ')}`); + } + } catch (error) { + console.error('Failed to get result:', error); + process.exit(1); + } + }); +``` + +Commands use commander.js pattern from existing CLI. + + npm run build passes, cw agent --help shows commands + CLI commands added: agent spawn, stop, list, get, resume, result + + + + + +Before declaring plan complete: +- [ ] npm run build succeeds without errors +- [ ] cw agent --help shows all agent commands +- [ ] Agent procedures accessible via tRPC client +- [ ] All 6 requirements satisfied (AGENT-01 through AGENT-07 except AGENT-06) + + + +- All tasks completed +- All verification checks pass +- No errors or warnings introduced +- Users can manage agents via CLI + + + +After completion, create `.planning/phases/04-agent-lifecycle/04-04-SUMMARY.md` + diff --git a/.planning/phases/04-agent-lifecycle/DISCOVERY.md b/.planning/phases/04-agent-lifecycle/DISCOVERY.md new file mode 100644 index 0000000..41aee36 --- /dev/null +++ b/.planning/phases/04-agent-lifecycle/DISCOVERY.md @@ -0,0 +1,220 @@ +# Phase 4: Agent Lifecycle - Discovery + +**Research Level:** 2 (Standard Research) +**Completed:** 2026-01-30 + +## Research Questions + +1. How do we spawn Claude Code agents programmatically? +2. What's the output format for background/non-interactive agents? +3. How do we persist agent sessions across terminal close/reopen? +4. How do we manage the process lifecycle? + +## Key Finding: Claude Agent SDK + +**The Claude Agent SDK** (formerly Claude Code SDK) is the correct approach. It provides: + +- Programmatic agent spawning via `query()` function +- Built-in tool execution (no need to implement tool loop) +- Session persistence via session IDs +- Streaming message output +- Multi-turn conversations + +### Installation + +```bash +npm install @anthropic-ai/claude-agent-sdk +``` + +Requires Node.js 18+. + +### API Patterns + +**Single-turn / autonomous task (V1 - recommended for our use case):** + +```typescript +import { query } from "@anthropic-ai/claude-agent-sdk"; + +for await (const message of query({ + prompt: "Find and fix the bug in auth.py", + options: { + allowedTools: ["Read", "Edit", "Bash"], + permissionMode: "bypassPermissions" // For automation + } +})) { + if ("result" in message) console.log(message.result); +} +``` + +**Multi-turn sessions (V2 preview):** + +```typescript +import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk' + +await using session = unstable_v2_createSession({ + model: 'claude-sonnet-4-5-20250929' +}) + +await session.send('Read the auth module') +for await (const msg of session.stream()) { + // Process messages +} +``` + +### Session Persistence (AGENT-04) + +Sessions can be captured and resumed: + +```typescript +let sessionId: string | undefined; + +// First query: capture session ID +for await (const message of query({ + prompt: "Read the authentication module", + options: { allowedTools: ["Read", "Glob"] } +})) { + if (message.type === "system" && message.subtype === "init") { + sessionId = message.session_id; // Store this! + } +} + +// Later: resume with full context +for await (const message of query({ + prompt: "Now find all places that call it", + options: { resume: sessionId } +})) { + // Full context preserved +} +``` + +**Decision:** Store `session_id` in database for persistence across terminal sessions. + +### Key Options + +| Option | Purpose | +|--------|---------| +| `allowedTools` | Array of tool names (Read, Edit, Bash, Glob, etc.) | +| `permissionMode` | "bypassPermissions" for automation, "acceptEdits" for safer mode | +| `resume` | Session ID to resume from | +| `maxTurns` | Maximum agentic turns before stopping | +| `cwd` | Working directory for agent operations | + +### Message Types + +| Type | Subtype | Contains | +|------|---------|----------| +| `system` | `init` | `session_id` - capture this for persistence | +| `assistant` | - | `message.content` with text blocks | +| `result` | `success`/`error` | Final result | + +### Working Directory & Worktrees + +The SDK accepts `cwd` option for working directory. We'll set this to the worktree path: + +```typescript +for await (const message of query({ + prompt: task.description, + options: { + allowedTools: ["Read", "Edit", "Bash", "Glob", "Grep"], + cwd: worktree.path, // Agent operates in isolated worktree + permissionMode: "bypassPermissions" + } +})) { + // ... +} +``` + +## Architecture Decision + +### AgentManager Port Interface + +Following hexagonal architecture (same as EventBus, WorktreeManager): + +```typescript +interface AgentManager { + // Spawn new agent with task assignment (AGENT-01) + spawn(taskId: string, prompt: string): Promise; + + // Stop running agent (AGENT-02) + stop(agentId: string): Promise; + + // List all agents with status (AGENT-03) + list(): Promise; + + // Get single agent + get(agentId: string): Promise; + + // Resume agent session (AGENT-04) + resume(agentId: string, prompt: string): Promise; +} + +interface Agent { + id: string; + taskId: string; + sessionId: string; // Claude SDK session ID for persistence + worktreeId: string; // WorktreeManager worktree ID + status: 'running' | 'idle' | 'stopped' | 'crashed'; + createdAt: Date; + lastActivityAt: Date; +} +``` + +### Database Schema Addition + +Add `agents` table to persist agent state: + +```sql +CREATE TABLE agents ( + id TEXT PRIMARY KEY, + task_id TEXT REFERENCES tasks(id), + session_id TEXT NOT NULL, -- Claude SDK session ID + worktree_id TEXT NOT NULL, -- WorktreeManager worktree ID + status TEXT NOT NULL DEFAULT 'idle', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +``` + +### Integration with Existing Infrastructure + +1. **WorktreeManager** - Create isolated worktree per agent +2. **EventBus** - Emit agent lifecycle events (AgentSpawned, AgentStopped, etc.) +3. **Database** - Persist agent state for session resumption +4. **tRPC** - Expose agent operations to CLI + +## Requirements Mapping + +| Requirement | Solution | +|-------------|----------| +| AGENT-01: Spawn with task | `AgentManager.spawn(taskId, prompt)` creates worktree, starts SDK query | +| AGENT-02: Stop agent | `AgentManager.stop(id)` - abort async generator, emit stop event | +| AGENT-03: List agents | `AgentManager.list()` - query database, return with status | +| AGENT-04: Session persistence | Store `session_id` in DB, use `resume` option to continue | +| AGENT-05: Background mode | SDK runs in Node.js event loop, no terminal needed | +| AGENT-07: JSON output | SDK streams structured messages, not raw CLI output | + +## Plan Structure + +**4 plans in 3 waves:** + +``` +Wave 1 (parallel): + 04-01: Agent schema + repository (database layer) + 04-02: AgentManager port + events (interface + types) + +Wave 2: + 04-03: AgentManager adapter (SDK implementation) + +Wave 3: + 04-04: tRPC integration + CLI commands +``` + +## Sources + +- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/agent-sdk/overview) +- [TypeScript SDK Reference](https://platform.claude.com/docs/en/agent-sdk/typescript) +- [Claude Agent SDK TypeScript V2 Preview](https://platform.claude.com/docs/en/agent-sdk/typescript-v2-preview) +- [Claude Code CLI Reference](https://code.claude.com/docs/en/cli-reference) + +--- +*Discovery completed: 2026-01-30*