diff --git a/Sources/KanbanCode/CardDetailView.swift b/Sources/KanbanCode/CardDetailView.swift index 2f439b6b..937b1df9 100644 --- a/Sources/KanbanCode/CardDetailView.swift +++ b/Sources/KanbanCode/CardDetailView.swift @@ -1537,8 +1537,8 @@ struct CardDetailView: View { guard let path = card.link.sessionLink?.sessionPath ?? card.session?.jsonlPath else { return } if turns.isEmpty { isLoadingHistory = true } do { - if card.link.effectiveAssistant == .gemini { - // Gemini uses JSON format — load all turns via session store + if card.link.effectiveAssistant != .claude { + // Non-Claude assistants load the full transcript via their session store. let allTurns = try await sessionStore.readTranscript(sessionPath: path) turns = allTurns hasMoreTurns = false diff --git a/Sources/KanbanCode/CardView.swift b/Sources/KanbanCode/CardView.swift index 95552c0a..3036873f 100644 --- a/Sources/KanbanCode/CardView.swift +++ b/Sources/KanbanCode/CardView.swift @@ -304,6 +304,9 @@ struct AssistantIcon: View { SessionIcon() case .gemini: GeminiSparkle() + case .mastracode: + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: 14, weight: .semibold)) } } @@ -317,6 +320,8 @@ struct AssistantIcon: View { return SessionIcon.resizedForMenu(src, to: size) case .gemini: return geminiMenuImage(size: size) + case .mastracode: + return NSImage(systemSymbolName: "point.3.connected.trianglepath.dotted", accessibilityDescription: nil) } } diff --git a/Sources/KanbanCode/ContentView.swift b/Sources/KanbanCode/ContentView.swift index 49913ea6..331521ac 100644 --- a/Sources/KanbanCode/ContentView.swift +++ b/Sources/KanbanCode/ContentView.swift @@ -109,6 +109,9 @@ struct ContentView: View { let geminiDiscovery = GeminiSessionDiscovery() let geminiDetector = GeminiActivityDetector() let geminiStore = GeminiSessionStore() + let mastraDiscovery = MastracodeSessionDiscovery() + let mastraDetector = MastracodeActivityDetector() + let mastraStore = MastracodeSessionStore() let enabledAssistants = Self.loadEnabledAssistants() let registry = CodingAssistantRegistry() @@ -118,6 +121,9 @@ struct ContentView: View { if enabledAssistants.contains(.gemini) { registry.register(.gemini, discovery: geminiDiscovery, detector: geminiDetector, store: geminiStore) } + if enabledAssistants.contains(.mastracode) { + registry.register(.mastracode, discovery: mastraDiscovery, detector: mastraDetector, store: mastraStore) + } let discovery = CompositeSessionDiscovery(registry: registry) let activityDetector = CompositeActivityDetector(registry: registry, defaultDetector: claudeDetector) @@ -215,6 +221,8 @@ struct ContentView: View { assistantRegistry.register(.claude, discovery: ClaudeCodeSessionDiscovery(), detector: ClaudeCodeActivityDetector(), store: ClaudeCodeSessionStore()) case .gemini: assistantRegistry.register(.gemini, discovery: GeminiSessionDiscovery(), detector: GeminiActivityDetector(), store: GeminiSessionStore()) + case .mastracode: + assistantRegistry.register(.mastracode, discovery: MastracodeSessionDiscovery(), detector: MastracodeActivityDetector(), store: MastracodeSessionStore()) } } } else { @@ -1974,9 +1982,9 @@ struct ContentView: View { try? RemoteShellManager.deploy() shellOverride = RemoteShellManager.shellOverridePath() var env = RemoteShellManager.setupEnvironment(remote: remote, projectPath: projectPath) - // Gemini CLI uses `bash -c` directly (not $SHELL), so prepend + // Node-based CLIs use `bash -c` directly (not $SHELL), so prepend // our remote dir to PATH so it finds our bash wrapper first. - if assistant == .gemini { + if assistant.needsRemotePathShim { let remoteDir = RemoteShellManager.remoteDirPath() env["PATH"] = "\(remoteDir):$PATH" } @@ -2002,11 +2010,19 @@ struct ContentView: View { } // Snapshot existing session files for detection - let sessionFileExt = assistant == .gemini ? ".json" : ".jsonl" + let sessionFileExt = assistant.sessionFileExtension let configDir = (NSHomeDirectory() as NSString).appendingPathComponent(assistant.configDirName) let claudeProjectsDir = (configDir as NSString).appendingPathComponent("projects") let encodedProject = projectPath.replacingOccurrences(of: "/", with: "-") let sessionDir = (claudeProjectsDir as NSString).appendingPathComponent(encodedProject) + let existingMastraSessionIds: Set + if assistant == .mastracode { + let discovery = MastracodeSessionDiscovery() + let sessions = (try? await discovery.discoverSessions()) ?? [] + existingMastraSessionIds = Set(sessions.map(\.id)) + } else { + existingMastraSessionIds = [] + } // When worktree is enabled, also snapshot worktree-related directories // (worktrees create sessions in dirs like -.claude-worktrees-) @@ -2018,20 +2034,24 @@ struct ContentView: View { dirsToSnapshot = slugDirs.map { slug in (tmpDir as NSString).appendingPathComponent(slug).appending("/chats") } - } else if worktreeName != nil { + } else if assistant == .claude, worktreeName != nil { let allDirs = (try? FileManager.default.contentsOfDirectory(atPath: claudeProjectsDir)) ?? [] dirsToSnapshot = [sessionDir] + allDirs .filter { $0.hasPrefix(encodedProject) && $0 != encodedProject } .map { (claudeProjectsDir as NSString).appendingPathComponent($0) } + } else if assistant == .mastracode { + dirsToSnapshot = [] } else { dirsToSnapshot = [sessionDir] } var existingFilesByDir: [String: Set] = [:] - for dir in dirsToSnapshot { - existingFilesByDir[dir] = Set( - ((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []) - .filter { $0.hasSuffix(sessionFileExt) } - ) + if let sessionFileExt { + for dir in dirsToSnapshot { + existingFilesByDir[dir] = Set( + ((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []) + .filter { $0.hasSuffix(sessionFileExt) } + ) + } } let tmuxName = try await launcher.launch( @@ -2069,7 +2089,7 @@ struct ContentView: View { } if !prompt.isEmpty { - if assistant == .gemini { + if assistant.usesPastedPrompt { try await self.tmuxAdapter.pastePrompt(to: tmuxName, text: prompt) } else { try await self.tmuxAdapter.sendPrompt(to: tmuxName, text: prompt) @@ -2078,12 +2098,30 @@ struct ContentView: View { } // Detect new session by polling for new session file - // Worktree launches and Gemini need more attempts (slower startup) - let maxAttempts = (worktreeName != nil || assistant == .gemini) ? 12 : 6 + // Worktree launches and non-Claude assistants need more attempts (slower startup) + let maxAttempts = (worktreeName != nil || assistant != .claude) ? 12 : 6 var sessionLink: SessionLink? for attempt in 0.. Bool { let path = settingsPath ?? defaultSettingsPath(for: assistant) guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let hooks = root["hooks"] as? [String: Any] else { + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } + if assistant == .mastracode { + return requiredHooks(for: assistant).allSatisfy { eventName in + guard let entries = root[eventName] as? [[String: Any]] else { return false } + return entries.contains { entry in + (entry["command"] as? String)?.contains(".kanban-code/hook.sh") == true + } + } + } + + guard let hooks = root["hooks"] as? [String: Any] else { return false } return requiredHooks(for: assistant).allSatisfy { eventName in guard let groups = hooks[eventName] as? [[String: Any]] else { return false } return groups.contains { group in @@ -82,6 +93,33 @@ public enum HookManager { root = [:] } + if assistant == .mastracode { + let hookEntry: [String: Any] = [ + "type": "command", + "command": scriptPath, + "description": "Kanban Code hook bridge", + ] + + for eventName in requiredHooks(for: assistant) { + var entries = root[eventName] as? [[String: Any]] ?? [] + let alreadyInstalled = entries.contains { + ($0["command"] as? String)?.contains(".kanban-code/hook.sh") == true + } + if !alreadyInstalled { + entries.append(hookEntry) + } + root[eventName] = entries + } + + let fileManager = FileManager.default + let dir = (resolvedSettingsPath as NSString).deletingLastPathComponent + try fileManager.createDirectory(atPath: dir, withIntermediateDirectories: true) + + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: URL(fileURLWithPath: resolvedSettingsPath)) + return + } + var hooks = root["hooks"] as? [String: Any] ?? [:] let hookEntry: [String: Any] = [ @@ -139,11 +177,28 @@ public enum HookManager { let resolvedSettingsPath = settingsPath ?? defaultSettingsPath(for: assistant) guard let data = try? Data(contentsOf: URL(fileURLWithPath: resolvedSettingsPath)), - var root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - var hooks = root["hooks"] as? [String: Any] else { + var root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + + if assistant == .mastracode { + for eventName in requiredHooks(for: assistant) { + var entries = root[eventName] as? [[String: Any]] ?? [] + entries.removeAll { ($0["command"] as? String)?.contains(".kanban-code/hook.sh") == true } + if entries.isEmpty { + root.removeValue(forKey: eventName) + } else { + root[eventName] = entries + } + } + + let newData = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: URL(fileURLWithPath: resolvedSettingsPath)) return } + guard var hooks = root["hooks"] as? [String: Any] else { return } + for eventName in requiredHooks(for: assistant) { if var groups = hooks[eventName] as? [[String: Any]] { for i in groups.indices { @@ -230,7 +285,12 @@ public enum HookManager { /// Settings file path per assistant. public static func defaultSettingsPath(for assistant: CodingAssistant) -> String { - (NSHomeDirectory() as NSString).appendingPathComponent("\(assistant.configDirName)/settings.json") + switch assistant { + case .claude, .gemini: + (NSHomeDirectory() as NSString).appendingPathComponent("\(assistant.configDirName)/settings.json") + case .mastracode: + (NSHomeDirectory() as NSString).appendingPathComponent(".mastracode/hooks.json") + } } private static func defaultSettingsPath() -> String { diff --git a/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeActivityDetector.swift b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeActivityDetector.swift new file mode 100644 index 00000000..88b70a7b --- /dev/null +++ b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeActivityDetector.swift @@ -0,0 +1,87 @@ +import Foundation + +public actor MastracodeActivityDetector: ActivityDetector { + private var polledStates: [String: ActivityState] = [:] + private var hookStates: [String: ActivityState] = [:] + private var lastEventTime: [String: Date] = [:] + + private let activeThreshold: TimeInterval + private let attentionThreshold: TimeInterval + + public init(activeThreshold: TimeInterval = 120, attentionThreshold: TimeInterval = 300) { + self.activeThreshold = activeThreshold + self.attentionThreshold = attentionThreshold + } + + public func handleHookEvent(_ event: HookEvent) async { + lastEventTime[event.sessionId] = event.timestamp + + switch HookManager.normalizeEventName(event.eventName) { + case "UserPromptSubmit": + hookStates[event.sessionId] = .activelyWorking + case "SessionStart": + hookStates[event.sessionId] = .idleWaiting + case "Stop", "Notification": + hookStates[event.sessionId] = .needsAttention + case "SessionEnd": + hookStates[event.sessionId] = .ended + default: + break + } + } + + public func pollActivity(sessionPaths: [String: String]) async -> [String: ActivityState] { + var states: [String: ActivityState] = [:] + + for (sessionId, sessionPath) in sessionPaths { + if let hookState = effectiveHookState(for: sessionId) { + states[sessionId] = hookState + continue + } + + guard let resolved = MastracodeSessionPath.decode(sessionPath), + let updatedAt = try? MastracodeDatabase.readUpdatedAt( + databasePath: resolved.databasePath, + threadId: resolved.threadId + ), + let updatedDate = makeFractionalISO8601Formatter().date(from: updatedAt) + ?? ISO8601DateFormatter().date(from: updatedAt) else { + states[sessionId] = .ended + continue + } + + let age = Date.now.timeIntervalSince(updatedDate) + if age < activeThreshold { + states[sessionId] = .activelyWorking + } else if age < attentionThreshold { + states[sessionId] = .needsAttention + } else if age < 3600 { + states[sessionId] = .idleWaiting + } else if age < 86400 { + states[sessionId] = .ended + } else { + states[sessionId] = .stale + } + } + + for (sessionId, state) in states { + polledStates[sessionId] = state + } + return states + } + + public func activityState(for sessionId: String) async -> ActivityState { + effectiveHookState(for: sessionId) ?? polledStates[sessionId] ?? .stale + } + + private func effectiveHookState(for sessionId: String) -> ActivityState? { + guard let hookState = hookStates[sessionId] else { return nil } + if hookState == .activelyWorking, + let eventTime = lastEventTime[sessionId], + Date.now.timeIntervalSince(eventTime) > attentionThreshold { + hookStates[sessionId] = .needsAttention + return .needsAttention + } + return hookState + } +} diff --git a/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeDatabase.swift b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeDatabase.swift new file mode 100644 index 00000000..7e08dcab --- /dev/null +++ b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeDatabase.swift @@ -0,0 +1,582 @@ +import Foundation +import SQLite3 + +struct MastracodeThreadRecord: Sendable { + let id: String + let title: String + let metadata: String? + let createdAt: String + let updatedAt: String + let messageCount: Int + let firstUserContent: String? +} + +struct MastracodeMessageRecord: Sendable { + let role: String + let type: String + let content: String + let createdAt: String +} + +enum MastracodeDatabase { + private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + static func readThreads(databasePath: String) throws -> [MastracodeThreadRecord] { + try withDatabase(at: databasePath, readOnly: true) { db in + let sql = """ + SELECT + t.id, + t.title, + t.metadata, + t.createdAt, + t.updatedAt, + (SELECT COUNT(*) FROM mastra_messages WHERE thread_id = t.id) AS messageCount, + (SELECT content FROM mastra_messages + WHERE thread_id = t.id AND role = 'user' + ORDER BY createdAt ASC LIMIT 1) AS firstUserContent + FROM mastra_threads t + ORDER BY t.updatedAt DESC + """ + + var rows: [MastracodeThreadRecord] = [] + try query(db: db, sql: sql) { stmt in + rows.append( + MastracodeThreadRecord( + id: text(stmt, index: 0) ?? "", + title: text(stmt, index: 1) ?? "", + metadata: text(stmt, index: 2), + createdAt: text(stmt, index: 3) ?? "", + updatedAt: text(stmt, index: 4) ?? "", + messageCount: Int(sqlite3_column_int64(stmt, 5)), + firstUserContent: text(stmt, index: 6) + ) + ) + } + return rows + } + } + + static func readMessages(databasePath: String, threadId: String) throws -> [MastracodeMessageRecord] { + try withDatabase(at: databasePath, readOnly: true) { db in + let sql = """ + SELECT role, type, content, createdAt + FROM mastra_messages + WHERE thread_id = ? + ORDER BY createdAt ASC, id ASC + """ + var rows: [MastracodeMessageRecord] = [] + try query(db: db, sql: sql, bind: [.text(threadId)]) { stmt in + rows.append( + MastracodeMessageRecord( + role: text(stmt, index: 0) ?? "", + type: text(stmt, index: 1) ?? "", + content: text(stmt, index: 2) ?? "", + createdAt: text(stmt, index: 3) ?? "" + ) + ) + } + return rows + } + } + + static func readUpdatedAt(databasePath: String, threadId: String) throws -> String? { + try withDatabase(at: databasePath, readOnly: true) { db in + let sql = "SELECT updatedAt FROM mastra_threads WHERE id = ? LIMIT 1" + var value: String? + try query(db: db, sql: sql, bind: [.text(threadId)]) { stmt in + value = text(stmt, index: 0) + } + return value + } + } + + static func writeSession( + databasePath: String, + threadId: String, + title: String, + projectPath: String?, + turns: [ConversationTurn] + ) throws { + try ensureSchema(databasePath: databasePath) + + try withDatabase(at: databasePath, readOnly: false) { db in + try exec(db: db, sql: "BEGIN IMMEDIATE TRANSACTION") + do { + let firstTimestamp = turns.first?.timestamp ?? isoNow() + let lastTimestamp = turns.last?.timestamp ?? firstTimestamp + let metadata = try jsonString(["projectPath": projectPath ?? ""]) + + try exec( + db: db, + sql: """ + INSERT OR REPLACE INTO mastra_threads (id, resourceId, title, metadata, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + """, + bind: [ + .text(threadId), + .text(projectPath ?? (threadId as NSString).lastPathComponent), + .text(title), + .text(metadata), + .text(firstTimestamp), + .text(lastTimestamp), + ] + ) + + try exec( + db: db, + sql: "DELETE FROM mastra_messages WHERE thread_id = ?", + bind: [.text(threadId)] + ) + + for (index, turn) in turns.enumerated() { + let messageId = "msg-\(threadId)-\(index)" + let payload = try messagePayload(for: turn) + try exec( + db: db, + sql: """ + INSERT INTO mastra_messages (id, thread_id, content, role, type, createdAt, resourceId) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + bind: [ + .text(messageId), + .text(threadId), + .text(payload.json), + .text(payload.role), + .text(payload.type), + .text(turn.timestamp ?? isoNow()), + .text(projectPath), + ] + ) + } + + try exec(db: db, sql: "COMMIT") + } catch { + try? exec(db: db, sql: "ROLLBACK") + throw error + } + } + } + + static func backupAndDeleteSession(databasePath: String, threadId: String) throws -> String { + let thread = try readThreads(databasePath: databasePath).first { $0.id == threadId } + let messages = try readMessages(databasePath: databasePath, threadId: threadId) + + let backupPath = databasePath + ".\(threadId).bak.json" + let backup: [String: Any] = [ + "thread": [ + "id": thread?.id ?? threadId, + "title": thread?.title ?? "", + "metadata": thread?.metadata ?? "", + "createdAt": thread?.createdAt ?? "", + "updatedAt": thread?.updatedAt ?? "", + ], + "messages": messages.map { + [ + "role": $0.role, + "type": $0.type, + "content": $0.content, + "createdAt": $0.createdAt, + ] + }, + ] + let backupData = try JSONSerialization.data(withJSONObject: backup, options: [.prettyPrinted, .sortedKeys]) + try backupData.write(to: URL(fileURLWithPath: backupPath)) + + try withDatabase(at: databasePath, readOnly: false) { db in + try exec(db: db, sql: "BEGIN IMMEDIATE TRANSACTION") + do { + try exec(db: db, sql: "DELETE FROM mastra_messages WHERE thread_id = ?", bind: [.text(threadId)]) + try exec(db: db, sql: "DELETE FROM mastra_threads WHERE id = ?", bind: [.text(threadId)]) + try exec(db: db, sql: "COMMIT") + } catch { + try? exec(db: db, sql: "ROLLBACK") + throw error + } + } + + return backupPath + } + + static func ensureSchema(databasePath: String) throws { + let parentDir = (databasePath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true) + + try withDatabase(at: databasePath, readOnly: false) { db in + try exec( + db: db, + sql: """ + CREATE TABLE IF NOT EXISTS mastra_threads ( + id TEXT NOT NULL PRIMARY KEY, + resourceId TEXT NOT NULL, + title TEXT NOT NULL, + metadata TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + """ + ) + try exec( + db: db, + sql: """ + CREATE TABLE IF NOT EXISTS mastra_messages ( + id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + content TEXT NOT NULL, + role TEXT NOT NULL, + type TEXT NOT NULL, + createdAt TEXT NOT NULL, + resourceId TEXT + ); + """ + ) + } + } + + static func extractProjectPath(from metadata: String?) -> String? { + guard let metadata, !metadata.isEmpty else { return nil } + + if let object = jsonObject(from: metadata), + let projectPath = findString(in: object, matchingKeys: ["projectPath", "cwd", "workingDirectory", "workspaceRoot", "repositoryRoot"]), + !projectPath.isEmpty { + return projectPath + } + + let text = metadata.unicodeScalars.map { scalar in + CharacterSet.controlCharacters.contains(scalar) ? " " : String(scalar) + }.joined() + guard let keyRange = text.range(of: "projectPath") else { return nil } + let suffix = text[keyRange.upperBound...] + guard let pathStart = suffix.firstIndex(where: { $0 == "/" || $0 == "~" }) else { return nil } + let path = suffix[pathStart...].prefix { character in + character.unicodeScalars.allSatisfy { scalar in + !CharacterSet.controlCharacters.contains(scalar) && !CharacterSet.whitespacesAndNewlines.contains(scalar) + } + } + return path.isEmpty ? nil : String(path) + } + + static func extractUserText(from content: String?) -> String? { + guard let content, let object = jsonObject(from: content) else { return nil } + if let text = object["content"] as? String, !text.isEmpty { + return text + } + if let parts = object["parts"] as? [[String: Any]] { + let textParts = parts.compactMap { part -> String? in + guard (part["type"] as? String) == "text" else { return nil } + return part["text"] as? String + } + let joined = textParts.joined(separator: "\n") + return joined.isEmpty ? nil : joined + } + return nil + } + + static func parseTurns(messages: [MastracodeMessageRecord]) -> [ConversationTurn] { + messages.enumerated().map { index, message in + let role: String + switch message.role { + case "user": + role = "user" + case "assistant": + role = "assistant" + default: + role = "system" + } + + let blocks = parseBlocks(content: message.content, role: role) + let preview = buildTextPreview(blocks: blocks, fallback: extractUserText(from: message.content) ?? "") + return ConversationTurn( + index: index, + lineNumber: index + 1, + role: role, + textPreview: preview.isEmpty ? message.type : preview, + timestamp: message.createdAt, + contentBlocks: blocks + ) + } + } + + private static func parseBlocks(content: String, role: String) -> [ContentBlock] { + guard let object = jsonObject(from: content) else { + return [ContentBlock(kind: .text, text: content)] + } + + if role != "assistant" { + if let text = extractUserText(from: content), !text.isEmpty { + return [ContentBlock(kind: .text, text: text)] + } + return [] + } + + var blocks: [ContentBlock] = [] + let parts = object["parts"] as? [[String: Any]] ?? [] + for part in parts { + switch part["type"] as? String { + case "text": + if let text = part["text"] as? String, !text.isEmpty { + blocks.append(ContentBlock(kind: .text, text: text)) + } + case "reasoning": + let reasoning = (part["reasoning"] as? String) + ?? ((part["details"] as? [[String: Any]])?.compactMap { $0["text"] as? String }.joined(separator: "\n")) + if let reasoning, !reasoning.isEmpty { + blocks.append(ContentBlock(kind: .thinking, text: String(reasoning.prefix(500)))) + } + case "tool-invocation": + if let toolInvocation = part["toolInvocation"] as? [String: Any] { + let name = (toolInvocation["toolName"] as? String) ?? "unknown" + let args = stringifyMap(toolInvocation["args"] as? [String: Any] ?? [:]) + blocks.append(ContentBlock(kind: .toolUse(name: name, input: args), text: name)) + if let resultText = toolResultText(from: toolInvocation["result"]), !resultText.isEmpty { + blocks.append(ContentBlock(kind: .toolResult(toolName: name), text: resultText)) + } + } + default: + continue + } + } + + if blocks.isEmpty, let text = object["content"] as? String, !text.isEmpty { + blocks.append(ContentBlock(kind: .text, text: text)) + } + + return blocks + } + + private static func buildTextPreview(blocks: [ContentBlock], fallback: String) -> String { + for block in blocks { + switch block.kind { + case .text, .thinking, .toolResult: + if !block.text.isEmpty { return block.text } + case .toolUse(let name, _): + return name + } + } + return fallback + } + + private static func messagePayload(for turn: ConversationTurn) throws -> (json: String, role: String, type: String) { + switch turn.role { + case "user": + let text = extractText(turn: turn) + return (try jsonString([ + "format": 2, + "parts": [["type": "text", "text": text]], + "content": text, + ]), "user", "message") + case "assistant": + var parts: [[String: Any]] = [] + var lastToolIndex: Int? + + for block in turn.contentBlocks { + switch block.kind { + case .text: + parts.append(["type": "text", "text": block.text]) + case .thinking: + parts.append([ + "type": "reasoning", + "reasoning": block.text, + "details": [["type": "text", "text": block.text]], + ]) + case .toolUse(let name, let input): + parts.append([ + "type": "tool-invocation", + "toolInvocation": [ + "state": "pending", + "toolName": name, + "args": input, + ], + ]) + lastToolIndex = parts.count - 1 + case .toolResult: + if let lastToolIndex, + var toolPart = parts[lastToolIndex]["toolInvocation"] as? [String: Any] { + toolPart["state"] = "result" + toolPart["result"] = ["content": block.text] + parts[lastToolIndex]["toolInvocation"] = toolPart + } else { + parts.append(["type": "text", "text": block.text]) + } + } + } + + let text = extractText(turn: turn) + return (try jsonString([ + "format": 2, + "parts": parts, + "content": text, + ]), "assistant", "message") + default: + let text = extractText(turn: turn) + return (try jsonString([ + "format": 2, + "parts": [["type": "text", "text": text]], + "content": text, + ]), "system", "notification") + } + } + + private static func extractText(turn: ConversationTurn) -> String { + let text = turn.contentBlocks.compactMap { block -> String? in + if case .text = block.kind { return block.text } + return nil + }.joined(separator: "\n") + return text.isEmpty ? turn.textPreview : text + } + + private static func toolResultText(from result: Any?) -> String? { + switch result { + case let text as String: + return text + case let dict as [String: Any]: + if let content = dict["content"] as? String { return content } + if JSONSerialization.isValidJSONObject(dict), + let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) { + return text + } + return nil + case let array as [Any]: + if JSONSerialization.isValidJSONObject(array), + let data = try? JSONSerialization.data(withJSONObject: array, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) { + return text + } + return nil + default: + return nil + } + } + + private static func stringifyMap(_ dictionary: [String: Any]) -> [String: String] { + dictionary.reduce(into: [String: String]()) { result, item in + if let value = item.value as? String { + result[item.key] = value + } else if let number = item.value as? NSNumber { + result[item.key] = number.stringValue + } else if JSONSerialization.isValidJSONObject([item.key: item.value]), + let data = try? JSONSerialization.data(withJSONObject: item.value, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) { + result[item.key] = text + } + } + } + + private static func jsonObject(from text: String) -> [String: Any]? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return object + } + + private static func findString(in object: Any, matchingKeys keys: Set) -> String? { + if let dictionary = object as? [String: Any] { + for (key, value) in dictionary { + if keys.contains(key), let text = value as? String, !text.isEmpty { + return text + } + if let found = findString(in: value, matchingKeys: keys) { + return found + } + } + } else if let array = object as? [Any] { + for item in array { + if let found = findString(in: item, matchingKeys: keys) { + return found + } + } + } + return nil + } + + private enum SQLiteValue { + case text(String?) + } + + private static func withDatabase(at path: String, readOnly: Bool, _ body: (OpaquePointer) throws -> T) throws -> T { + var db: OpaquePointer? + let flags = readOnly ? SQLITE_OPEN_READONLY : (SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE) + guard sqlite3_open_v2(path, &db, flags, nil) == SQLITE_OK, let db else { + throw sqliteError(db) + } + defer { sqlite3_close(db) } + return try body(db) + } + + private static func query( + db: OpaquePointer, + sql: String, + bind: [SQLiteValue] = [], + row: (OpaquePointer) throws -> Void + ) throws { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + throw sqliteError(db) + } + defer { sqlite3_finalize(stmt) } + try bindValues(bind, to: stmt) + + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_ROW { + try row(stmt) + } else if step == SQLITE_DONE { + return + } else { + throw sqliteError(db) + } + } + } + + private static func exec(db: OpaquePointer, sql: String, bind: [SQLiteValue] = []) throws { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + throw sqliteError(db) + } + defer { sqlite3_finalize(stmt) } + try bindValues(bind, to: stmt) + + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw sqliteError(db) + } + } + + private static func bindValues(_ values: [SQLiteValue], to stmt: OpaquePointer) throws { + for (index, value) in values.enumerated() { + switch value { + case .text(let text): + if let text { + sqlite3_bind_text(stmt, Int32(index + 1), text, -1, sqliteTransient) + } else { + sqlite3_bind_null(stmt, Int32(index + 1)) + } + } + } + } + + private static func text(_ stmt: OpaquePointer, index: Int32) -> String? { + guard let pointer = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: pointer) + } + + private static func isoNow() -> String { + ISO8601DateFormatter().string(from: .now) + } + + private static func jsonString(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + guard let text = String(data: data, encoding: .utf8) else { + throw SessionStoreError.writeNotSupported + } + return text + } + + private static func sqliteError(_ db: OpaquePointer?) -> NSError { + NSError( + domain: "sqlite", + code: Int(sqlite3_errcode(db)), + userInfo: [NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(db))] + ) + } +} diff --git a/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeSessionDiscovery.swift b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeSessionDiscovery.swift new file mode 100644 index 00000000..cb29c580 --- /dev/null +++ b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeSessionDiscovery.swift @@ -0,0 +1,43 @@ +import Foundation + +public final class MastracodeSessionDiscovery: SessionDiscovery, @unchecked Sendable { + private let databasePath: String + + public init(databasePath: String? = nil) { + self.databasePath = databasePath + ?? (NSHomeDirectory() as NSString).appendingPathComponent("Library/Application Support/mastracode/mastra.db") + } + + public func discoverSessions() async throws -> [Session] { + guard FileManager.default.fileExists(atPath: databasePath) else { return [] } + + let threads = try MastracodeDatabase.readThreads(databasePath: databasePath) + return threads.map { thread in + Session( + id: thread.id, + name: thread.title.isEmpty ? nil : thread.title, + firstPrompt: MastracodeDatabase.extractUserText(from: thread.firstUserContent), + projectPath: MastracodeDatabase.extractProjectPath(from: thread.metadata), + messageCount: thread.messageCount, + modifiedTime: parseDate(thread.updatedAt) ?? .distantPast, + jsonlPath: MastracodeSessionPath.encode(databasePath: databasePath, threadId: thread.id), + assistant: .mastracode + ) + } + } + + public func discoverNewOrModified(since: Date) async throws -> [Session] { + try await discoverSessions().filter { $0.modifiedTime >= since } + } + + private func parseDate(_ value: String) -> Date? { + makeFractionalISO8601Formatter().date(from: value) + ?? ISO8601DateFormatter().date(from: value) + } +} + +func makeFractionalISO8601Formatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +} diff --git a/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeSessionPath.swift b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeSessionPath.swift new file mode 100644 index 00000000..2d409926 --- /dev/null +++ b/Sources/KanbanCodeCore/Adapters/Mastracode/MastracodeSessionPath.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum MastracodeSessionPath { + public static func encode(databasePath: String, threadId: String) -> String { + "\(databasePath)#\(threadId)" + } + + public static func decode(_ sessionPath: String) -> (databasePath: String, threadId: String)? { + guard let split = sessionPath.lastIndex(of: "#") else { return nil } + let databasePath = String(sessionPath[.. [ConversationTurn] { + guard let resolved = resolve(sessionPath: sessionPath) else { + throw SessionStoreError.fileNotFound(sessionPath) + } + guard FileManager.default.fileExists(atPath: resolved.databasePath) else { + return [] + } + let messages = try MastracodeDatabase.readMessages( + databasePath: resolved.databasePath, + threadId: resolved.threadId + ) + return MastracodeDatabase.parseTurns(messages: messages) + } + + public func forkSession(sessionPath: String, targetDirectory: String? = nil) async throws -> String { + guard let resolved = resolve(sessionPath: sessionPath) else { + throw SessionStoreError.fileNotFound(sessionPath) + } + let turns = try await readTranscript(sessionPath: sessionPath) + let newThreadId = UUID().uuidString.lowercased() + let projectPath = targetDirectory ?? databaseProjectPath(for: resolved.databasePath, threadId: resolved.threadId) + try MastracodeDatabase.writeSession( + databasePath: resolved.databasePath, + threadId: newThreadId, + title: (turns.first?.textPreview ?? "").prefix(120).description, + projectPath: projectPath, + turns: turns + ) + return newThreadId + } + + public func truncateSession(sessionPath: String, afterTurn: ConversationTurn) async throws { + guard let resolved = resolve(sessionPath: sessionPath) else { + throw SessionStoreError.fileNotFound(sessionPath) + } + let turns = try await readTranscript(sessionPath: sessionPath) + let keptTurns = Array(turns.prefix(afterTurn.lineNumber)) + let title = keptTurns.first?.textPreview ?? "" + try MastracodeDatabase.writeSession( + databasePath: resolved.databasePath, + threadId: resolved.threadId, + title: String(title.prefix(120)), + projectPath: databaseProjectPath(for: resolved.databasePath, threadId: resolved.threadId), + turns: keptTurns + ) + } + + public func searchSessions(query: String, paths: [String]) async throws -> [SearchResult] { + let terms = BM25Scorer.tokenize(query) + guard !terms.isEmpty else { return [] } + + struct Doc { + let path: String + let tokens: [String] + let snippets: [String] + let modifiedTime: Date + } + + var docs: [Doc] = [] + var docFreqs: [String: Int] = [:] + + for path in paths { + let turns = try await readTranscript(sessionPath: path) + let combined = turns.map(\.textPreview).joined(separator: "\n") + let tokens = BM25Scorer.tokenize(combined) + guard !tokens.isEmpty else { continue } + let snippets = turns + .map(\.textPreview) + .filter { text in terms.contains { term in text.localizedCaseInsensitiveContains(term) } } + guard !snippets.isEmpty else { continue } + + let unique = Set(tokens) + for token in unique { + docFreqs[token, default: 0] += 1 + } + + let modifiedTime: Date + if let resolved = resolve(sessionPath: path), + let updatedAt = try MastracodeDatabase.readUpdatedAt(databasePath: resolved.databasePath, threadId: resolved.threadId), + let date = makeFractionalISO8601Formatter().date(from: updatedAt) ?? ISO8601DateFormatter().date(from: updatedAt) { + modifiedTime = date + } else { + modifiedTime = .distantPast + } + + docs.append(Doc(path: path, tokens: tokens, snippets: Array(snippets.prefix(3)), modifiedTime: modifiedTime)) + } + + let avgDocLength = docs.isEmpty ? 1.0 : Double(docs.map { $0.tokens.count }.reduce(0, +)) / Double(docs.count) + + return docs.compactMap { doc in + let score = BM25Scorer.score( + terms: terms, + documentTokens: doc.tokens, + avgDocLength: avgDocLength, + docCount: max(docs.count, 1), + docFreqs: docFreqs, + recencyBoost: BM25Scorer.recencyBoost(modifiedTime: doc.modifiedTime) + ) + guard score > 0 else { return nil } + return SearchResult(sessionPath: doc.path, score: score, snippets: doc.snippets) + } + .sorted { $0.score > $1.score } + } + + public func writeSession(turns: [ConversationTurn], sessionId: String, projectPath: String?) async throws -> String { + let dbPath = databasePath + ?? (NSHomeDirectory() as NSString).appendingPathComponent("Library/Application Support/mastracode/mastra.db") + try MastracodeDatabase.writeSession( + databasePath: dbPath, + threadId: sessionId, + title: String((turns.first?.textPreview ?? sessionId).prefix(120)), + projectPath: projectPath, + turns: turns + ) + return MastracodeSessionPath.encode(databasePath: dbPath, threadId: sessionId) + } + + public func backupAndDeleteSession(sessionPath: String) async throws -> String { + guard let resolved = resolve(sessionPath: sessionPath) else { + throw SessionStoreError.fileNotFound(sessionPath) + } + return try MastracodeDatabase.backupAndDeleteSession( + databasePath: resolved.databasePath, + threadId: resolved.threadId + ) + } + + private func resolve(sessionPath: String) -> (databasePath: String, threadId: String)? { + if let resolved = MastracodeSessionPath.decode(sessionPath) { + return resolved + } + if let databasePath, !sessionPath.contains("#") { + return (databasePath, sessionPath) + } + return nil + } + + private func databaseProjectPath(for databasePath: String, threadId: String) -> String? { + let thread = try? MastracodeDatabase.readThreads(databasePath: databasePath).first { $0.id == threadId } + return MastracodeDatabase.extractProjectPath(from: thread?.metadata) + } +} diff --git a/Sources/KanbanCodeCore/Domain/Entities/CodingAssistant.swift b/Sources/KanbanCodeCore/Domain/Entities/CodingAssistant.swift index 5d1d001b..c6c54d78 100644 --- a/Sources/KanbanCodeCore/Domain/Entities/CodingAssistant.swift +++ b/Sources/KanbanCodeCore/Domain/Entities/CodingAssistant.swift @@ -4,11 +4,13 @@ import Foundation public enum CodingAssistant: String, Codable, Sendable, CaseIterable { case claude case gemini + case mastracode public var displayName: String { switch self { case .claude: "Claude Code" case .gemini: "Gemini CLI" + case .mastracode: "Mastra Code" } } @@ -16,6 +18,7 @@ public enum CodingAssistant: String, Codable, Sendable, CaseIterable { switch self { case .claude: "claude" case .gemini: "gemini" + case .mastracode: "mastracode" } } @@ -24,6 +27,7 @@ public enum CodingAssistant: String, Codable, Sendable, CaseIterable { switch self { case .claude: "❯" case .gemini: "Type your message" + case .mastracode: "> " } } @@ -32,17 +36,24 @@ public enum CodingAssistant: String, Codable, Sendable, CaseIterable { switch self { case .claude: "--dangerously-skip-permissions" case .gemini: "--yolo" + case .mastracode: "/yolo" } } /// CLI flag to resume a session. - public var resumeFlag: String { "--resume" } + public var resumeFlag: String? { + switch self { + case .claude, .gemini: "--resume" + case .mastracode: nil + } + } /// Whether this assistant supports git worktree creation. public var supportsWorktree: Bool { switch self { case .claude: true case .gemini: false + case .mastracode: false } } @@ -51,14 +62,16 @@ public enum CodingAssistant: String, Codable, Sendable, CaseIterable { switch self { case .claude: true case .gemini: false + case .mastracode: false } } - /// Name of the config directory under $HOME (e.g. ".claude", ".gemini"). + /// Name of the config directory under $HOME (may include nested directories). public var configDirName: String { switch self { case .claude: ".claude" case .gemini: ".gemini" + case .mastracode: "Library/Application Support/mastracode" } } @@ -67,6 +80,7 @@ public enum CodingAssistant: String, Codable, Sendable, CaseIterable { switch self { case .claude: "❯" case .gemini: "✦" + case .mastracode: ">" } } @@ -75,6 +89,32 @@ public enum CodingAssistant: String, Codable, Sendable, CaseIterable { switch self { case .claude: "npm install -g @anthropic-ai/claude-code" case .gemini: "npm install -g @google/gemini-cli" + case .mastracode: "npm install -g mastracode" + } + } + + /// Whether prompt submission should use bracketed paste instead of per-key send. + public var usesPastedPrompt: Bool { + switch self { + case .claude: false + case .gemini, .mastracode: true + } + } + + /// Whether remote execution needs the wrapper directory prepended to PATH. + public var needsRemotePathShim: Bool { + switch self { + case .claude: false + case .gemini, .mastracode: true + } + } + + /// Session storage path suffix when file-backed. + public var sessionFileExtension: String? { + switch self { + case .claude: ".jsonl" + case .gemini: ".json" + case .mastracode: nil } } } diff --git a/Sources/KanbanCodeCore/Domain/Ports/SessionStore.swift b/Sources/KanbanCodeCore/Domain/Ports/SessionStore.swift index cf92d067..99da6245 100644 --- a/Sources/KanbanCodeCore/Domain/Ports/SessionStore.swift +++ b/Sources/KanbanCodeCore/Domain/Ports/SessionStore.swift @@ -24,6 +24,10 @@ public protocol SessionStore: Sendable { /// Write conversation turns to a new session file in this store's native format. /// Returns the path to the new session file. func writeSession(turns: [ConversationTurn], sessionId: String, projectPath: String?) async throws -> String + + /// Create a backup of the session and delete the original. + /// Returns the backup path. + func backupAndDeleteSession(sessionPath: String) async throws -> String } extension SessionStore { @@ -44,6 +48,22 @@ extension SessionStore { public func writeSession(turns: [ConversationTurn], sessionId: String, projectPath: String?) async throws -> String { throw SessionStoreError.writeNotSupported } + + /// Default: backup a file-based session and remove the original. + public func backupAndDeleteSession(sessionPath: String) async throws -> String { + let fm = FileManager.default + guard fm.fileExists(atPath: sessionPath) else { + throw SessionStoreError.fileNotFound(sessionPath) + } + + let backupPath = sessionPath + ".bak" + if fm.fileExists(atPath: backupPath) { + try? fm.removeItem(atPath: backupPath) + } + try fm.copyItem(atPath: sessionPath, toPath: backupPath) + try? fm.removeItem(atPath: sessionPath) + return backupPath + } } /// A search result from full-text session search. diff --git a/Sources/KanbanCodeCore/Infrastructure/DependencyChecker.swift b/Sources/KanbanCodeCore/Infrastructure/DependencyChecker.swift index 437d8a39..37edb612 100644 --- a/Sources/KanbanCodeCore/Infrastructure/DependencyChecker.swift +++ b/Sources/KanbanCodeCore/Infrastructure/DependencyChecker.swift @@ -6,6 +6,7 @@ public enum DependencyChecker { public struct Status: Sendable { public let claudeAvailable: Bool public let geminiAvailable: Bool + public let mastracodeAvailable: Bool public let hooksInstalled: Bool public let pandocAvailable: Bool public let wkhtmltoimageAvailable: Bool @@ -19,7 +20,7 @@ public enum DependencyChecker { public let assistantHooks: [CodingAssistant: Bool] public init( - claudeAvailable: Bool, geminiAvailable: Bool = false, + claudeAvailable: Bool, geminiAvailable: Bool = false, mastracodeAvailable: Bool = false, hooksInstalled: Bool, assistantHooks: [CodingAssistant: Bool] = [:], pandocAvailable: Bool, @@ -29,6 +30,7 @@ public enum DependencyChecker { ) { self.claudeAvailable = claudeAvailable self.geminiAvailable = geminiAvailable + self.mastracodeAvailable = mastracodeAvailable self.hooksInstalled = hooksInstalled self.pandocAvailable = pandocAvailable self.wkhtmltoimageAvailable = wkhtmltoimageAvailable @@ -41,12 +43,21 @@ public enum DependencyChecker { ? [.claude: hooksInstalled] : assistantHooks } + + public func isAvailable(_ assistant: CodingAssistant) -> Bool { + switch assistant { + case .claude: claudeAvailable + case .gemini: geminiAvailable + case .mastracode: mastracodeAvailable + } + } } /// Check all dependencies concurrently. public static func checkAll(settingsStore: SettingsStore) async -> Status { async let claude = ShellCommand.isAvailable("claude") async let gemini = ShellCommand.isAvailable("gemini") + async let mastracode = ShellCommand.isAvailable("mastracode") async let pandoc = ShellCommand.isAvailable("pandoc") async let wkhtmltoimage = ShellCommand.isAvailable("wkhtmltoimage") async let gh = ShellCommand.isAvailable("gh") @@ -72,6 +83,7 @@ public enum DependencyChecker { return await Status( claudeAvailable: claude, geminiAvailable: gemini, + mastracodeAvailable: mastracode, hooksInstalled: hooks[.claude] ?? false, assistantHooks: hooks, pandocAvailable: pandoc, diff --git a/Sources/KanbanCodeCore/UseCases/EffectHandler.swift b/Sources/KanbanCodeCore/UseCases/EffectHandler.swift index df785d8e..fec6fe74 100644 --- a/Sources/KanbanCodeCore/UseCases/EffectHandler.swift +++ b/Sources/KanbanCodeCore/UseCases/EffectHandler.swift @@ -91,7 +91,7 @@ public actor EffectHandler { } case .sendPromptToTmux(let sessionName, let promptBody, let assistant): do { - if assistant == .gemini { + if assistant.usesPastedPrompt { try await tmuxAdapter?.pastePrompt(to: sessionName, text: promptBody) } else { try await tmuxAdapter?.sendPrompt(to: sessionName, text: promptBody) @@ -114,7 +114,7 @@ public actor EffectHandler { setClipboard: setClipboard ) } - if assistant == .gemini { + if assistant.usesPastedPrompt { try await tmux.pastePrompt(to: sessionName, text: promptBody) } else { try await tmux.sendPrompt(to: sessionName, text: promptBody) diff --git a/Sources/KanbanCodeCore/UseCases/ImageSender.swift b/Sources/KanbanCodeCore/UseCases/ImageSender.swift index 82ed7b3f..32870fc3 100644 --- a/Sources/KanbanCodeCore/UseCases/ImageSender.swift +++ b/Sources/KanbanCodeCore/UseCases/ImageSender.swift @@ -35,6 +35,11 @@ public actor ImageSender { pollInterval: Duration = .milliseconds(500), timeout: Duration? = nil ) async throws { + if assistant == .mastracode { + try await Task.sleep(for: .seconds(2)) + return + } + // Gemini takes longer to start (ASCII art banner, auth, plan info) let effectiveTimeout = timeout ?? (assistant == .gemini ? .seconds(60) : .seconds(30)) let start = ContinuousClock.now diff --git a/Sources/KanbanCodeCore/UseCases/LaunchSession.swift b/Sources/KanbanCodeCore/UseCases/LaunchSession.swift index dc457600..4e17cf54 100644 --- a/Sources/KanbanCodeCore/UseCases/LaunchSession.swift +++ b/Sources/KanbanCodeCore/UseCases/LaunchSession.swift @@ -85,7 +85,9 @@ public final class LaunchSession: SessionLauncher, @unchecked Sendable { } else { var built = assistant.cliCommand if skipPermissions { built += " \(assistant.autoApproveFlag)" } - built += " \(assistant.resumeFlag) \(sessionId)" + if let resumeFlag = assistant.resumeFlag { + built += " \(resumeFlag) \(sessionId)" + } let envPrefix = buildEnvPrefix(shellOverride: shellOverride, extraEnv: extraEnv) if !envPrefix.isEmpty { built = envPrefix + " " + built diff --git a/Sources/KanbanCodeCore/UseCases/SessionMigrator.swift b/Sources/KanbanCodeCore/UseCases/SessionMigrator.swift index 242f5142..36b2e03a 100644 --- a/Sources/KanbanCodeCore/UseCases/SessionMigrator.swift +++ b/Sources/KanbanCodeCore/UseCases/SessionMigrator.swift @@ -39,15 +39,9 @@ public enum SessionMigrator { projectPath: projectPath ) - // 4. Backup source file, then remove original so the reconciler + // 4. Backup source session, then remove the original so the reconciler // doesn't rediscover it and create a duplicate card. - let backupPath = sourceSessionPath + ".bak" - let fm = FileManager.default - if fm.fileExists(atPath: backupPath) { - try? fm.removeItem(atPath: backupPath) - } - try fm.copyItem(atPath: sourceSessionPath, toPath: backupPath) - try? fm.removeItem(atPath: sourceSessionPath) + let backupPath = try await sourceStore.backupAndDeleteSession(sessionPath: sourceSessionPath) return MigrationResult( newSessionId: newSessionId, diff --git a/Tests/KanbanCodeCoreTests/Mastracode/MastracodeActivityDetectorTests.swift b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeActivityDetectorTests.swift new file mode 100644 index 00000000..5af1bd37 --- /dev/null +++ b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeActivityDetectorTests.swift @@ -0,0 +1,66 @@ +import Testing +import Foundation +@testable import KanbanCodeCore + +@Suite("MastracodeActivityDetector") +struct MastracodeActivityDetectorTests { + + @Test("Recent thread update is actively working") + func activeByUpdatedAt() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "thread-1", + updatedAt: ISO8601DateFormatter().string(from: .now.addingTimeInterval(-20)) + ) + + let detector = MastracodeActivityDetector() + let result = await detector.pollActivity(sessionPaths: [ + "thread-1": MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-1") + ]) + + #expect(result["thread-1"] == .activelyWorking) + } + + @Test("Older thread update becomes ended") + func endedByUpdatedAt() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "thread-1", + updatedAt: ISO8601DateFormatter().string(from: .now.addingTimeInterval(-7200)) + ) + + let detector = MastracodeActivityDetector() + let result = await detector.pollActivity(sessionPaths: [ + "thread-1": MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-1") + ]) + + #expect(result["thread-1"] == .ended) + } + + @Test("Hook state overrides DB polling") + func hookPriority() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "thread-1", + updatedAt: ISO8601DateFormatter().string(from: .now.addingTimeInterval(-7200)) + ) + + let detector = MastracodeActivityDetector() + await detector.handleHookEvent(HookEvent(sessionId: "thread-1", eventName: "UserPromptSubmit")) + let result = await detector.pollActivity(sessionPaths: [ + "thread-1": MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-1") + ]) + + #expect(result["thread-1"] == .activelyWorking) + } +} + diff --git a/Tests/KanbanCodeCoreTests/Mastracode/MastracodeSessionDiscoveryTests.swift b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeSessionDiscoveryTests.swift new file mode 100644 index 00000000..f4dcae91 --- /dev/null +++ b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeSessionDiscoveryTests.swift @@ -0,0 +1,67 @@ +import Testing +import Foundation +@testable import KanbanCodeCore + +@Suite("MastracodeSessionDiscovery") +struct MastracodeSessionDiscoveryTests { + + @Test("Discovers sessions from Mastra DB") + func discoversSessions() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "thread-1", + metadata: ["projectPath": "/repo/app"] + ) + try MastracodeTestHelpers.insertMessage( + dbPath: dbPath, + threadId: "thread-1", + role: "user", + content: MastracodeTestHelpers.userContent("Fix the login bug"), + createdAt: "2026-03-09T10:00:01Z" + ) + + let discovery = MastracodeSessionDiscovery(databasePath: dbPath) + let sessions = try await discovery.discoverSessions() + + #expect(sessions.count == 1) + #expect(sessions[0].assistant == .mastracode) + #expect(sessions[0].projectPath == "/repo/app") + #expect(sessions[0].firstPrompt == "Fix the login bug") + #expect(sessions[0].jsonlPath == MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-1")) + } + + @Test("Sorts sessions by updated time descending") + func sortsByUpdatedAt() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "older", + metadata: ["projectPath": "/repo/a"], + updatedAt: "2026-03-09T10:00:00Z" + ) + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "newer", + metadata: ["projectPath": "/repo/b"], + updatedAt: "2026-03-09T10:10:00Z" + ) + + let discovery = MastracodeSessionDiscovery(databasePath: dbPath) + let sessions = try await discovery.discoverSessions() + + #expect(sessions.map(\.id) == ["newer", "older"]) + } + + @Test("Returns empty when database is missing") + func missingDatabase() async throws { + let discovery = MastracodeSessionDiscovery(databasePath: "/tmp/does-not-exist.sqlite") + let sessions = try await discovery.discoverSessions() + #expect(sessions.isEmpty) + } +} + diff --git a/Tests/KanbanCodeCoreTests/Mastracode/MastracodeSessionStoreTests.swift b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeSessionStoreTests.swift new file mode 100644 index 00000000..dbf33a03 --- /dev/null +++ b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeSessionStoreTests.swift @@ -0,0 +1,112 @@ +import Testing +import Foundation +@testable import KanbanCodeCore + +@Suite("MastracodeSessionStore") +struct MastracodeSessionStoreTests { + + @Test("Reads transcript from Mastra DB") + func readTranscript() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "thread-1", + metadata: ["projectPath": "/repo/app"] + ) + try MastracodeTestHelpers.insertMessage( + dbPath: dbPath, + threadId: "thread-1", + role: "user", + content: MastracodeTestHelpers.userContent("Fix login"), + createdAt: "2026-03-09T10:00:01Z" + ) + try MastracodeTestHelpers.insertMessage( + dbPath: dbPath, + threadId: "thread-1", + role: "assistant", + content: MastracodeTestHelpers.assistantContent( + reasoning: "I should inspect the auth flow", + toolName: "view", + toolArgs: ["path": "src/auth.ts"], + toolResult: "file contents", + text: "I found the issue." + ), + createdAt: "2026-03-09T10:00:02Z" + ) + try MastracodeTestHelpers.insertMessage( + dbPath: dbPath, + threadId: "thread-1", + role: "system", + type: "notification", + content: MastracodeTestHelpers.systemContent("Checkpoint saved"), + createdAt: "2026-03-09T10:00:03Z" + ) + + let store = MastracodeSessionStore(databasePath: dbPath) + let turns = try await store.readTranscript( + sessionPath: MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-1") + ) + + #expect(turns.count == 3) + #expect(turns[0].role == "user") + #expect(turns[0].textPreview == "Fix login") + #expect(turns[1].role == "assistant") + #expect(turns[1].contentBlocks.contains { if case .thinking = $0.kind { return true } else { return false } }) + #expect(turns[1].contentBlocks.contains { if case .toolUse(let name, let input) = $0.kind { return name == "view" && input["path"] == "src/auth.ts" } else { return false } }) + #expect(turns[1].contentBlocks.contains { if case .toolResult(let toolName) = $0.kind { return toolName == "view" && $0.text == "file contents" } else { return false } }) + #expect(turns[2].role == "system") + } + + @Test("Writes migrated session into Mastra DB") + func writeSession() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + let store = MastracodeSessionStore(databasePath: dbPath) + let path = try await store.writeSession( + turns: [ + ConversationTurn(index: 0, lineNumber: 1, role: "user", textPreview: "Plan it", contentBlocks: [ContentBlock(kind: .text, text: "Plan it")]), + ConversationTurn(index: 1, lineNumber: 2, role: "assistant", textPreview: "Doing it", contentBlocks: [ContentBlock(kind: .text, text: "Doing it")]), + ], + sessionId: "thread-migrated", + projectPath: "/repo/migrate" + ) + + #expect(path == MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-migrated")) + + let transcript = try await store.readTranscript(sessionPath: path) + #expect(transcript.count == 2) + #expect(transcript[0].textPreview == "Plan it") + #expect(transcript[1].textPreview == "Doing it") + } + + @Test("Backup and delete exports session and removes thread") + func backupAndDelete() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "thread-delete", + metadata: ["projectPath": "/repo/app"] + ) + try MastracodeTestHelpers.insertMessage( + dbPath: dbPath, + threadId: "thread-delete", + role: "user", + content: MastracodeTestHelpers.userContent("Delete me"), + createdAt: "2026-03-09T10:00:01Z" + ) + + let store = MastracodeSessionStore(databasePath: dbPath) + let sessionPath = MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-delete") + let backupPath = try await store.backupAndDeleteSession(sessionPath: sessionPath) + + #expect(FileManager.default.fileExists(atPath: backupPath)) + let remaining = try await store.readTranscript(sessionPath: sessionPath) + #expect(remaining.isEmpty) + } +} + diff --git a/Tests/KanbanCodeCoreTests/Mastracode/MastracodeTestHelpers.swift b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeTestHelpers.swift new file mode 100644 index 00000000..ec238d7c --- /dev/null +++ b/Tests/KanbanCodeCoreTests/Mastracode/MastracodeTestHelpers.swift @@ -0,0 +1,209 @@ +import Foundation +import SQLite3 + +enum MastracodeTestHelpers { + private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + static func makeTempDatabase() throws -> String { + let dir = "/tmp/kanban-test-mastracode-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let dbPath = (dir as NSString).appendingPathComponent("mastra.db") + try createSchema(at: dbPath) + return dbPath + } + + static func cleanupDatabase(at dbPath: String) { + let dir = (dbPath as NSString).deletingLastPathComponent + try? FileManager.default.removeItem(atPath: dir) + } + + static func insertThread( + dbPath: String, + id: String, + resourceId: String = "proj-1", + title: String = "", + metadata: [String: Any]? = nil, + createdAt: String = "2026-03-09T10:00:00Z", + updatedAt: String = "2026-03-09T10:05:00Z" + ) throws { + let metadataText = try metadata.map(jsonString) ?? "" + try execute( + dbPath: dbPath, + sql: """ + INSERT INTO mastra_threads (id, resourceId, title, metadata, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + """, + bind: [ + .text(id), + .text(resourceId), + .text(title), + .text(metadataText), + .text(createdAt), + .text(updatedAt), + ] + ) + } + + static func insertMessage( + dbPath: String, + id: String = UUID().uuidString, + threadId: String, + role: String, + type: String = "message", + content: [String: Any], + createdAt: String + ) throws { + try execute( + dbPath: dbPath, + sql: """ + INSERT INTO mastra_messages (id, thread_id, content, role, type, createdAt, resourceId) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + bind: [ + .text(id), + .text(threadId), + .text(try jsonString(content)), + .text(role), + .text(type), + .text(createdAt), + .text(nil), + ] + ) + } + + static func userContent(_ text: String) -> [String: Any] { + [ + "format": 2, + "parts": [["type": "text", "text": text]], + "content": text, + ] + } + + static func assistantContent( + reasoning: String? = nil, + toolName: String? = nil, + toolArgs: [String: String]? = nil, + toolResult: String? = nil, + text: String? = nil + ) -> [String: Any] { + var parts: [[String: Any]] = [] + if let reasoning { + parts.append([ + "type": "reasoning", + "reasoning": reasoning, + "details": [["type": "text", "text": reasoning]], + ]) + } + if let toolName { + var toolInvocation: [String: Any] = [ + "state": toolResult == nil ? "pending" : "result", + "toolName": toolName, + "args": toolArgs ?? [:], + ] + if let toolResult { + toolInvocation["result"] = ["content": toolResult] + } + parts.append([ + "type": "tool-invocation", + "toolInvocation": toolInvocation, + ]) + } + if let text { + parts.append(["type": "text", "text": text]) + } + return [ + "format": 2, + "parts": parts, + "content": text ?? "", + ] + } + + static func systemContent(_ text: String) -> [String: Any] { + [ + "format": 2, + "parts": [["type": "text", "text": text]], + "content": text, + ] + } + + private enum SQLiteValue { + case text(String?) + } + + private static func createSchema(at dbPath: String) throws { + try execute( + dbPath: dbPath, + sql: """ + CREATE TABLE mastra_threads ( + id TEXT NOT NULL PRIMARY KEY, + resourceId TEXT NOT NULL, + title TEXT NOT NULL, + metadata TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + """, + bind: [] + ) + try execute( + dbPath: dbPath, + sql: """ + CREATE TABLE mastra_messages ( + id TEXT NOT NULL PRIMARY KEY, + thread_id TEXT NOT NULL, + content TEXT NOT NULL, + role TEXT NOT NULL, + type TEXT NOT NULL, + createdAt TEXT NOT NULL, + resourceId TEXT + ); + """, + bind: [] + ) + } + + private static func execute(dbPath: String, sql: String, bind: [SQLiteValue]) throws { + var db: OpaquePointer? + guard sqlite3_open(dbPath, &db) == SQLITE_OK, let db else { + throw NSError(domain: "sqlite", code: 1) + } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + throw sqliteError(db) + } + defer { sqlite3_finalize(stmt) } + + for (index, value) in bind.enumerated() { + switch value { + case .text(let text): + if let text { + sqlite3_bind_text(stmt, Int32(index + 1), text, -1, sqliteTransient) + } else { + sqlite3_bind_null(stmt, Int32(index + 1)) + } + } + } + + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw sqliteError(db) + } + } + + private static func jsonString(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + guard let string = String(data: data, encoding: .utf8) else { + throw NSError(domain: "json", code: 1) + } + return string + } + + private static func sqliteError(_ db: OpaquePointer?) -> NSError { + NSError( + domain: "sqlite", + code: Int(sqlite3_errcode(db)), + userInfo: [NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(db))] + ) + } +} diff --git a/Tests/KanbanCodeCoreTests/Mastracode/SessionMigratorMastracodeTests.swift b/Tests/KanbanCodeCoreTests/Mastracode/SessionMigratorMastracodeTests.swift new file mode 100644 index 00000000..ebbb4065 --- /dev/null +++ b/Tests/KanbanCodeCoreTests/Mastracode/SessionMigratorMastracodeTests.swift @@ -0,0 +1,70 @@ +import Testing +import Foundation +@testable import KanbanCodeCore + +private final class RecordingTargetStore: SessionStore, @unchecked Sendable { + var writtenTurns: [ConversationTurn] = [] + var writtenProjectPath: String? + + func readTranscript(sessionPath: String) async throws -> [ConversationTurn] { [] } + func forkSession(sessionPath: String, targetDirectory: String?) async throws -> String { "unused" } + func truncateSession(sessionPath: String, afterTurn: ConversationTurn) async throws {} + func searchSessions(query: String, paths: [String]) async throws -> [SearchResult] { [] } + func writeSession(turns: [ConversationTurn], sessionId: String, projectPath: String?) async throws -> String { + writtenTurns = turns + writtenProjectPath = projectPath + return "/tmp/migrated-\(sessionId).json" + } + func backupAndDeleteSession(sessionPath: String) async throws -> String { sessionPath + ".bak" } +} + +@Suite("SessionMigrator Mastracode") +struct SessionMigratorMastracodeTests { + @Test("Migrates Mastra session through normalized turns and removes source thread") + func migrateFromMastra() async throws { + let dbPath = try MastracodeTestHelpers.makeTempDatabase() + defer { MastracodeTestHelpers.cleanupDatabase(at: dbPath) } + + try MastracodeTestHelpers.insertThread( + dbPath: dbPath, + id: "thread-migrate", + metadata: ["projectPath": "/repo/app"] + ) + try MastracodeTestHelpers.insertMessage( + dbPath: dbPath, + threadId: "thread-migrate", + role: "user", + content: MastracodeTestHelpers.userContent("Fix auth"), + createdAt: "2026-03-09T10:00:01Z" + ) + try MastracodeTestHelpers.insertMessage( + dbPath: dbPath, + threadId: "thread-migrate", + role: "assistant", + content: MastracodeTestHelpers.assistantContent(text: "Working on it"), + createdAt: "2026-03-09T10:00:02Z" + ) + + let sourceStore = MastracodeSessionStore(databasePath: dbPath) + let targetStore = RecordingTargetStore() + let sourcePath = MastracodeSessionPath.encode(databasePath: dbPath, threadId: "thread-migrate") + + let result = try await SessionMigrator.migrate( + sourceSessionPath: sourcePath, + sourceStore: sourceStore, + targetStore: targetStore, + projectPath: "/repo/app" + ) + + #expect(targetStore.writtenTurns.count == 2) + #expect(targetStore.writtenTurns[0].textPreview == "Fix auth") + #expect(targetStore.writtenTurns[1].textPreview == "Working on it") + #expect(targetStore.writtenProjectPath == "/repo/app") + #expect(FileManager.default.fileExists(atPath: result.backupPath)) + + let remaining = try await sourceStore.readTranscript(sessionPath: sourcePath) + #expect(remaining.isEmpty) + #expect(result.newSessionPath.contains("/tmp/migrated-")) + } +} + diff --git a/Tests/KanbanCodeCoreTests/MultiAssistant/CodingAssistantTests.swift b/Tests/KanbanCodeCoreTests/MultiAssistant/CodingAssistantTests.swift index 3953a590..ed192555 100644 --- a/Tests/KanbanCodeCoreTests/MultiAssistant/CodingAssistantTests.swift +++ b/Tests/KanbanCodeCoreTests/MultiAssistant/CodingAssistantTests.swift @@ -17,6 +17,11 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.displayName == "Gemini CLI") } + @Test("Mastra display name") + func mastraDisplayName() { + #expect(CodingAssistant.mastracode.displayName == "Mastra Code") + } + // MARK: - CLI Commands @Test("Claude CLI command") @@ -29,6 +34,11 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.cliCommand == "gemini") } + @Test("Mastra CLI command") + func mastraCliCommand() { + #expect(CodingAssistant.mastracode.cliCommand == "mastracode") + } + // MARK: - Prompt Characters @Test("Claude prompt character is ❯") @@ -41,6 +51,11 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.promptCharacter == "Type your message") } + @Test("Mastra prompt character") + func mastraPromptCharacter() { + #expect(CodingAssistant.mastracode.promptCharacter == "> ") + } + // MARK: - Auto-Approve Flags @Test("Claude auto-approve flag") @@ -53,12 +68,18 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.autoApproveFlag == "--yolo") } + @Test("Mastra auto-approve command") + func mastraAutoApproveFlag() { + #expect(CodingAssistant.mastracode.autoApproveFlag == "/yolo") + } + // MARK: - Resume Flag - @Test("Both assistants use --resume") + @Test("Resume flags match assistant behavior") func resumeFlag() { #expect(CodingAssistant.claude.resumeFlag == "--resume") #expect(CodingAssistant.gemini.resumeFlag == "--resume") + #expect(CodingAssistant.mastracode.resumeFlag == nil) } // MARK: - Capabilities @@ -73,6 +94,11 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.supportsWorktree == false) } + @Test("Mastra does not support worktrees") + func mastraNoWorktree() { + #expect(CodingAssistant.mastracode.supportsWorktree == false) + } + @Test("Claude supports image upload") func claudeSupportsImageUpload() { #expect(CodingAssistant.claude.supportsImageUpload == true) @@ -83,6 +109,11 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.supportsImageUpload == false) } + @Test("Mastra does not support image upload") + func mastraNoImageUpload() { + #expect(CodingAssistant.mastracode.supportsImageUpload == false) + } + // MARK: - Config Directory @Test("Claude config dir") @@ -95,6 +126,11 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.configDirName == ".gemini") } + @Test("Mastra config dir") + func mastraConfigDir() { + #expect(CodingAssistant.mastracode.configDirName == "Library/Application Support/mastracode") + } + // MARK: - Install Command @Test("Claude install command") @@ -107,6 +143,11 @@ struct CodingAssistantTests { #expect(CodingAssistant.gemini.installCommand.contains("gemini-cli")) } + @Test("Mastra install command") + func mastraInstallCommand() { + #expect(CodingAssistant.mastracode.installCommand.contains("mastracode")) + } + // MARK: - Codable @Test("CodingAssistant Codable round-trip") @@ -120,9 +161,9 @@ struct CodingAssistantTests { @Test("CodingAssistant raw value encoding") func rawValueEncoding() throws { - let data = try JSONEncoder().encode(CodingAssistant.gemini) + let data = try JSONEncoder().encode(CodingAssistant.mastracode) let json = String(data: data, encoding: .utf8)! - #expect(json == "\"gemini\"") + #expect(json == "\"mastracode\"") } @Test("CodingAssistant decodes from raw string") @@ -139,6 +180,7 @@ struct CodingAssistantTests { let all = CodingAssistant.allCases #expect(all.contains(.claude)) #expect(all.contains(.gemini)) - #expect(all.count == 2) + #expect(all.contains(.mastracode)) + #expect(all.count == 3) } } diff --git a/Tests/KanbanCodeCoreTests/MultiAssistant/LaunchSessionMultiAssistantTests.swift b/Tests/KanbanCodeCoreTests/MultiAssistant/LaunchSessionMultiAssistantTests.swift index b2ffb375..f373c05a 100644 --- a/Tests/KanbanCodeCoreTests/MultiAssistant/LaunchSessionMultiAssistantTests.swift +++ b/Tests/KanbanCodeCoreTests/MultiAssistant/LaunchSessionMultiAssistantTests.swift @@ -77,6 +77,28 @@ struct LaunchSessionMultiAssistantTests { #expect(!cmd.contains("--dangerously-skip-permissions")) } + @Test("Launch with Mastra uses 'mastracode' command") + func launchMastra() async throws { + let mock = RecordingTmux() + let launcher = LaunchSession(tmux: mock) + + _ = try await launcher.launch( + sessionName: "test", + projectPath: "/tmp/project", + prompt: "fix bug", + worktreeName: nil, + shellOverride: nil, + skipPermissions: true, + assistant: .mastracode + ) + + let cmd = mock.lastCommand ?? "" + #expect(cmd.contains("mastracode")) + #expect(cmd.contains("/yolo")) + #expect(!cmd.contains("--resume")) + #expect(!cmd.contains("--worktree")) + } + @Test("Launch with Gemini skips worktree even when provided") func launchGeminiNoWorktree() async throws { let mock = RecordingTmux() @@ -176,6 +198,27 @@ struct LaunchSessionMultiAssistantTests { #expect(cmd.contains("--resume")) } + @Test("Resume with Mastra stays project-scoped") + func resumeMastra() async throws { + let mock = RecordingTmux() + let launcher = LaunchSession(tmux: mock) + + let sessionName = try await launcher.resume( + sessionId: "thread_abc123-rest", + projectPath: "/tmp/project", + shellOverride: nil, + skipPermissions: true, + assistant: .mastracode + ) + + #expect(sessionName == "mastracode-thread_a") + let cmd = mock.lastCommand ?? "" + #expect(cmd.contains("mastracode")) + #expect(cmd.contains("/yolo")) + #expect(!cmd.contains("--resume")) + #expect(!cmd.contains("thread_abc123-rest")) + } + // MARK: - Shell override @Test("Launch with shell override prepends SHELL=") diff --git a/Tests/KanbanCodeCoreTests/MultiAssistant/RegistryTests.swift b/Tests/KanbanCodeCoreTests/MultiAssistant/RegistryTests.swift index 73db917c..fb872462 100644 --- a/Tests/KanbanCodeCoreTests/MultiAssistant/RegistryTests.swift +++ b/Tests/KanbanCodeCoreTests/MultiAssistant/RegistryTests.swift @@ -39,6 +39,7 @@ private final class MockStore: SessionStore, @unchecked Sendable { func truncateSession(sessionPath: String, afterTurn: ConversationTurn) async throws {} func searchSessions(query: String, paths: [String]) async throws -> [SearchResult] { [] } func searchSessionsStreaming(query: String, paths: [String], onResult: @MainActor @Sendable ([SearchResult]) -> Void) async throws {} + func backupAndDeleteSession(sessionPath: String) async throws -> String { sessionPath + ".bak" } } // MARK: - Registry Tests @@ -67,15 +68,17 @@ struct RegistryTests { #expect(registry.available == [.claude]) } - @Test("Register both assistants") - func registerBoth() { + @Test("Register all assistants") + func registerAll() { let registry = CodingAssistantRegistry() registry.register(.claude, discovery: MockDiscovery(), detector: MockDetector(), store: MockStore()) registry.register(.gemini, discovery: MockDiscovery(), detector: MockDetector(), store: MockStore()) + registry.register(.mastracode, discovery: MockDiscovery(), detector: MockDetector(), store: MockStore()) - #expect(registry.available.count == 2) + #expect(registry.available.count == 3) #expect(registry.available.contains(.claude)) #expect(registry.available.contains(.gemini)) + #expect(registry.available.contains(.mastracode)) } @Test("Unregistered assistant returns nil") @@ -84,6 +87,7 @@ struct RegistryTests { #expect(registry.discovery(for: .gemini) == nil) #expect(registry.detector(for: .gemini) == nil) #expect(registry.store(for: .gemini) == nil) + #expect(registry.discovery(for: .mastracode) == nil) } @Test("Available is sorted by rawValue") @@ -93,9 +97,11 @@ struct RegistryTests { registry.register(.gemini, discovery: MockDiscovery(), detector: MockDetector(), store: MockStore()) registry.register(.claude, discovery: MockDiscovery(), detector: MockDetector(), store: MockStore()) - // Should be sorted: claude < gemini alphabetically + registry.register(.mastracode, discovery: MockDiscovery(), detector: MockDetector(), store: MockStore()) + + // Should be sorted alphabetically by rawValue #expect(registry.available.first == .claude) - #expect(registry.available.last == .gemini) + #expect(registry.available.last == .mastracode) } } diff --git a/specs/sessions/assistant-registry.feature b/specs/sessions/assistant-registry.feature index 3a1fe1f8..f36ebf78 100644 --- a/specs/sessions/assistant-registry.feature +++ b/specs/sessions/assistant-registry.feature @@ -8,42 +8,58 @@ Feature: Coding Assistant Registry # ── Registration ── + @unit Scenario: Register Claude adapters When Claude adapters are registered Then registry.discovery(for: .claude) should return ClaudeCodeSessionDiscovery And registry.detector(for: .claude) should return ClaudeCodeActivityDetector And registry.store(for: .claude) should return ClaudeCodeSessionStore + @unit Scenario: Register Gemini adapters When Gemini adapters are registered Then registry.discovery(for: .gemini) should return GeminiSessionDiscovery And registry.detector(for: .gemini) should return GeminiActivityDetector And registry.store(for: .gemini) should return GeminiSessionStore + @unit + Scenario: Register Mastracode adapters + When Mastracode adapters are registered + Then registry.discovery(for: .mastracode) should return MastracodeSessionDiscovery + And registry.detector(for: .mastracode) should return MastracodeActivityDetector + And registry.store(for: .mastracode) should return MastracodeSessionStore + + @unit Scenario: Only installed assistants are registered - Given Gemini CLI is not installed + Given Gemini CLI and Mastra Code are not installed Then registry.available should only contain [.claude] And registry.discovery(for: .gemini) should return nil + And registry.discovery(for: .mastracode) should return nil - Scenario: Both assistants registered - Given both Claude and Gemini are installed - Then registry.available should contain [.claude, .gemini] + @unit + Scenario: All installed assistants are registered + Given Claude Code, Gemini CLI, and Mastra Code are installed + Then registry.available should contain [.claude, .gemini, .mastracode] # ── Composite Discovery ── - Scenario: Composite discovery merges all sources + @integration + Scenario: Composite discovery merges all registered sources Given Claude discovery finds sessions [A, B, C] And Gemini discovery finds sessions [D, E] + And Mastracode discovery finds sessions [F] When composite discovery runs - Then it should return [A, B, C, D, E] sorted by modification time + Then it should return [A, B, C, D, E, F] sorted by modification time + @integration Scenario: Composite discovery handles one source failing Given Claude discovery succeeds with sessions [A, B] - And Gemini discovery throws an error + And Mastracode discovery throws an error When composite discovery runs Then it should still return [A, B] from Claude - And log the Gemini error + And log the Mastracode error + @unit Scenario: Composite discovery with no registered assistants Given no assistants are registered When composite discovery runs @@ -51,16 +67,19 @@ Feature: Coding Assistant Registry # ── Composite Activity Detector ── + @unit Scenario: Route hook event to Claude detector Given a HookEvent with sessionId matching a "claude" session When the composite detector handles it Then ClaudeCodeActivityDetector.handleHookEvent should be called + @integration Scenario: Route poll to correct detectors - Given session paths include both Claude and Gemini sessions - When composite detector polls activity + Given session paths include Claude, Gemini, and Mastracode sessions + When the composite detector polls activity Then each session's path should be polled by its assistant's detector + @unit Scenario: Unknown assistant session is skipped Given a session path that matches no registered assistant When the composite detector polls @@ -68,13 +87,15 @@ Feature: Coding Assistant Registry # ── Store Resolution ── + @unit Scenario: Read transcript uses correct store - Given a card with assistant "gemini" and a session path + Given a card with assistant "mastracode" and a session path When readTranscript is called - Then GeminiSessionStore.readTranscript should handle it + Then MastracodeSessionStore.readTranscript should handle it + @integration Scenario: Search sessions uses all stores - Given both Claude and Gemini stores are registered + Given Claude, Gemini, and Mastracode stores are registered When searchSessions is called - Then both stores should be searched + Then all registered stores should be searched And results should be merged and sorted by score diff --git a/specs/sessions/launch-multi-assistant.feature b/specs/sessions/launch-multi-assistant.feature index ba23d7f7..e3005255 100644 --- a/specs/sessions/launch-multi-assistant.feature +++ b/specs/sessions/launch-multi-assistant.feature @@ -1,14 +1,15 @@ Feature: Launch Sessions with Multiple Assistants - As a developer choosing between Claude Code and Gemini CLI + As a developer choosing between coding assistants I want to launch tasks with my preferred assistant So that each task uses the right tool Background: Given the Kanban Code application is running - And both Claude Code and Gemini CLI are installed + And Claude Code, Gemini CLI, and Mastra Code are installed # ── Launch Command Construction ── + @unit Scenario: Launch Claude Code session Given I create a task with assistant "claude" And project path "/Users/rchaves/Projects/remote/kanban" @@ -16,6 +17,7 @@ Feature: Launch Sessions with Multiple Assistants When the launch command is built Then the command should be "claude --dangerously-skip-permissions" + @unit Scenario: Launch Gemini CLI session Given I create a task with assistant "gemini" And project path "/Users/rchaves/Projects/remote/kanban" @@ -23,19 +25,36 @@ Feature: Launch Sessions with Multiple Assistants When the launch command is built Then the command should be "gemini --yolo" + @unit + Scenario: Launch Mastracode session + Given I create a task with assistant "mastracode" + And project path "/Users/rchaves/Projects/remote/kanban" + And skipPermissions is true + When the launch command is built + Then the command should be "mastracode /yolo" + + @unit Scenario: Launch Claude with worktree Given I create a task with assistant "claude" And worktreeName is "feature-x" When the launch command is built Then the command should include "--worktree feature-x" + @unit Scenario: Launch Gemini ignores worktree (not supported) Given I create a task with assistant "gemini" And worktreeName is "feature-x" When the launch command is built Then the command should NOT include "--worktree" - # Gemini CLI does not support worktrees + @unit + Scenario: Launch Mastracode ignores worktree (not supported) + Given I create a task with assistant "mastracode" + And worktreeName is "feature-x" + When the launch command is built + Then the command should NOT include "--worktree" + + @unit Scenario: Custom command override bypasses assistant logic Given a commandOverride of "aider --model gpt-4" When the launch command is built @@ -43,74 +62,112 @@ Feature: Launch Sessions with Multiple Assistants # ── Tmux Session Naming ── + @unit Scenario: Claude tmux session name for new launch Given assistant is "claude" and cardId is "card_abc123" And projectPath is "/Users/rchaves/Projects/remote/kanban" When a new session is launched Then the tmux session name should be "kanban-card_abc123" + @unit Scenario: Claude tmux session name for resume Given assistant is "claude" and sessionId is "abcdef12-3456-7890" When the session is resumed Then the tmux session name should be "claude-abcdef12" + @unit Scenario: Gemini tmux session name for resume Given assistant is "gemini" and sessionId is "1250be89-48ad-4418" When the session is resumed Then the tmux session name should be "gemini-1250be89" + @unit + Scenario: Mastracode tmux session name for resume + Given assistant is "mastracode" and sessionId is "thread_abc123" + When the session is resumed + Then the tmux session name should be "mastracode-thread_a" + # ── Resume Command ── + @unit Scenario: Resume Claude session Given a card with assistant "claude" and sessionId "abcdef12-3456-7890" When resume is triggered Then the command should be "claude --resume abcdef12-3456-7890" + @unit Scenario: Resume Gemini session by UUID Given a card with assistant "gemini" and sessionId "1250be89-48ad-4418-bec4-1f40afead50e" When resume is triggered Then the command should be "gemini --resume 1250be89-48ad-4418-bec4-1f40afead50e" + @unit + Scenario: Resume Mastracode session + Given a card with assistant "mastracode" and sessionId "thread_abc123" + When resume is triggered + Then the command should be "mastracode" + And it should NOT include "--resume" + + @unit Scenario: Resume with skipPermissions for Claude Given a card with assistant "claude" and skipPermissions is true When resume is triggered Then the command should include "--dangerously-skip-permissions" + @unit Scenario: Resume with skipPermissions for Gemini Given a card with assistant "gemini" and skipPermissions is true When resume is triggered Then the command should include "--yolo" + @unit + Scenario: Resume with skipPermissions for Mastracode + Given a card with assistant "mastracode" and skipPermissions is true + When resume is triggered + Then the command should include "/yolo" + # ── Ready Detection ── + @unit Scenario: Detect Claude is ready in tmux pane Given the tmux pane output contains "❯" When checking if assistant "claude" is ready Then isReady should return true + @unit Scenario: Detect Gemini is ready in tmux pane - Given the tmux pane output contains "> " + Given the tmux pane output contains "Type your message" When checking if assistant "gemini" is ready Then isReady should return true + @unit + Scenario: Detect Mastracode is ready in tmux pane + Given the tmux pane output contains the configured prompt string for assistant "mastracode" + When checking if assistant "mastracode" is ready + Then isReady should return true + + @unit Scenario: Claude prompt character does not trigger Gemini ready - Given the tmux pane output contains "❯" but NOT "> " + Given the tmux pane output contains "❯" but NOT "Type your message" When checking if assistant "gemini" is ready Then isReady should return false + @unit Scenario: Gemini prompt character does not trigger Claude ready - Given the tmux pane output contains "> " but NOT "❯" + Given the tmux pane output contains "Type your message" but NOT "❯" When checking if assistant "claude" is ready Then isReady should return false # ── Image Sending ── + @integration Scenario: Images are sent for Claude sessions Given a card with assistant "claude" And 2 images are attached When the session is launched Then images should be sent via bracketed paste after Claude is ready + @integration Scenario: Images are NOT sent for Gemini sessions Given a card with assistant "gemini" And 2 images are attached @@ -118,15 +175,24 @@ Feature: Launch Sessions with Multiple Assistants Then images should NOT be sent (supportsImageUpload = false) And the text prompt should still be sent + @integration + Scenario: Images are NOT sent for Mastracode sessions + Given a card with assistant "mastracode" + And 2 images are attached + When the session is launched + Then images should NOT be sent (supportsImageUpload = false) + And the text prompt should still be sent + # ── Environment Variables ── - Scenario: KANBAN_CODE_* env vars are set for both assistants + @unit + Scenario: KANBAN_CODE_* env vars are set for all assistants Given any assistant is being launched When the launch command includes extraEnv Then KANBAN_CODE_CARD_ID and KANBAN_CODE_SESSION_ID should be set - # These are assistant-agnostic - Scenario: SHELL override works for both assistants + @unit + Scenario: SHELL override works for all assistants Given shellOverride is "/bin/zsh" When any assistant is launched Then "SHELL=/bin/zsh" should be prepended to the command diff --git a/specs/sessions/mastracode/mastracode-activity.feature b/specs/sessions/mastracode/mastracode-activity.feature new file mode 100644 index 00000000..819f094f --- /dev/null +++ b/specs/sessions/mastracode/mastracode-activity.feature @@ -0,0 +1,49 @@ +Feature: Mastracode Activity Detection + As a developer running Mastracode sessions + I want Kanban Code to detect when Mastracode is active or idle + So that the board updates automatically with status indicators + + Background: + Given the Kanban Code application is running + And the Mastracode LibSQL database is located at the system path + And a Mastracode session is linked to a card + + # ── Database Polling ── + + @integration + Scenario: Detect activity from database modification time + Given the Mastracode database file (`db.sqlite` or `.wal`) was modified less than 2 minutes ago + When the Mastracode activity detector polls + Then it should emit a `SessionUpdate` for the active thread + And `isThinking` should be true + + @integration + Scenario: Detect idle state from older modification time + Given the Mastracode database file was modified more than 5 minutes ago + When the Mastracode activity detector polls + Then it should not emit a `SessionUpdate` + + # ── Active Thread Resolution ── + + @integration + Scenario: Correlate activity with a specific session + Given the Mastracode database was recently modified + And a query to the `messages` table reveals the latest message belongs to "thread_123" + And "thread_123" is linked to card "kanban-5" + When the Mastracode activity detector polls + Then the `SessionUpdate` should only target session "thread_123" + + # ── Edge Cases ── + + @unit + Scenario: Database file missing during polling + Given the Mastracode database file does not exist + When the Mastracode activity detector polls + Then it should fail silently and not emit any updates + + @integration + Scenario: Multiple Mastracode sessions active + Given the database was recently modified + And queries show new messages for both "thread_A" and "thread_B" + When the Mastracode activity detector polls + Then it should emit updates for both sessions diff --git a/specs/sessions/mastracode/mastracode-discovery.feature b/specs/sessions/mastracode/mastracode-discovery.feature new file mode 100644 index 00000000..28cbf0b9 --- /dev/null +++ b/specs/sessions/mastracode/mastracode-discovery.feature @@ -0,0 +1,60 @@ +Feature: Mastracode Session Discovery + As a developer using Mastracode + I want Kanban Code to discover my Mastracode sessions + So that they appear on the board alongside Claude and Gemini sessions + + Background: + Given the Kanban Code application is running + And Mastracode is installed + And a LibSQL database exists at the system-specific application support path + + # ── Project Scoping ── + + @unit + Scenario: Read threads scoped to git remote URL + Given the current project has the git remote URL "git@github.com:langwatch/kanban-code.git" + And the Mastracode database contains a thread mapped to that URL + When Mastracode session discovery queries the database + Then it should retrieve that thread as a Kanban Session + And its projectPath should match the current project + + @unit + Scenario: Read threads scoped to absolute path fallback + Given the current project has no git remote URL + And the current project path is "/Users/dev/my-project" + And the Mastracode database contains a thread mapped to "/Users/dev/my-project" + When Mastracode session discovery queries the database + Then it should retrieve that thread as a Kanban Session + + # ── Session Querying ── + + @integration + Scenario: Parse session metadata from database rows + Given a Mastracode database contains a thread: + | ThreadID | Title | CreatedAt | UpdatedAt | + | thread_1 | Fix API errors | 2026-03-09T10:00:00 | 2026-03-09T10:05:00 | + When the Mastracode session discovery runs + Then 1 session should be discovered + And it should have assistant = "mastracode" + And id = "thread_1" + And name = "Fix API errors" + + @unit + Scenario: Exclude empty threads + Given the Mastracode database contains a thread "thread_2" with 0 messages + When Mastracode session discovery runs + Then "thread_2" should not appear in the results + + # ── Edge Cases ── + + @unit + Scenario: Missing or uninitialized database + Given the Mastracode database file does not exist (first run) + When Mastracode session discovery runs + Then it should return an empty session list without crashing + + @unit + Scenario: SQLite database locked + Given the Mastracode CLI is actively writing and the database returns SQLITE_BUSY + When Mastracode session discovery runs + Then it should retry gracefully or return the last known state diff --git a/specs/sessions/mastracode/mastracode-transcript.feature b/specs/sessions/mastracode/mastracode-transcript.feature new file mode 100644 index 00000000..4d596b4b --- /dev/null +++ b/specs/sessions/mastracode/mastracode-transcript.feature @@ -0,0 +1,57 @@ +Feature: Mastracode Session Transcript Reading + As a developer viewing Mastracode session history + I want to read Mastracode session transcripts in the history view + So that I can review the actions taken by Mastracode + + Background: + Given the Kanban Code application is running + And a Mastracode thread exists in the LibSQL database + + # ── Message Type Mapping ── + + @unit + Scenario: User messages are mapped correctly + Given a row in the messages table with role "user" and content "analyze this file" + When the transcript is read + Then a ConversationTurn with role "user" and text "analyze this file" should be produced + + @unit + Scenario: Assistant messages are mapped correctly + Given a row in the messages table with role "assistant" and content "I found the issue." + When the transcript is read + Then a ConversationTurn with role "assistant" and text "I found the issue." should be produced + + # ── Tool Calls ── + + @unit + Scenario: Tool calls are parsed from JSON strings in the database + Given a message row contains a tool call block: + """ + [{"type": "tool_call", "name": "grep_search", "args": {"pattern": "auth"}}] + """ + When the transcript is read + Then the ConversationTurn should include a toolUse content block for "grep_search" + + @unit + Scenario: Tool results are mapped to the corresponding tool calls + Given a subsequent message row contains a tool result block for "grep_search" + When the transcript is read + Then the result should be appended to the previous toolUse content block + + # ── Fork Session ── + + @integration + Scenario: Forking a Mastracode session duplicates rows in LibSQL + Given a Mastracode thread "thread_A" with 5 messages + When the session is forked to a new card "card_B" + Then a new thread ID should be generated + And the 5 messages should be duplicated in the database under the new thread ID + And the new thread should be mapped to the current project + + # ── Search Sessions ── + + @integration + Scenario: Search within Mastracode sessions via SQLite full-text query + Given the database contains a message with text "React context bug" + When searching for "context" + Then the session containing that text should be returned with snippets diff --git a/specs/sessions/multi-assistant.feature b/specs/sessions/multi-assistant.feature index d003f40d..f1cd1fcc 100644 --- a/specs/sessions/multi-assistant.feature +++ b/specs/sessions/multi-assistant.feature @@ -1,5 +1,5 @@ Feature: Multi-Coding-Assistant Support - As a developer using multiple AI coding assistants (Claude Code, Gemini CLI) + As a developer using multiple AI coding assistants I want Kanban Code to manage sessions from any supported assistant So that I can use whichever tool fits each task @@ -8,65 +8,89 @@ Feature: Multi-Coding-Assistant Support # ── CodingAssistant Enum ── + @unit Scenario: Known coding assistants Then the following coding assistants should be supported: - | ID | Display Name | CLI Command | Config Dir | - | claude | Claude Code | claude | .claude | - | gemini | Gemini CLI | gemini | .gemini | + | ID | Display Name | CLI Command | Session Storage | + | claude | Claude Code | claude | ~/.claude/projects | + | gemini | Gemini CLI | gemini | ~/.gemini/tmp | + | mastracode | Mastra Code | mastracode | ~/Library/Application Support/mastracode | + @unit Scenario: Assistant capabilities Then each assistant should declare its capabilities: - | Assistant | Worktree Support | Image Upload | Auto-Approve Flag | Resume Flag | - | claude | true | true | --dangerously-skip-permissions | --resume | - | gemini | false | false | --yolo | --resume | + | Assistant | Worktree Support | Image Upload | Auto-Approve Flag | Resume Flag | + | claude | true | true | --dangerously-skip-permissions | --resume | + | gemini | false | false | --yolo | --resume | + | mastracode | false | false | /yolo | project-scoped | + @unit Scenario: Assistant prompt characters Then each assistant should have a known prompt character for ready detection: - | Assistant | Prompt Character | - | claude | ❯ | - | gemini | > | + | Assistant | Prompt Character | + | claude | ❯ | + | gemini | Type your message | + + @unit + Scenario: Mastracode prompt markers are configured + Then assistant "mastracode" should have a non-empty promptCharacter + And assistant "mastracode" should have a non-empty historyPromptSymbol # ── Card Assistant Identity ── + @unit Scenario: Cards store their assistant type - Given a card is created with assistant "gemini" - Then the card's Link should have assistant field set to "gemini" + Given a card is created with assistant "mastracode" + Then the card's Link should have assistant field set to "mastracode" + @unit Scenario: Backward compatibility for cards without assistant Given an existing card JSON has no "assistant" field When it is decoded Then its effectiveAssistant should default to "claude" + @integration Scenario: Cards persist assistant through JSON round-trip - Given a card with assistant "gemini" is saved to links.json + Given a card with assistant "mastracode" is saved to links.json When links.json is loaded - Then the card's assistant should be "gemini" + Then the card's assistant should be "mastracode" # ── Session Discovery Tags ── + @integration Scenario: Discovered Claude sessions are tagged Given sessions exist under ~/.claude/projects/ When the composite discovery runs Then those sessions should have assistant = "claude" + @integration Scenario: Discovered Gemini sessions are tagged Given sessions exist under ~/.gemini/tmp//chats/ When the composite discovery runs Then those sessions should have assistant = "gemini" - Scenario: Composite discovery merges both sources - Given 3 Claude sessions and 2 Gemini sessions exist + @integration + Scenario: Discovered Mastracode sessions are tagged + Given sessions exist in the Mastracode database for the current project + When the composite discovery runs + Then those sessions should have assistant = "mastracode" + + @integration + Scenario: Composite discovery preserves all assistant identities + Given 3 Claude sessions, 2 Gemini sessions, and 1 Mastracode session exist When the composite discovery runs - Then all 5 sessions should be returned + Then all 6 sessions should be returned And they should be sorted by modification time (newest first) # ── Settings ── - Scenario: Global default assistant setting - Given the user sets defaultAssistant to "gemini" in settings - When creating a new task - Then the assistant picker should default to "gemini" + @integration + Scenario: Global default assistant setting persists Mastracode + Given the user sets defaultAssistant to "mastracode" in settings + When settings are saved and reloaded + Then defaultAssistant should be "mastracode" + @unit Scenario: Default assistant backward compatibility Given settings.json has no "defaultAssistant" field When settings are loaded diff --git a/specs/system/onboarding-multi-assistant.feature b/specs/system/onboarding-multi-assistant.feature index 5e3d0e2f..656f2268 100644 --- a/specs/system/onboarding-multi-assistant.feature +++ b/specs/system/onboarding-multi-assistant.feature @@ -8,61 +8,100 @@ Feature: Onboarding with Multiple Assistants # ── Coding Assistants Step ── + @integration Scenario: Step shows all known assistants Then the "Coding Assistants" step should check for: - | Assistant | Check Command | - | Claude Code | which claude | - | Gemini CLI | which gemini | + | Assistant | Check Command | + | Claude Code | which claude | + | Gemini CLI | which gemini | + | Mastra Code | which mastracode | - Scenario: Both assistants installed - Given both "claude" and "gemini" are on PATH - Then both should show green checkmarks + @integration + Scenario: All assistants installed + Given "claude", "gemini", and "mastracode" are on PATH + Then Claude Code, Gemini CLI, and Mastra Code should show green checkmarks + @integration Scenario: Only Claude installed - Given "claude" is on PATH but "gemini" is not + Given "claude" is on PATH but "gemini" and "mastracode" are not Then Claude Code should show a green checkmark And Gemini CLI should show "Not installed" with install instructions + And Mastra Code should show "Not installed" with install instructions + @integration Scenario: Only Gemini installed - Given "gemini" is on PATH but "claude" is not + Given "gemini" is on PATH but "claude" and "mastracode" are not Then Gemini CLI should show a green checkmark And Claude Code should show "Not installed" with install instructions + And Mastra Code should show "Not installed" with install instructions - Scenario: Neither installed - Given neither "claude" nor "gemini" is on PATH - Then both should show "Not installed" - And install instructions should be shown for both + @integration + Scenario: Only Mastra Code installed + Given "mastracode" is on PATH but "claude" and "gemini" are not + Then Mastra Code should show a green checkmark + And Claude Code should show "Not installed" with install instructions + And Gemini CLI should show "Not installed" with install instructions + + @integration + Scenario: No assistants installed + Given "claude", "gemini", and "mastracode" are not on PATH + Then Claude Code, Gemini CLI, and Mastra Code should show "Not installed" + And install instructions should be shown for all three + @unit Scenario: Claude install instruction Given Claude Code is not installed Then the install command should be "npm install -g @anthropic-ai/claude-code" + @unit Scenario: Gemini install instruction Given Gemini CLI is not installed Then the install command should be "npm install -g @google/gemini-cli" + @unit + Scenario: Mastra Code install instruction + Given Mastra Code is not installed + Then the install command should be "npm install -g mastracode" + + @integration + Scenario: Mastra Code setup explains database access + Given Mastra Code is installed + Then the onboarding should explain that session history is read from "~/Library/Application Support/mastracode/" + And it should show whether the Mastracode database is accessible + # ── Hooks Step ── - Scenario: Hooks step checks both assistants + @integration + Scenario: Hooks step checks Claude and Gemini Given both Claude and Gemini are installed Then the hooks step should check hook installation for both + @integration Scenario: Install Claude hooks Given Claude Code is installed but hooks are not installed When "Install Claude Hooks" is clicked Then hooks should be written to ~/.claude/settings.json + @integration Scenario: Install Gemini hooks Given Gemini CLI is installed but hooks are not installed When "Install Gemini Hooks" is clicked Then hooks should be installed via Gemini's hook system - Scenario: Kill pre-existing sessions warning + @integration + Scenario: Mastra Code does not require a hooks step + Given Mastra Code is installed and enabled + Then the onboarding should NOT show a "Mastra Code Hooks" step + And activity tracking should rely on database polling instead + + @integration + Scenario: Kill pre-existing Claude sessions warning Given Claude hooks were just installed And 3 Claude processes are running without hooks Then a warning should appear: "3 Claude sessions running without hooks" And a "Kill All Claude Sessions" button should be shown + @integration Scenario: Kill pre-existing Gemini sessions Given Gemini hooks were just installed And 2 Gemini processes are running without hooks @@ -71,24 +110,29 @@ Feature: Onboarding with Multiple Assistants # ── Summary Step ── + @integration Scenario: Summary shows status of all assistants Then the summary step should show: - | Item | Status | + | Item | Status | | Claude Code | Ready/Not set up | | Claude Code Hooks | Ready/Not set up | | Gemini CLI | Ready/Not set up | + | Mastra Code | Ready/Not set up | | Pushover | Ready/Not set up | | tmux | Ready/Not set up | | GitHub CLI | Ready/Not set up | # ── Dependency Checker ── - Scenario: DependencyChecker reports both assistants + @integration + Scenario: DependencyChecker reports assistant and database status When DependencyChecker.checkAll() runs Then the status should include: - | Field | Type | - | claudeAvailable | Bool | - | geminiAvailable | Bool | - | hooksInstalled | Bool | - | tmuxAvailable | Bool | - | ghAvailable | Bool | + | Field | Type | + | claudeAvailable | Bool | + | geminiAvailable | Bool | + | mastracodeAvailable | Bool | + | mastracodeDatabaseAccess | Bool | + | hooksInstalled | Bool | + | tmuxAvailable | Bool | + | ghAvailable | Bool | diff --git a/specs/ui/assistant-picker.feature b/specs/ui/assistant-picker.feature index 16cea5a6..d91b7934 100644 --- a/specs/ui/assistant-picker.feature +++ b/specs/ui/assistant-picker.feature @@ -8,91 +8,139 @@ Feature: Assistant Picker in Task Creation # ── New Task Dialog ── + @integration Scenario: Assistant picker is shown in new task dialog When the New Task dialog opens Then an assistant picker should be visible And it should list all installed assistants + @integration Scenario: Default assistant from settings - Given settings.defaultAssistant is "gemini" + Given settings.defaultAssistant is "mastracode" When the New Task dialog opens - Then the assistant picker should default to "Gemini CLI" + Then the assistant picker should default to "Mastra Code" + @integration Scenario: Default to Claude when no setting Given settings.defaultAssistant is nil When the New Task dialog opens Then the assistant picker should default to "Claude Code" + @integration Scenario: Only installed assistants are shown - Given Claude Code is installed but Gemini CLI is not + Given Claude Code is installed but Gemini CLI and Mastra Code are not When the New Task dialog opens Then only "Claude Code" should appear in the picker And the picker should be disabled (single option) - Scenario: Both assistants installed - Given both Claude Code and Gemini CLI are installed + @integration + Scenario: All installed assistants are shown + Given Claude Code, Gemini CLI, and Mastra Code are installed When the New Task dialog opens - Then both "Claude Code" and "Gemini CLI" should appear in the picker + Then "Claude Code", "Gemini CLI", and "Mastra Code" should appear in the picker # ── Capability-Based UI ── + @integration Scenario: Worktree toggle disabled for Gemini Given assistant "gemini" is selected in the picker Then the "Create worktree" toggle should be disabled And it should show a tooltip "Gemini CLI does not support worktrees" + @integration Scenario: Worktree toggle enabled for Claude Given assistant "claude" is selected in the picker Then the "Create worktree" toggle should be enabled + @integration + Scenario: Worktree toggle disabled for Mastra Code + Given assistant "mastracode" is selected in the picker + Then the "Create worktree" toggle should be disabled + And it should show a tooltip "Mastra Code does not support worktrees" + + @integration Scenario: Image paste disabled for Gemini Given assistant "gemini" is selected When the user tries to paste an image (Cmd+V) Then the image should NOT be added - # supportsImageUpload is false for Gemini + @integration Scenario: Image paste enabled for Claude Given assistant "claude" is selected When the user pastes an image (Cmd+V) Then the image chip should appear + @integration + Scenario: Image paste disabled for Mastra Code + Given assistant "mastracode" is selected + When the user tries to paste an image (Cmd+V) + Then the image should NOT be added + + @integration Scenario: Skip-permissions label adapts to assistant Given assistant "claude" is selected Then the checkbox should show "--dangerously-skip-permissions" When assistant is switched to "gemini" Then the checkbox should show "--yolo" + When assistant is switched to "mastracode" + Then the checkbox should show "/yolo" # ── Command Preview ── + @integration Scenario: Command preview shows Claude command Given assistant "claude" and skipPermissions true Then the command preview should show "claude --dangerously-skip-permissions" + @integration Scenario: Command preview shows Gemini command Given assistant "gemini" and skipPermissions true Then the command preview should show "gemini --yolo" + @integration + Scenario: Command preview shows Mastra Code command + Given assistant "mastracode" and skipPermissions true + Then the command preview should show "mastracode /yolo" + + @integration Scenario: Command preview updates when switching assistant Given the command preview shows "claude --dangerously-skip-permissions" When the user switches to "gemini" Then the preview should update to "gemini --yolo" + When the user switches to "mastracode" + Then the preview should update to "mastracode /yolo" # ── Launch Confirmation Dialog ── + @integration Scenario: Launch confirmation shows correct assistant Given a card with assistant "gemini" is being launched When the Launch Confirmation dialog appears Then the command preview should use "gemini" not "claude" And the worktree toggle should be disabled + @integration Scenario: Resume confirmation shows correct assistant Given a card with assistant "claude" is being resumed When the Launch Confirmation dialog appears Then the command preview should use "claude --resume " + @integration + Scenario: Resume confirmation for Mastra Code omits a resume flag + Given a card with assistant "mastracode" is being resumed + When the Launch Confirmation dialog appears + Then the command preview should use "mastracode" not "mastracode --resume " + # ── Queued Prompts ── - Scenario: Queued prompt dialog respects card assistant for images + @integration + Scenario: Queued prompt dialog respects Gemini image support Given a card with assistant "gemini" has a queued prompt When editing the queued prompt Then image paste should be disabled in the prompt editor + + @integration + Scenario: Queued prompt dialog respects Mastra Code image support + Given a card with assistant "mastracode" has a queued prompt + When editing the queued prompt + Then image paste should be disabled in the prompt editor diff --git a/specs/ui/card-assistant-badge.feature b/specs/ui/card-assistant-badge.feature index 69e96bbe..f6446df6 100644 --- a/specs/ui/card-assistant-badge.feature +++ b/specs/ui/card-assistant-badge.feature @@ -8,19 +8,27 @@ Feature: Card Assistant Badge # ── Badge Display ── - Scenario: Card shows assistant icon + @integration + Scenario: Card shows Gemini icon Given a card with assistant "gemini" Then the card should display a Gemini icon/badge + @integration Scenario: Claude card shows Claude icon Given a card with assistant "claude" Then the card should display a Claude icon/badge + @integration + Scenario: Mastra Code card shows Mastra icon + Given a card with assistant "mastracode" + Then the card should display a Mastra Code icon/badge + + @integration Scenario: Legacy card without assistant shows Claude icon Given an existing card with no assistant field (nil) Then the card should display a Claude icon/badge - # effectiveAssistant defaults to claude + @integration Scenario: Badge is subtle Then the assistant badge should be a small icon And it should not dominate the card layout @@ -28,13 +36,16 @@ Feature: Card Assistant Badge # ── Card Detail View ── + @integration Scenario: Card detail shows assistant name - Given a card with assistant "gemini" is selected + Given a card with assistant "mastracode" is selected When the card detail view is shown - Then the assistant should be displayed as "Gemini CLI" + Then the assistant should be displayed as "Mastra Code" - Scenario: Card detail shows migration button + @integration + Scenario: Card detail shows migration targets Given a card with assistant "claude" and a session When the card detail view is shown - Then a "Migrate to Gemini CLI" button should be available - # Only if the other assistant is installed + Then a migration menu should be available for each other enabled assistant + And it should include "Gemini CLI" + And it should include "Mastra Code"