From df457eb9cd7029fd16757eaf179c3ebf971ac13d Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 8 Mar 2026 00:24:48 +0200 Subject: [PATCH] refactor: streamline task handling and remove legacy support - Removed legacy overlay review state handling from taskStore, simplifying task normalization processes. - Updated task retrieval methods to directly use normalized task data without fallback to legacy kanban states. - Eliminated outdated tests related to legacy kanban overlay, focusing on modern task management mechanisms. - Refactored TeamDataService and TaskBoundaryParser to enhance clarity and maintainability by removing unnecessary complexity. - Updated related types and interfaces to reflect the removal of legacy support. --- .../src/internal/taskStore.js | 32 +- .../test/controller.test.js | 33 - .../services/discovery/SubagentLocator.ts | 99 +- src/main/services/team/TaskBoundaryParser.ts | 76 +- .../services/team/TeamAgentToolsInstaller.ts | 55 - src/main/services/team/TeamDataService.ts | 86 +- .../services/team/TeamMemberLogsFinder.ts | 30 +- .../services/team/TeamTaskAttachmentStore.ts | 47 +- src/main/services/team/TeamTaskReader.ts | 25 +- src/main/services/team/index.ts | 1 - .../settings/sections/ConfigEditorDialog.tsx | 29 +- .../team/dialogs/MembersJsonEditor.tsx | 52 +- src/renderer/utils/codemirrorTheme.ts | 89 +- src/shared/types/review.ts | 4 +- .../services/team/TaskBoundaryParser.test.ts | 52 +- .../team/TeamMemberLogsFinder.test.ts | 4 +- test/main/services/team/teamctl.test.ts | 2741 ----------------- 17 files changed, 156 insertions(+), 3299 deletions(-) delete mode 100644 src/main/services/team/TeamAgentToolsInstaller.ts delete mode 100644 test/main/services/team/teamctl.test.ts diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index bc0c372f..773aabfd 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -72,33 +72,10 @@ function normalizeTaskReviewState(value) { return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none'; } -function getOverlayReviewState(overlayTasks, taskId) { - if (!overlayTasks || typeof overlayTasks !== 'object') { - return 'none'; - } - - const entry = overlayTasks[String(taskId)]; - if (!entry || typeof entry !== 'object') { - return 'none'; - } - - return entry.column === 'review' || entry.column === 'approved' ? entry.column : 'none'; -} - -function withCompatibleReviewState(task, overlayTasks) { - const explicit = normalizeTaskReviewState(task.reviewState); - return explicit === 'none' ? { ...task, reviewState: getOverlayReviewState(overlayTasks, task.id) } : task; -} - function listRawTasks(paths) { ensureDir(paths.tasksDir); const entries = fs.readdirSync(paths.tasksDir); const out = []; - const overlayState = readJson(paths.kanbanPath, null); - const overlayTasks = - overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object' - ? overlayState.tasks - : null; for (const fileName of entries) { if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; @@ -107,7 +84,7 @@ function listRawTasks(paths) { if (!rawTask) continue; if (rawTask.metadata && rawTask.metadata._internal === true) continue; try { - out.push(withCompatibleReviewState(normalizeTask(rawTask, filePath), overlayTasks)); + out.push(normalizeTask(rawTask, filePath)); } catch { // Skip unreadable task rows. } @@ -165,12 +142,7 @@ function readTask(paths, taskRef, options = {}) { if (!rawTask) { throw new Error(`Task not found: ${String(taskRef)}`); } - const overlayState = readJson(paths.kanbanPath, null); - const overlayTasks = - overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object' - ? overlayState.tasks - : null; - return withCompatibleReviewState(normalizeTask(rawTask, taskPath), overlayTasks); + return normalizeTask(rawTask, taskPath); } function createStatusTransition(history, from, to, actor, timestamp) { diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index c8c9bfba..02772457 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -194,39 +194,6 @@ describe('agent-teams-controller API', () => { expect(second.linkedCommentsCreated).toBe(0); }); - it('derives reviewState from legacy kanban overlay and tolerates corrupt kanban state', () => { - const claudeDir = makeClaudeDir(); - const controller = createController({ teamName: 'my-team', claudeDir }); - const task = controller.tasks.createTask({ subject: 'Legacy review task' }); - const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`); - const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8')); - delete rawTask.reviewState; - fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2)); - - const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'); - fs.writeFileSync( - kanbanPath, - JSON.stringify( - { - teamName: 'my-team', - reviewers: [], - tasks: { - [task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null }, - }, - }, - null, - 2 - ) - ); - - expect(controller.tasks.getTask(task.id).reviewState).toBe('review'); - expect(controller.tasks.listTasks()[0].reviewState).toBe('review'); - - fs.writeFileSync(kanbanPath, '{broken-json'); - expect(controller.tasks.getTask(task.id).reviewState).toBe('none'); - expect(controller.tasks.listTasks()[0].reviewState).toBe('none'); - }); - it('tracks lifecycle history and intervals without duplicate same-status transitions', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/src/main/services/discovery/SubagentLocator.ts b/src/main/services/discovery/SubagentLocator.ts index 86092d89..961d2620 100644 --- a/src/main/services/discovery/SubagentLocator.ts +++ b/src/main/services/discovery/SubagentLocator.ts @@ -4,10 +4,8 @@ * Responsibilities: * - Check if sessions have subagent files * - List subagent files for a session - * - Handle both NEW and OLD subagent directory structures: - * - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl - * - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported) - * - Determine subagent ownership for OLD structure + * - Handle the canonical subagent directory structure: + * - {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl */ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; @@ -91,110 +89,25 @@ export class SubagentLocator { } /** - * Lists all subagent files for a session from both NEW and OLD structures. - * Returns NEW structure files first, then OLD structure files. - * - * @param projectId - The project ID - * @param sessionId - The session ID - * @returns Promise resolving to array of file paths + * Lists all subagent files for a session from the canonical session-local structure. */ async listSubagentFiles(projectId: string, sessionId: string): Promise { - const allFiles: string[] = []; - try { - // Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); if (await this.fsProvider.exists(newSubagentsPath)) { const entries = await this.fsProvider.readdir(newSubagentsPath); - const newFiles = entries + return entries .filter( (entry) => entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl') ) .map((entry) => path.join(newSubagentsPath, entry.name)); - allFiles.push(...newFiles); } } catch (error) { - logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error); + logger.error(`Error scanning subagent structure for session ${sessionId}:`, error); } - try { - // Scan OLD structure: {projectId}/agent-*.jsonl - // Must filter by sessionId since all sessions share the same project root - const oldFiles = await this.getProjectRootSubagentFiles(projectId, sessionId); - allFiles.push(...oldFiles); - } catch (error) { - logger.error(`Error scanning OLD subagent structure for project ${projectId}:`, error); - } - - return allFiles; - } - - /** - * Gets subagent files from project root (OLD structure). - * Scans {projectId}/agent-*.jsonl files and filters by sessionId. - * - * In the OLD structure, all subagent files are in the project root, - * so we must read each file's first line to check if it belongs to the session. - * - * @param projectId - The project ID - * @param sessionId - The session ID - * @returns Promise resolving to array of file paths - */ - async getProjectRootSubagentFiles(projectId: string, sessionId: string): Promise { - try { - const projectPath = path.join(this.projectsDir, extractBaseDir(projectId)); - - if (!(await this.fsProvider.exists(projectPath))) { - return []; - } - - const entries = await this.fsProvider.readdir(projectPath); - const agentFiles = entries - .filter((entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')) - .map((entry) => path.join(projectPath, entry.name)); - - // Filter files by checking if their sessionId matches - const matchingFiles: string[] = []; - for (const filePath of agentFiles) { - if (await this.subagentBelongsToSession(filePath, sessionId)) { - matchingFiles.push(filePath); - } - } - - return matchingFiles; - } catch (error) { - logger.error(`Error reading project root for subagent files:`, error); - return []; - } - } - - /** - * Checks if a subagent file belongs to a specific session by reading its first line. - * Subagent files have a sessionId field that points to the parent session. - * - * @param filePath - Path to the subagent file - * @param sessionId - The session ID to check - * @returns Promise resolving to true if the subagent belongs to the session - */ - async subagentBelongsToSession(filePath: string, sessionId: string): Promise { - try { - // Read just the first line to check sessionId - const content = await this.fsProvider.readFile(filePath); - const firstNewline = content.indexOf('\n'); - const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content; - - if (!firstLine.trim()) { - return false; - } - - const entry = JSON.parse(firstLine) as { sessionId?: string }; - return entry.sessionId === sessionId; - } catch (error) { - // If we can't read or parse the file, don't include it - log for debugging - logger.debug(`SubagentLocator: Could not parse file ${filePath}:`, error); - return false; - } + return []; } /** diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts index 98fa9453..797b8608 100644 --- a/src/main/services/team/TaskBoundaryParser.ts +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -31,14 +31,9 @@ interface ToolUseInfo { filePath?: string; } -/** - * Historical fallback for legacy CLI sessions only. - * New runtime sessions are expected to emit structured task updates via MCP/TaskUpdate. - */ -const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/; const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']); -type DetectedMechanism = 'TaskUpdate' | 'teamctl' | 'mcp' | 'none'; +type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none'; function pickDetectedMechanism( current: DetectedMechanism, @@ -46,9 +41,8 @@ function pickDetectedMechanism( ): DetectedMechanism { const priority = { none: 0, - teamctl: 1, - TaskUpdate: 2, - mcp: 3, + TaskUpdate: 1, + mcp: 2, } as const; return priority[next] > priority[current] ? next : current; } @@ -123,13 +117,6 @@ export class TaskBoundaryParser { boundaries.push(...mcpBounds); continue; } - - // Legacy CLI fallback for historical JSONL rows. - const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp); - if (teamctlBounds.length > 0) { - detectedMechanism = pickDetectedMechanism(detectedMechanism, 'teamctl'); - boundaries.push(...teamctlBounds); - } } catch { // Пропускаем невалидные строки } @@ -290,63 +277,6 @@ export class TaskBoundaryParser { return results; } - /** - * Historical fallback: detect legacy teamctl task commands in Bash tool_use blocks. - * Regex: /task\s+(start|complete|set-status)\s+(\d+)/ - */ - private extractTeamctlBoundaries( - content: unknown[], - lineNumber: number, - timestamp: string - ): TaskBoundary[] { - const results: TaskBoundary[] = []; - - for (const block of content) { - if (!block || typeof block !== 'object') continue; - const b = block as Record; - if (b.type !== 'tool_use') continue; - - const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); - if (toolName !== 'Bash') continue; - - const input = b.input as Record | undefined; - if (!input) continue; - - const command = typeof input.command === 'string' ? input.command : ''; - if (!command.includes('teamctl')) continue; - - const match = TEAMCTL_TASK_REGEX.exec(command); - if (!match) continue; - - const action = match[1]; // start | complete | set-status - const taskId = match[2]; - - let event: TaskBoundaryEvent = null; - if (action === 'start') event = 'start'; - else if (action === 'complete') event = 'complete'; - else if (action === 'set-status') { - // set-status может быть start или complete — определяем по аргументам - if (command.includes('in_progress') || command.includes('in-progress')) event = 'start'; - else if (command.includes('completed') || command.includes('done')) event = 'complete'; - } - - if (event) { - const toolUseId = typeof b.id === 'string' ? b.id : undefined; - results.push({ - taskId, - event, - lineNumber, - timestamp, - mechanism: 'teamctl', - toolUseId, - }); - } - } - - return results; - } - /** * Вычислить scopes для каждой задачи на основе границ. * diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts deleted file mode 100644 index 04949124..00000000 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { getToolsBasePath } from '@main/utils/pathDecoder'; -import * as fs from 'fs'; -import * as path from 'path'; - -import { atomicWriteAsync } from './atomicWrite'; - -const TOOL_FILE_NAME = 'teamctl.js'; - -function getCandidateLegacyCliPaths(): string[] { - const cwd = process.cwd(); - - return [ - path.join(cwd, 'agent-teams-controller', 'src', 'legacy', 'teamctl.cli.js'), - path.join(cwd, 'agent-teams-controller', 'dist', 'legacy', 'teamctl.cli.js'), - ]; -} - -async function readExtractedTeamctlSource(): Promise { - for (const candidatePath of getCandidateLegacyCliPaths()) { - try { - return await fs.promises.readFile(candidatePath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } - } - - throw new Error('Extracted teamctl CLI source not found in agent-teams-controller package'); -} - -export class TeamAgentToolsInstaller { - async ensureInstalled(): Promise { - const toolsDir = getToolsBasePath(); - const toolPath = path.join(toolsDir, TOOL_FILE_NAME); - await fs.promises.mkdir(toolsDir, { recursive: true }); - - const desired = await readExtractedTeamctlSource(); - let current: string | null = null; - try { - current = await fs.promises.readFile(toolPath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } - - if (current === desired) { - return toolPath; - } - - await atomicWriteAsync(toolPath, desired); - return toolPath; - } -} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 34cadef9..8aa694c7 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -107,20 +107,13 @@ export class TeamDataService { } private resolveTaskReviewState( - task: Pick, - kanbanState?: Pick + task: Pick ): 'none' | 'review' | 'approved' { - const explicit = normalizeReviewState(task.reviewState); - if (explicit !== 'none') { - return explicit; - } - - const overlay = kanbanState?.tasks?.[task.id]?.column; - return overlay === 'review' || overlay === 'approved' ? overlay : 'none'; + return normalizeReviewState(task.reviewState); } - private attachKanbanCompatibility(task: TeamTask, kanbanState?: KanbanState): TeamTaskWithKanban { - const reviewState = this.resolveTaskReviewState(task, kanbanState); + private attachKanbanCompatibility(task: TeamTask): TeamTaskWithKanban { + const reviewState = this.resolveTaskReviewState(task); return { ...task, reviewState, @@ -172,8 +165,7 @@ export class TeamDataService { continue; } const info = teamInfoMap.get(task.teamName)!; - const kanban = kanbanByTeam.get(task.teamName); - const reviewState = this.resolveTaskReviewState(task, kanban); + const reviewState = this.resolveTaskReviewState(task); const kanbanColumn = getKanbanColumnFromReviewState(reviewState); // IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields). @@ -348,8 +340,6 @@ export class TeamDataService { } if (includeMessages) { - this.ensureStableMessageIds(messages); - // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's // session ID (by timestamp). This avoids the old forward-only propagation bug. if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { @@ -413,19 +403,17 @@ export class TeamDataService { reviewers: [], tasks: {}, }; - let canRunKanbanGc = true; try { kanbanState = await this.kanbanManager.getState(teamName); } catch { warnings.push('Kanban state failed to load'); - canRunKanbanGc = false; } mark('kanbanState'); mark('kanbanGc'); const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => - this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined) + this.attachKanbanCompatibility(task) ); const members = this.memberResolver.resolveMembers( @@ -444,7 +432,7 @@ export class TeamDataService { mark('syncComments'); const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => - this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined) + this.attachKanbanCompatibility(task) ); let processes: TeamProcess[] = []; @@ -1114,66 +1102,6 @@ export class TeamDataService { return normalized === leadName.trim().toLowerCase() || normalized === 'team-lead'; } - private normalizeMessageIdPart(value: string | undefined, fallback = 'unknown'): string { - const normalized = (value ?? '') - .trim() - .replace(/\r\n/g, '\n') - .replace(/\s+/g, '-') - .replace(/[^\p{L}\p{N}_-]/gu, '') - .slice(0, 40); - return normalized || fallback; - } - - /** - * Older inbox/sent-message records may not include messageId. Assign deterministic ids - * so renderer keys remain stable across refreshes, filtering, and live updates. - */ - private ensureStableMessageIds(messages: InboxMessage[]): void { - const seenAssignedIds = new Set(); - const missingIdOccurrences = new Map(); - - for (const message of messages) { - const existingId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; - if (existingId) { - seenAssignedIds.add(existingId); - continue; - } - - const textPrefix = this.normalizeMessageIdPart(message.text?.slice(0, 80), 'empty'); - const fingerprint = [ - message.source ?? 'unknown', - message.timestamp, - message.from, - message.to ?? '', - message.leadSessionId ?? '', - textPrefix, - ].join('\0'); - const occurrence = (missingIdOccurrences.get(fingerprint) ?? 0) + 1; - missingIdOccurrences.set(fingerprint, occurrence); - - let syntheticId = [ - 'synthetic-msg', - this.normalizeMessageIdPart(message.source, 'unknown'), - this.normalizeMessageIdPart(message.timestamp, 'unknown'), - this.normalizeMessageIdPart(message.from, 'unknown'), - this.normalizeMessageIdPart(message.to, 'none'), - textPrefix, - occurrence, - ].join('-'); - - if (seenAssignedIds.has(syntheticId)) { - let collision = 2; - while (seenAssignedIds.has(`${syntheticId}-dup${collision}`)) { - collision++; - } - syntheticId = `${syntheticId}-dup${collision}`; - } - - message.messageId = syntheticId; - seenAssignedIds.add(syntheticId); - } - } - async sendDirectToLead( teamName: string, leadName: string, diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index ed3bf56d..8c6f228e 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -309,8 +309,7 @@ export class TeamMemberLogsFinder { /** * Fast marker probe for task-related logs. - * Prefer structured MCP/TaskUpdate markers for modern sessions; keep teamctl text matching - * only as historical fallback for old JSONL data. + * Prefer structured MCP/TaskUpdate markers for modern sessions. */ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise { const stream = createReadStream(filePath, { encoding: 'utf8' }); @@ -336,11 +335,6 @@ export class TeamMemberLogsFinder { stream.destroy(); return true; } - if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) { - rl.close(); - stream.destroy(); - return true; - } } } catch { // ignore read errors @@ -508,18 +502,6 @@ export class TeamMemberLogsFinder { return typeof raw === 'string' ? raw.trim() : null; }; - const matchesTeamctlCommand = (command: string): boolean => { - if (!/\bteamctl(?:\.js)?\b/i.test(command)) return false; - - const teamMatch = /\s--team(?:\s+|=)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i.exec(command); - const cmdTeam = (teamMatch?.[1] ?? teamMatch?.[2] ?? teamMatch?.[3])?.trim(); - if (cmdTeam?.toLowerCase() !== teamLower) return false; - - const taskMatch = /\btask\s+(?:start|complete|set-status)\s+(\d+)\b/i.exec(command); - const cmdTaskId = taskMatch?.[1]; - return Boolean(cmdTaskId && cmdTaskId === taskIdStr); - }; - const matchesTeamMentionText = (text: string): boolean => { const t = text.toLowerCase(); if (!t.includes(teamLower)) return false; @@ -631,16 +613,6 @@ export class TeamMemberLogsFinder { taskSeenWithoutTeam = true; } } - - // Deterministic CLI match: teamctl command line (Bash tool). - if (toolName === 'Bash') { - const command = typeof input.command === 'string' ? input.command : ''; - if (command && matchesTeamctlCommand(command)) { - rl.close(); - stream.destroy(); - return true; - } - } } if (teamSeen && taskSeenWithoutTeam) { diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 953cc5ba..5f5b372c 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -10,13 +10,6 @@ const logger = createLogger('Service:TeamTaskAttachmentStore'); const TASK_ATTACHMENTS_DIR = 'task-attachments'; const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB -const KNOWN_IMAGE_MIME_TYPES: ReadonlySet = new Set([ - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', -]); - export class TeamTaskAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( @@ -42,7 +35,7 @@ export class TeamTaskAttachmentStore { private sanitizeStoredFilename(original: string): string { const raw = String(original ?? '').trim(); - const base = raw ? raw.split(/[\\/]/).pop() ?? raw : ''; + const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : ''; const cleaned = base .replace(/\0/g, '') .replace(/[\r\n\t]/g, ' ') @@ -65,42 +58,15 @@ export class TeamTaskAttachmentStore { return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`); } - /** Map known MIME types to file extension (legacy storage format). */ - private mimeToExt(mimeType: string): string { - switch (mimeType) { - case 'image/png': - return '.png'; - case 'image/jpeg': - return '.jpg'; - case 'image/gif': - return '.gif'; - case 'image/webp': - return '.webp'; - default: - return '.bin'; - } - } - private async findAttachmentFilePath( teamName: string, taskId: string, attachmentId: string, - mimeType?: string + _mimeType?: string ): Promise { const dir = this.getTaskDir(teamName, taskId); - // 1) Prefer legacy path for known image types (older storage format). - if (mimeType && KNOWN_IMAGE_MIME_TYPES.has(mimeType)) { - const legacy = path.join(dir, `${attachmentId}${this.mimeToExt(mimeType)}`); - try { - const stat = await fs.promises.stat(legacy); - if (stat.isFile()) return legacy; - } catch { - // ignore - } - } - - // 2) New format: "--" + // Canonical format: "--" try { const entries = await fs.promises.readdir(dir); const prefix = `${attachmentId}--`; @@ -108,13 +74,6 @@ export class TeamTaskAttachmentStore { if (matches.length > 0) { return path.join(dir, matches[0]); } - - // 3) Fallback: any file starting with "." (covers legacy when mimeType missing/wrong). - const dotPrefix = `${attachmentId}.`; - const dotMatches = entries.filter((e) => e.startsWith(dotPrefix)); - if (dotMatches.length > 0) { - return path.join(dir, dotMatches[0]); - } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; // Non-directory or other IO errors should surface. diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index d1833658..ad74666c 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -103,24 +103,10 @@ export class TeamTaskReader { if (metadata?._internal === true) { continue; } - // CLI sometimes writes "title" instead of "subject" — normalize - const subject = - typeof parsed.subject === 'string' - ? parsed.subject - : typeof parsed.title === 'string' - ? parsed.title - : ''; - // Resolve createdAt: prefer JSON field, fallback to fs.stat (reuse fileStat from above) - let createdAt: string | undefined; + const subject = typeof parsed.subject === 'string' ? parsed.subject : ''; + const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined; let updatedAt: string | undefined; - if (typeof parsed.createdAt === 'string') { - createdAt = parsed.createdAt; - } try { - if (!createdAt) { - const bt = fileStat.birthtime.getTime(); - createdAt = (bt > 0 ? fileStat.birthtime : fileStat.mtime).toISOString(); - } updatedAt = fileStat.mtime.toISOString(); } catch { /* leave undefined */ @@ -354,12 +340,7 @@ export class TeamTaskReader { continue; } - const subject = - typeof parsed.subject === 'string' - ? parsed.subject - : typeof parsed.title === 'string' - ? parsed.title - : ''; + const subject = typeof parsed.subject === 'string' ? parsed.subject : ''; const task: TeamTask = { id: diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index e46405df..f11cc731 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -6,7 +6,6 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher'; export { MemberStatsComputer } from './MemberStatsComputer'; export { ReviewApplierService } from './ReviewApplierService'; export { TaskBoundaryParser } from './TaskBoundaryParser'; -export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamConfigReader } from './TeamConfigReader'; export { TeamDataService } from './TeamDataService'; diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx index 428e93f7..976a44d0 100644 --- a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx +++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx @@ -16,7 +16,7 @@ import { indentOnInput, syntaxHighlighting, } from '@codemirror/language'; -import { type Diagnostic, linter, lintGutter } from '@codemirror/lint'; +import { lintGutter } from '@codemirror/lint'; import { search, searchKeymap } from '@codemirror/search'; import { EditorState } from '@codemirror/state'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; @@ -29,7 +29,7 @@ import { } from '@codemirror/view'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; -import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; +import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme'; import { AlertTriangle, Check, Loader2, X } from 'lucide-react'; import type { AppConfig } from '@renderer/types/data'; @@ -40,31 +40,6 @@ import type { AppConfig } from '@renderer/types/data'; const SAVE_DEBOUNCE_MS = 800; -// ============================================================================= -// JSON Linter -// ============================================================================= - -const jsonLinter = linter((view: EditorView) => { - const diagnostics: Diagnostic[] = []; - const text = view.state.doc.toString(); - try { - JSON.parse(text); - } catch (e) { - if (e instanceof SyntaxError) { - const match = /position (\d+)/.exec(e.message); - const pos = match ? parseInt(match[1], 10) : 0; - const safePos = Math.min(pos, text.length); - diagnostics.push({ - from: safePos, - to: Math.min(safePos + 1, text.length), - severity: 'error', - message: e.message, - }); - } - } - return diagnostics; -}); - // ============================================================================= // Types // ============================================================================= diff --git a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx index b82e2a61..2be69e8b 100644 --- a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx +++ b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx @@ -3,10 +3,23 @@ import React, { useEffect, useRef } from 'react'; import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { json } from '@codemirror/lang-json'; -import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { + bracketMatching, + foldGutter, + indentOnInput, + syntaxHighlighting, +} from '@codemirror/language'; +import { lintGutter } from '@codemirror/lint'; import { EditorState } from '@codemirror/state'; -import { oneDark } from '@codemirror/theme-one-dark'; -import { EditorView, keymap, lineNumbers } from '@codemirror/view'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; +import { + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, +} from '@codemirror/view'; +import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme'; interface MembersJsonEditorProps { value: string; @@ -14,6 +27,16 @@ interface MembersJsonEditorProps { error: string | null; } +const membersEditorTheme = EditorView.theme({ + '&': { + fontSize: '12px', + maxHeight: '300px', + }, + '.cm-scroller': { + overflow: 'auto', + }, +}); + export const MembersJsonEditor = ({ value, onChange, @@ -31,30 +54,25 @@ export const MembersJsonEditor = ({ doc: value, extensions: [ json(), - oneDark, lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), history(), + foldGutter(), + indentOnInput(), bracketMatching(), closeBrackets(), - syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + syntaxHighlighting(oneDarkHighlightStyle), + jsonLinter, + lintGutter(), keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]), + baseEditorTheme, + membersEditorTheme, EditorView.updateListener.of((update) => { if (update.docChanged) { onChangeRef.current(update.state.doc.toString()); } }), - EditorView.theme({ - '&': { - fontSize: '12px', - maxHeight: '300px', - }, - '.cm-scroller': { - overflow: 'auto', - }, - '.cm-content': { - fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace', - }, - }), ], }); diff --git a/src/renderer/utils/codemirrorTheme.ts b/src/renderer/utils/codemirrorTheme.ts index 270bc360..745e4f0b 100644 --- a/src/renderer/utils/codemirrorTheme.ts +++ b/src/renderer/utils/codemirrorTheme.ts @@ -1,10 +1,11 @@ /** - * Base CodeMirror 6 theme using CSS variables. + * Base CodeMirror 6 theme and shared extensions. * - * Extracted from CodeMirrorDiffView.tsx — shared between diff view and project editor. + * Extracted from CodeMirrorDiffView.tsx — shared between diff view, config editor, and member editor. * Diff-specific styles (changedLine, deletedChunk, merge toolbar) stay in CodeMirrorDiffView. */ +import { type Diagnostic, linter } from '@codemirror/lint'; import { EditorView } from '@codemirror/view'; /** Base editor theme — general styling without diff-specific rules */ @@ -50,4 +51,88 @@ export const baseEditorTheme = EditorView.theme({ '.cm-selectionBackground': { backgroundColor: 'rgba(59, 130, 246, 0.3) !important', }, + + /* ---- Lint tooltips & diagnostics (dark-theme aware) ---- */ + '.cm-tooltip': { + backgroundColor: 'var(--color-surface-raised)', + color: 'var(--color-text)', + border: '1px solid var(--color-border-emphasis)', + borderRadius: '6px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)', + }, + '.cm-tooltip-lint': { + padding: '0', + borderRadius: '6px', + overflow: 'hidden', + }, + '.cm-diagnostic': { + padding: '6px 10px', + borderLeft: '3px solid transparent', + fontSize: '12px', + lineHeight: '1.4', + }, + '.cm-diagnostic-error': { + borderLeftColor: '#ef4444', + color: '#fca5a5', + backgroundColor: 'rgba(239, 68, 68, 0.08)', + }, + '.cm-diagnostic-warning': { + borderLeftColor: '#f59e0b', + color: '#fcd34d', + backgroundColor: 'rgba(245, 158, 11, 0.08)', + }, + '.cm-diagnostic-info': { + borderLeftColor: '#3b82f6', + color: '#93c5fd', + backgroundColor: 'rgba(59, 130, 246, 0.08)', + }, + '.cm-lintRange-error': { + backgroundImage: 'none', + textDecoration: 'underline wavy #ef4444', + textUnderlineOffset: '3px', + }, + '.cm-lintRange-warning': { + backgroundImage: 'none', + textDecoration: 'underline wavy #f59e0b', + textUnderlineOffset: '3px', + }, + + /* ---- Search panel (dark-theme aware) ---- */ + '.cm-panels': { + backgroundColor: 'var(--color-surface-raised)', + color: 'var(--color-text)', + borderTop: '1px solid var(--color-border)', + }, + '.cm-panel input, .cm-panel button': { + color: 'inherit', + }, + '.cm-panel input[type="text"]': { + backgroundColor: 'var(--color-surface)', + border: '1px solid var(--color-border)', + borderRadius: '4px', + padding: '2px 6px', + color: 'var(--color-text)', + }, +}); + +/** Shared JSON linter — validates JSON and reports syntax errors inline. */ +export const jsonLinter = linter((view: EditorView) => { + const diagnostics: Diagnostic[] = []; + const text = view.state.doc.toString(); + try { + JSON.parse(text); + } catch (e) { + if (e instanceof SyntaxError) { + const match = /position (\d+)/.exec(e.message); + const pos = match ? parseInt(match[1], 10) : 0; + const safePos = Math.min(pos, text.length); + diagnostics.push({ + from: safePos, + to: Math.min(safePos + 1, text.length), + severity: 'error', + message: e.message, + }); + } + } + return diagnostics; }); diff --git a/src/shared/types/review.ts b/src/shared/types/review.ts index d0232b9d..860f43a9 100644 --- a/src/shared/types/review.ts +++ b/src/shared/types/review.ts @@ -129,7 +129,7 @@ export interface TaskBoundary { event: 'start' | 'complete'; lineNumber: number; timestamp: string; - mechanism: 'TaskUpdate' | 'teamctl' | 'mcp'; + mechanism: 'TaskUpdate' | 'mcp'; toolUseId?: string; } @@ -158,7 +158,7 @@ export interface TaskBoundariesResult { boundaries: TaskBoundary[]; scopes: TaskChangeScope[]; isSingleTaskSession: boolean; - detectedMechanism: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none'; + detectedMechanism: 'TaskUpdate' | 'mcp' | 'none'; } /** Расширенный TaskChangeSet с confidence деталями (backwards compatible) */ diff --git a/test/main/services/team/TaskBoundaryParser.test.ts b/test/main/services/team/TaskBoundaryParser.test.ts index 5814b873..22cad834 100644 --- a/test/main/services/team/TaskBoundaryParser.test.ts +++ b/test/main/services/team/TaskBoundaryParser.test.ts @@ -64,55 +64,7 @@ describe('TaskBoundaryParser', () => { expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true); }); - it('falls back to legacy teamctl bash parsing for historical logs', async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); - const jsonlPath = path.join(tmpDir, 'teamctl.jsonl'); - await fs.writeFile( - jsonlPath, - [ - JSON.stringify({ - timestamp: '2026-03-01T10:00:00.000Z', - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool-1', - name: 'Bash', - input: { command: 'node "teamctl.js" --team demo task start 123' }, - }, - ], - }, - }), - JSON.stringify({ - timestamp: '2026-03-01T10:10:00.000Z', - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool-2', - name: 'Bash', - input: { command: 'node "teamctl.js" --team demo task set-status 123 completed' }, - }, - ], - }, - }), - ].join('\n') + '\n', - 'utf8' - ); - - const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); - - expect(result.detectedMechanism).toBe('teamctl'); - expect(result.boundaries).toHaveLength(2); - expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']); - expect(result.boundaries.every((entry) => entry.mechanism === 'teamctl')).toBe(true); - }); - - it('prefers structured mechanisms over legacy teamctl in mixed logs', async () => { + it('ignores legacy teamctl bash markers and keeps modern MCP markers only', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); const jsonlPath = path.join(tmpDir, 'mixed.jsonl'); await fs.writeFile( @@ -155,5 +107,7 @@ describe('TaskBoundaryParser', () => { const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); expect(result.detectedMechanism).toBe('mcp'); + expect(result.boundaries).toHaveLength(1); + expect(result.boundaries[0]?.mechanism).toBe('mcp'); }); }); diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 8a83b01a..3d65d774 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -589,7 +589,7 @@ describe('TeamMemberLogsFinder', () => { ).toBe(false); }); - it('detects structured task markers while keeping legacy teamctl matching as fallback', async () => { + it('detects structured task markers and ignores legacy teamctl command lines', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-marker-logs-')); const structuredPath = path.join(tmpDir, 'structured.jsonl'); @@ -646,7 +646,7 @@ describe('TeamMemberLogsFinder', () => { const finder = new TeamMemberLogsFinder(); await expect(finder.hasTaskUpdateMarker(structuredPath, 'task-42')).resolves.toBe(true); - await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(true); + await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(false); await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false); }); }); diff --git a/test/main/services/team/teamctl.test.ts b/test/main/services/team/teamctl.test.ts deleted file mode 100644 index 09b59e79..00000000 --- a/test/main/services/team/teamctl.test.ts +++ /dev/null @@ -1,2741 +0,0 @@ -/** - * Integration tests for teamctl.js — the CLI tool agents use to manage tasks, - * kanban state, messages, reviews, and processes. - * - * Strategy: - * 1. Use TeamAgentToolsInstaller.ensureInstalled() to write the real script. - * 2. Create a temp directory with --claude-dir for full isolation. - * 3. Use child_process.execFileSync (no shell) to run commands. - * 4. Assert on stdout, stderr, exit codes, and written JSON files. - */ - -import { execFile, execFileSync } from 'child_process'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Temp root for all tests. Cleaned up in afterAll. */ -let tmpRoot: string; - -/** Path to the installed teamctl.js script. */ -let scriptPath: string; - -const TEAM = 'test-team'; - -const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/; -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; - -/** Create a fresh claude-dir structure for a single test. */ -function makeFreshClaudeDir(): string { - const dir = fs.mkdtempSync(path.join(tmpRoot, 'claude-')); - const teamsDir = path.join(dir, 'teams', TEAM); - const tasksDir = path.join(dir, 'tasks', TEAM); - fs.mkdirSync(teamsDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); - - const config = { - name: TEAM, - description: 'Test team', - members: [ - { name: 'alice', role: 'team-lead' }, - { name: 'bob', role: 'developer' }, - ], - }; - fs.writeFileSync(path.join(teamsDir, 'config.json'), JSON.stringify(config, null, 2)); - return dir; -} - -/** Write a task fixture into the tasks dir. */ -function writeTask(claudeDir: string, id: string, task: Record): void { - const tasksDir = path.join(claudeDir, 'tasks', TEAM); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(tasksDir, `${id}.json`), JSON.stringify(task, null, 2)); -} - -/** Read a task from disk. */ -function readTask(claudeDir: string, id: string): Record { - const filePath = path.join(claudeDir, 'tasks', TEAM, `${id}.json`); - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -/** Read kanban state from disk. */ -function readKanban(claudeDir: string): Record { - const filePath = path.join(claudeDir, 'teams', TEAM, 'kanban-state.json'); - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch { - return {}; - } -} - -/** Read inbox messages for a member. */ -function readInbox(claudeDir: string, member: string): unknown[] { - const filePath = path.join(claudeDir, 'teams', TEAM, 'inboxes', `${member}.json`); - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch { - return []; - } -} - -/** Read processes.json. */ -function readProcesses(claudeDir: string): unknown[] { - const filePath = path.join(claudeDir, 'teams', TEAM, 'processes.json'); - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch { - return []; - } -} - -interface RunResult { - stdout: string; - stderr: string; - exitCode: number; -} - -/** Run teamctl.js synchronously and return stdout, stderr, exitCode. */ -function run(claudeDir: string, args: string[]): RunResult { - try { - const stdout = execFileSync( - process.execPath, - [scriptPath, '--claude-dir', claudeDir, '--team', TEAM, ...args], - { encoding: 'utf8', timeout: 10_000 } - ); - return { stdout, stderr: '', exitCode: 0 }; - } catch (err: unknown) { - const e = err as { stdout?: string; stderr?: string; status?: number }; - return { - stdout: e.stdout ?? '', - stderr: e.stderr ?? '', - exitCode: e.status ?? 1, - }; - } -} - -/** Run teamctl.js asynchronously (for concurrency tests). */ -function runAsync(claudeDir: string, args: string[]): Promise { - return new Promise((resolve) => { - execFile( - process.execPath, - [scriptPath, '--claude-dir', claudeDir, '--team', TEAM, ...args], - { encoding: 'utf8', timeout: 10_000 }, - (error, stdout, stderr) => { - if (error) { - const e = error as { code?: number; status?: number }; - resolve({ - stdout: stdout ?? '', - stderr: stderr ?? '', - exitCode: e.status ?? e.code ?? 1, - }); - } else { - resolve({ stdout: stdout ?? '', stderr: stderr ?? '', exitCode: 0 }); - } - } - ); - }); -} - -// --------------------------------------------------------------------------- -// Setup / Teardown -// --------------------------------------------------------------------------- - -beforeAll(async () => { - vi.restoreAllMocks(); - - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teamctl-test-')); - - // Mock getToolsBasePath so ensureInstalled() writes to our temp dir - // (setup.ts stubs HOME to /home/testuser which doesn't exist) - const toolsDir = path.join(tmpRoot, 'tools'); - fs.mkdirSync(toolsDir, { recursive: true }); - vi.doMock('@main/utils/pathDecoder', async (importOriginal) => { - const orig = await importOriginal(); - return { ...orig, getToolsBasePath: () => toolsDir }; - }); - - const { TeamAgentToolsInstaller } = await import('@main/services/team/TeamAgentToolsInstaller'); - const installer = new TeamAgentToolsInstaller(); - scriptPath = await installer.ensureInstalled(); -}); - -afterAll(() => { - if (tmpRoot) { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } -}); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('teamctl.js', () => { - let claudeDir: string; - - beforeEach(() => { - vi.restoreAllMocks(); - claudeDir = makeFreshClaudeDir(); - }); - - // ========================================================================= - // Help - // ========================================================================= - describe('help', () => { - it('prints help with --help flag', () => { - const { stdout, exitCode } = run(claudeDir, ['--help']); - expect(exitCode).toBe(0); - expect(stdout).toContain('teamctl.js v'); - expect(stdout).toContain('Usage:'); - expect(stdout).toContain('task set-status'); - expect(stdout).toContain('task set-owner'); - expect(stdout).toContain('task set-clarification'); - expect(stdout).toContain('task briefing'); - expect(stdout).toContain('kanban set-column'); - expect(stdout).toContain('review approve'); - expect(stdout).toContain('review request-changes'); - expect(stdout).toContain('message send'); - expect(stdout).toContain('process register'); - }); - - it('prints help with -h short flag', () => { - const { stdout, exitCode } = run(claudeDir, ['-h']); - expect(exitCode).toBe(0); - expect(stdout).toContain('Usage:'); - }); - - it('prints help with no arguments', () => { - const { stdout, exitCode } = run(claudeDir, []); - expect(exitCode).toBe(0); - expect(stdout).toContain('Usage:'); - }); - }); - - // ========================================================================= - // Arg parsing - // ========================================================================= - describe('arg parsing', () => { - it('supports --key=value syntax', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject=Equals syntax task', - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.subject).toBe('Equals syntax task'); - }); - - it('supports -- separator to stop flag parsing', () => { - const { exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Task with separator', - '--', - '--not-a-flag', - ]); - expect(exitCode).toBe(0); - }); - }); - - // ========================================================================= - // Task Create - // ========================================================================= - describe('task create', () => { - it('creates a task with minimal fields', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'create', '--subject', 'My first task']); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.id).toBe('1'); - expect(parsed.subject).toBe('My first task'); - expect(parsed.status).toBe('pending'); - expect(parsed.owner).toBeUndefined(); - expect(parsed.blocks).toEqual([]); - expect(parsed.blockedBy).toEqual([]); - - // Verify file on disk matches stdout - const onDisk = readTask(claudeDir, '1'); - expect(onDisk.subject).toBe('My first task'); - expect(onDisk.description).toBe('My first task'); // defaults to subject - }); - - it('creates a task with owner -> status defaults to in_progress', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Owned task', - '--owner', - 'bob', - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.owner).toBe('bob'); - expect(parsed.status).toBe('in_progress'); - }); - - it('respects explicit status even with owner', () => { - const { stdout } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Pending owned', - '--owner', - 'bob', - '--status', - 'pending', - ]); - const parsed = JSON.parse(stdout); - expect(parsed.status).toBe('pending'); - expect(parsed.owner).toBe('bob'); - }); - - it('creates blocked task with reverse links and keeps status pending even with owner', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'API contract', status: 'pending' }); - writeTask(claudeDir, '2', { id: '2', subject: 'Database schema', status: 'pending' }); - writeTask(claudeDir, '3', { id: '3', subject: 'Frontend shell', status: 'pending' }); - - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Implement feature', - '--owner', - 'bob', - '--blocked-by', - '1,2', - '--related', - '3', - ]); - - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.id).toBe('4'); - expect(parsed.owner).toBe('bob'); - expect(parsed.status).toBe('pending'); - expect(parsed.blockedBy).toEqual(['1', '2']); - expect(parsed.related).toEqual(['3']); - - expect(readTask(claudeDir, '1').blocks).toEqual(['4']); - expect(readTask(claudeDir, '2').blocks).toEqual(['4']); - expect(readTask(claudeDir, '3').related).toEqual(['4']); - }); - - it('increments task IDs', () => { - run(claudeDir, ['task', 'create', '--subject', 'Task 1']); - run(claudeDir, ['task', 'create', '--subject', 'Task 2']); - const { stdout } = run(claudeDir, ['task', 'create', '--subject', 'Task 3']); - expect(JSON.parse(stdout).id).toBe('3'); - }); - - it('creates task with description, activeForm, and from', () => { - const { stdout } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Complex task', - '--description', - 'Do something important', - '--active-form', - 'Working on complex task', - '--from', - 'alice', - ]); - const parsed = JSON.parse(stdout); - expect(parsed.description).toBe('Do something important'); - expect(parsed.activeForm).toBe('Working on complex task'); - expect(parsed.createdBy).toBe('alice'); - }); - - it('accepts --desc as alias for --description', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'With desc alias', - '--desc', - 'Alias description', - ]); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout).description).toBe('Alias description'); - }); - - it('fails without --subject', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'create']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --subject'); - }); - - it('sends inbox notification with --notify and --owner', () => { - run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Assigned task', - '--owner', - 'bob', - '--notify', - '--from', - 'alice', - ]); - const inbox = readInbox(claudeDir, 'bob'); - expect(inbox.length).toBe(1); - const msg = inbox[0] as Record; - expect(msg.from).toBe('alice'); - expect(String(msg.text)).toMatch(/^New task assigned to you: #1 "Assigned task"\./); - expect(msg.summary).toBe('New task #1 assigned'); - expect(msg.source).toBe('system_notification'); - }); - - it('sends inbox notification with --notify including prompt and tool instructions', () => { - run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Task with prompt', - '--description', - 'Detailed work', - '--prompt', - 'Please implement authentication using JWT', - '--owner', - 'bob', - '--notify', - '--from', - 'alice', - ]); - const inbox = readInbox(claudeDir, 'bob'); - expect(inbox.length).toBe(1); - const text = String((inbox[0] as Record).text); - expect(text).toContain('New task assigned'); - expect(text).toContain('Description:'); - expect(text).toContain('Detailed work'); - expect(text).toContain('Instructions:'); - expect(text).toContain('Please implement authentication using JWT'); - expect(text).toContain('task start'); - expect(text).toContain('task complete'); - }); - - it('does NOT send notification with --notify but without --owner', () => { - run(claudeDir, ['task', 'create', '--subject', 'Unowned with notify', '--notify']); - const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes'); - try { - const files = fs.readdirSync(inboxDir); - expect(files).toHaveLength(0); - } catch { - // inboxes dir doesn't exist -> correct, no notification sent - } - }); - }); - - // ========================================================================= - // Task Set-Status - // ========================================================================= - describe('task set-status', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Test task', - status: 'pending', - blocks: [], - blockedBy: [], - }); - }); - - it('changes status to in_progress', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'set-status', '1', 'in_progress']); - expect(exitCode).toBe(0); - expect(stdout).toContain('status=in_progress'); - expect(readTask(claudeDir, '1').status).toBe('in_progress'); - }); - - it('changes status to completed', () => { - run(claudeDir, ['task', 'set-status', '1', 'completed']); - expect(readTask(claudeDir, '1').status).toBe('completed'); - }); - - it('changes status to deleted', () => { - run(claudeDir, ['task', 'set-status', '1', 'deleted']); - expect(readTask(claudeDir, '1').status).toBe('deleted'); - }); - - it('preserves other task fields when changing status', () => { - writeTask(claudeDir, '2', { - id: '2', - subject: 'Rich', - description: 'Desc', - owner: 'bob', - status: 'pending', - blocks: ['3'], - blockedBy: ['1'], - comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }], - }); - run(claudeDir, ['task', 'set-status', '2', 'in_progress']); - const task = readTask(claudeDir, '2'); - expect(task.status).toBe('in_progress'); - expect(task.subject).toBe('Rich'); - expect(task.description).toBe('Desc'); - expect(task.owner).toBe('bob'); - expect(task.blocks).toEqual(['3']); - expect((task.comments as unknown[]).length).toBe(1); - }); - - it('fails on invalid status', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-status', '1', 'invalid']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Invalid status'); - }); - - it('fails on missing task', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-status', '999', 'pending']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Task not found'); - }); - - it('fails without arguments', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-status']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - }); - - // ========================================================================= - // Task Start / Complete - // ========================================================================= - describe('task start / complete', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'pending' }); - }); - - it('task start sets in_progress', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'start', '1']); - expect(exitCode).toBe(0); - expect(stdout).toContain('status=in_progress'); - expect(readTask(claudeDir, '1').status).toBe('in_progress'); - }); - - it('task complete sets completed', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'complete', '1']); - expect(exitCode).toBe(0); - expect(stdout).toContain('status=completed'); - expect(readTask(claudeDir, '1').status).toBe('completed'); - }); - - it('"done" is alias for "complete"', () => { - expect(run(claudeDir, ['task', 'done', '1']).exitCode).toBe(0); - expect(readTask(claudeDir, '1').status).toBe('completed'); - }); - - it('start fails without task ID', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'start']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - - it('complete fails without task ID', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'complete']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - }); - - // ========================================================================= - // Task Get / List - // ========================================================================= - describe('task get / list', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { id: '1', subject: 'First', status: 'pending' }); - writeTask(claudeDir, '2', { id: '2', subject: 'Second', status: 'in_progress' }); - }); - - it('gets a single task by ID', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'get', '1']); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.subject).toBe('First'); - expect(parsed.id).toBe('1'); - }); - - it('lists all tasks sorted by ID', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'list']); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout) as Record[]; - expect(parsed).toHaveLength(2); - expect(parsed.map((t) => t.id)).toEqual(['1', '2']); - }); - - it('list ignores non-JSON files, dotfiles, and non-numeric names', () => { - const tasksDir = path.join(claudeDir, 'tasks', TEAM); - fs.writeFileSync(path.join(tasksDir, '.highwatermark'), '5'); - fs.writeFileSync(path.join(tasksDir, '.hidden.json'), '{}'); - fs.writeFileSync(path.join(tasksDir, 'readme.txt'), 'not a task'); - fs.writeFileSync(path.join(tasksDir, 'abc.json'), '{"id":"abc"}'); - fs.writeFileSync(path.join(tasksDir, '_internal_1.json'), '{"id":"_internal_1"}'); - - const { stdout, exitCode } = run(claudeDir, ['task', 'list']); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout) as Record[]; - expect(parsed).toHaveLength(2); // only 1.json and 2.json - }); - - it('fails on task get with missing ID', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'get']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - - it('task get on non-existent task fails', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'get', '999']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Task not found'); - }); - }); - - // ========================================================================= - // Task Link / Unlink - // ========================================================================= - describe('task link / unlink', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { id: '1', subject: 'Foundation', status: 'pending' }); - writeTask(claudeDir, '2', { id: '2', subject: 'Feature', status: 'pending' }); - writeTask(claudeDir, '3', { id: '3', subject: 'Docs', status: 'pending' }); - }); - - it('task link --blocked-by updates reverse blocks relationship', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'link', '2', '--blocked-by', '1']); - - expect(exitCode).toBe(0); - expect(stdout).toBe('OK task #2 blocked-by #1\n'); - expect(readTask(claudeDir, '2').blockedBy).toEqual(['1']); - expect(readTask(claudeDir, '1').blocks).toEqual(['2']); - }); - - it('task link --blocks delegates to reverse blockedBy relationship', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'link', '1', '--blocks', '2']); - - expect(exitCode).toBe(0); - expect(stdout).toBe('OK task #1 blocks #2\n'); - expect(readTask(claudeDir, '1').blocks).toEqual(['2']); - expect(readTask(claudeDir, '2').blockedBy).toEqual(['1']); - }); - - it('task unlink removes related links symmetrically', () => { - expect(run(claudeDir, ['task', 'link', '2', '--related', '3']).exitCode).toBe(0); - expect(readTask(claudeDir, '2').related).toEqual(['3']); - expect(readTask(claudeDir, '3').related).toEqual(['2']); - - const { stdout, exitCode } = run(claudeDir, ['task', 'unlink', '2', '--related', '3']); - - expect(exitCode).toBe(0); - expect(stdout).toBe('OK task #2 unlinked related #3\n'); - expect(readTask(claudeDir, '2').related).toEqual([]); - expect(readTask(claudeDir, '3').related).toEqual([]); - }); - - it('fails when link type is ambiguous', () => { - const { exitCode, stderr } = run(claudeDir, [ - 'task', - 'link', - '2', - '--blocked-by', - '1', - '--related', - '3', - ]); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Specify exactly one'); - }); - }); - - // ========================================================================= - // Task Set-Owner / Assign - // ========================================================================= - describe('task set-owner / assign', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Unowned task', - status: 'pending', - blocks: [], - blockedBy: [], - }); - }); - - it('sets owner on an existing task', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'set-owner', '1', 'bob']); - expect(exitCode).toBe(0); - expect(stdout).toContain('owner=bob'); - expect(readTask(claudeDir, '1').owner).toBe('bob'); - }); - - it('"assign" is alias for "set-owner"', () => { - const { exitCode } = run(claudeDir, ['task', 'assign', '1', 'bob']); - expect(exitCode).toBe(0); - expect(readTask(claudeDir, '1').owner).toBe('bob'); - }); - - it('clears owner with "clear"', () => { - writeTask(claudeDir, '2', { - id: '2', - subject: 'Owned task', - status: 'in_progress', - owner: 'bob', - }); - const { stdout, exitCode } = run(claudeDir, ['task', 'set-owner', '2', 'clear']); - expect(exitCode).toBe(0); - expect(stdout).toContain('owner=cleared'); - expect(readTask(claudeDir, '2').owner).toBeUndefined(); - }); - - it('clears owner with "none"', () => { - writeTask(claudeDir, '2', { - id: '2', - subject: 'Owned task', - status: 'in_progress', - owner: 'bob', - }); - expect(run(claudeDir, ['task', 'set-owner', '2', 'none']).exitCode).toBe(0); - expect(readTask(claudeDir, '2').owner).toBeUndefined(); - }); - - it('reassigns owner from one member to another', () => { - writeTask(claudeDir, '2', { - id: '2', - subject: 'Bob task', - status: 'in_progress', - owner: 'bob', - }); - expect(run(claudeDir, ['task', 'set-owner', '2', 'alice']).exitCode).toBe(0); - expect(readTask(claudeDir, '2').owner).toBe('alice'); - }); - - it('preserves other task fields when changing owner', () => { - writeTask(claudeDir, '2', { - id: '2', - subject: 'Rich', - description: 'Desc', - status: 'in_progress', - owner: 'bob', - blocks: ['3'], - blockedBy: ['1'], - comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }], - }); - run(claudeDir, ['task', 'set-owner', '2', 'alice']); - const task = readTask(claudeDir, '2'); - expect(task.owner).toBe('alice'); - expect(task.subject).toBe('Rich'); - expect(task.description).toBe('Desc'); - expect(task.status).toBe('in_progress'); - expect(task.blocks).toEqual(['3']); - expect((task.comments as unknown[]).length).toBe(1); - }); - - it('sends inbox notification with --notify', () => { - run(claudeDir, ['task', 'set-owner', '1', 'bob', '--notify', '--from', 'alice']); - const inbox = readInbox(claudeDir, 'bob'); - expect(inbox.length).toBe(1); - const msg = inbox[0] as Record; - expect(msg.from).toBe('alice'); - expect(String(msg.text)).toMatch(/^Task assigned to you: #1 "Unowned task"\./); - expect(msg.summary).toBe('Task #1 assigned'); - expect(msg.source).toBe('system_notification'); - }); - - it('does NOT send notification without --notify', () => { - run(claudeDir, ['task', 'set-owner', '1', 'bob']); - const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes'); - try { - const files = fs.readdirSync(inboxDir); - expect(files).toHaveLength(0); - } catch { - // inboxes dir doesn't exist -> correct, no notification sent - } - }); - - it('does NOT send notification when clearing owner', () => { - writeTask(claudeDir, '2', { - id: '2', - subject: 'Owned', - status: 'in_progress', - owner: 'bob', - }); - run(claudeDir, ['task', 'set-owner', '2', 'clear', '--notify']); - const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes'); - try { - const files = fs.readdirSync(inboxDir); - expect(files).toHaveLength(0); - } catch { - // no inboxes dir -> correct - } - }); - - it('fails without task ID', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-owner']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - - it('fails without owner argument', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-owner', '1']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - - it('fails on non-existent task', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-owner', '999', 'bob']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Task not found'); - }); - }); - - // ========================================================================= - // Task Comment - // ========================================================================= - describe('task comment', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Commentable task', - status: 'in_progress', - owner: 'bob', - comments: [], - }); - }); - - it('adds a comment with valid ID, timestamp, and type=regular', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'comment', - '1', - '--text', - 'Hello world', - '--from', - 'alice', - ]); - expect(exitCode).toBe(0); - expect(stdout).toContain('comment added'); - - const task = readTask(claudeDir, '1'); - const comments = task.comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].text).toBe('Hello world'); - expect(comments[0].author).toBe('alice'); - expect(comments[0].type).toBe('regular'); - expect(String(comments[0].id)).toMatch(UUID_RE); - expect(String(comments[0].createdAt)).toMatch(ISO_RE); - }); - - it('defaults author to inferred lead name when --from is not specified', () => { - run(claudeDir, ['task', 'comment', '1', '--text', 'No author']); - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments[0].author).toBe('alice'); - }); - - it('sends inbox notification to owner (skip self-notification)', () => { - run(claudeDir, ['task', 'comment', '1', '--text', 'Review this', '--from', 'alice']); - expect(readInbox(claudeDir, 'bob').length).toBe(1); - const msg = readInbox(claudeDir, 'bob')[0] as Record; - expect(String(msg.text)).toBe('Comment on task #1 "Commentable task":\n\nReview this'); - expect(msg.summary).toBe('Comment on #1'); - expect(msg.source).toBe('system_notification'); - - run(claudeDir, ['task', 'comment', '1', '--text', 'Self note', '--from', 'bob']); - expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1 - }); - - it('multiple comments accumulate with unique IDs and type=regular', () => { - run(claudeDir, ['task', 'comment', '1', '--text', 'First', '--from', 'alice']); - run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']); - run(claudeDir, ['task', 'comment', '1', '--text', 'Third', '--from', 'alice']); - - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(3); - expect(comments.map((c) => c.text)).toEqual(['First', 'Second', 'Third']); - expect(comments.map((c) => c.author)).toEqual(['alice', 'bob', 'alice']); - expect(new Set(comments.map((c) => c.id)).size).toBe(3); - expect(comments.every((c) => c.type === 'regular')).toBe(true); - }); - - it('comment on task without comments array initializes it', () => { - writeTask(claudeDir, '2', { id: '2', subject: 'No comments field', status: 'pending' }); - expect( - run(claudeDir, ['task', 'comment', '2', '--text', 'First', '--from', 'alice']).exitCode - ).toBe(0); - expect((readTask(claudeDir, '2').comments as unknown[]).length).toBe(1); - }); - - it('fails without --text', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'comment', '1']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --text'); - }); - }); - - // ========================================================================= - // Attachments (task + comment) - // ========================================================================= - describe('attachments', () => { - it('task attach copies file into storage and records metadata', () => { - // Create task - expect(run(claudeDir, ['task', 'create', '--subject', 'With attachment']).exitCode).toBe(0); - - const samplePath = path.join(claudeDir, 'sample.txt'); - fs.writeFileSync(samplePath, 'hello'); - - const { stdout, exitCode } = run(claudeDir, ['task', 'attach', '1', '--file', samplePath]); - expect(exitCode).toBe(0); - - const meta = JSON.parse(stdout) as { - id: string; - filename: string; - mimeType: string; - size: number; - addedAt: string; - }; - expect(meta.id).toBeDefined(); - expect(meta.filename).toBe('sample.txt'); - expect(meta.mimeType).toBe('text/plain'); - expect(meta.size).toBe(5); - expect(meta.addedAt).toMatch(ISO_RE); - - const storedPath = path.join( - claudeDir, - 'teams', - TEAM, - 'task-attachments', - '1', - `${meta.id}--${meta.filename}` - ); - expect(fs.existsSync(storedPath)).toBe(true); - expect(fs.readFileSync(storedPath, 'utf8')).toBe('hello'); - - const task = readTask(claudeDir, '1'); - const attachments = task.attachments as Record[]; - expect(attachments).toHaveLength(1); - expect(attachments[0].id).toBe(meta.id); - expect(attachments[0].filename).toBe(meta.filename); - expect(attachments[0].mimeType).toBe(meta.mimeType); - }); - - it('task attach supports --filename and --mime-type overrides', () => { - expect(run(claudeDir, ['task', 'create', '--subject', 'With override']).exitCode).toBe(0); - - const samplePath = path.join(claudeDir, 'sample.bin'); - fs.writeFileSync(samplePath, Buffer.from([1, 2, 3, 4])); - - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'attach', - '1', - '--file', - samplePath, - '--filename', - 'renamed.dat', - '--mime-type', - 'application/octet-stream', - ]); - expect(exitCode).toBe(0); - const meta = JSON.parse(stdout) as { id: string; filename: string; mimeType: string; size: number }; - expect(meta.filename).toBe('renamed.dat'); - expect(meta.mimeType).toBe('application/octet-stream'); - - const storedPath = path.join( - claudeDir, - 'teams', - TEAM, - 'task-attachments', - '1', - `${meta.id}--${meta.filename}` - ); - expect(fs.existsSync(storedPath)).toBe(true); - expect(fs.readFileSync(storedPath)).toEqual(Buffer.from([1, 2, 3, 4])); - }); - - it('task comment-attach adds attachment to a specific comment', () => { - expect(run(claudeDir, ['task', 'create', '--subject', 'Comment attach']).exitCode).toBe(0); - expect(run(claudeDir, ['task', 'comment', '1', '--text', 'First comment', '--from', 'alice']).exitCode).toBe( - 0 - ); - - const taskAfterComment = readTask(claudeDir, '1'); - const commentId = String((taskAfterComment.comments as Record[])[0].id); - - const samplePath = path.join(claudeDir, 'comment.txt'); - fs.writeFileSync(samplePath, 'comment-file'); - - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'comment-attach', - '1', - commentId, - '--file', - samplePath, - ]); - expect(exitCode).toBe(0); - const meta = JSON.parse(stdout) as { id: string; filename: string; mimeType: string }; - expect(meta.filename).toBe('comment.txt'); - - const storedPath = path.join( - claudeDir, - 'teams', - TEAM, - 'task-attachments', - '1', - `${meta.id}--${meta.filename}` - ); - expect(fs.existsSync(storedPath)).toBe(true); - - const taskAfterAttach = readTask(claudeDir, '1'); - const comment = (taskAfterAttach.comments as Record[]).find( - (c) => String(c.id) === commentId - ) as Record; - expect(comment).toBeDefined(); - const attachments = comment.attachments as Record[]; - expect(attachments).toHaveLength(1); - expect(attachments[0].id).toBe(meta.id); - expect(attachments[0].filename).toBe(meta.filename); - expect(attachments[0].mimeType).toBe(meta.mimeType); - }); - - it('task attach with --mode link succeeds (may fall back to copy)', () => { - expect(run(claudeDir, ['task', 'create', '--subject', 'Link mode']).exitCode).toBe(0); - - const samplePath = path.join(claudeDir, 'link.txt'); - fs.writeFileSync(samplePath, 'link'); - - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'attach', - '1', - '--file', - samplePath, - '--mode', - 'link', - ]); - expect(exitCode).toBe(0); - const meta = JSON.parse(stdout) as { id: string; filename: string }; - const storedPath = path.join( - claudeDir, - 'teams', - TEAM, - 'task-attachments', - '1', - `${meta.id}--${meta.filename}` - ); - expect(fs.existsSync(storedPath)).toBe(true); - }); - }); - - // ========================================================================= - // Comment Auto-Clear needsClarification - // ========================================================================= - describe('comment auto-clear needsClarification', () => { - it('clears "lead" when non-owner comments', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Blocked', - status: 'in_progress', - owner: 'bob', - needsClarification: 'lead', - comments: [], - }); - run(claudeDir, ['task', 'comment', '1', '--text', 'Answer', '--from', 'alice']); - expect(readTask(claudeDir, '1').needsClarification).toBeUndefined(); - }); - - it('does NOT clear "lead" when owner comments (still waiting for answer)', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Blocked', - status: 'in_progress', - owner: 'bob', - needsClarification: 'lead', - comments: [], - }); - run(claudeDir, ['task', 'comment', '1', '--text', 'Still waiting', '--from', 'bob']); - expect(readTask(claudeDir, '1').needsClarification).toBe('lead'); - }); - - it('does NOT clear "user" via CLI comment (only UI clears "user")', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Escalated', - status: 'in_progress', - owner: 'bob', - needsClarification: 'user', - comments: [], - }); - run(claudeDir, ['task', 'comment', '1', '--text', 'Anything', '--from', 'alice']); - expect(readTask(claudeDir, '1').needsClarification).toBe('user'); - }); - - it('clears "lead" with default author "agent" (agent != owner)', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Blocked', - status: 'in_progress', - owner: 'bob', - needsClarification: 'lead', - comments: [], - }); - run(claudeDir, ['task', 'comment', '1', '--text', 'Reply']); - expect(readTask(claudeDir, '1').needsClarification).toBeUndefined(); - }); - - it('auto-clear and comment are a single atomic write', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Blocked', - status: 'in_progress', - owner: 'bob', - needsClarification: 'lead', - comments: [], - }); - run(claudeDir, ['task', 'comment', '1', '--text', 'Answer', '--from', 'alice']); - const task = readTask(claudeDir, '1'); - expect(task.needsClarification).toBeUndefined(); - expect((task.comments as unknown[]).length).toBe(1); - }); - }); - - // ========================================================================= - // Task Set-Clarification - // ========================================================================= - describe('task set-clarification', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Task needing help', - status: 'in_progress', - owner: 'bob', - }); - }); - - it('sets needsClarification to "lead"', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'lead']); - expect(exitCode).toBe(0); - expect(stdout).toContain('needsClarification=lead'); - expect(readTask(claudeDir, '1').needsClarification).toBe('lead'); - }); - - it('sets needsClarification to "user"', () => { - expect(run(claudeDir, ['task', 'set-clarification', '1', 'user']).exitCode).toBe(0); - expect(readTask(claudeDir, '1').needsClarification).toBe('user'); - }); - - it('clears needsClarification with "clear"', () => { - run(claudeDir, ['task', 'set-clarification', '1', 'lead']); - expect(readTask(claudeDir, '1').needsClarification).toBe('lead'); - - const { stdout, exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'clear']); - expect(exitCode).toBe(0); - expect(stdout).toContain('needsClarification=cleared'); - expect(readTask(claudeDir, '1').needsClarification).toBeUndefined(); - }); - - it('can transition from lead to user (escalation)', () => { - run(claudeDir, ['task', 'set-clarification', '1', 'lead']); - run(claudeDir, ['task', 'set-clarification', '1', 'user']); - expect(readTask(claudeDir, '1').needsClarification).toBe('user'); - }); - - it('preserves all other task fields', () => { - writeTask(claudeDir, '2', { - id: '2', - subject: 'Rich task', - description: 'Detailed desc', - status: 'in_progress', - owner: 'bob', - blocks: ['3'], - blockedBy: [], - comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }], - }); - run(claudeDir, ['task', 'set-clarification', '2', 'lead']); - const task = readTask(claudeDir, '2'); - expect(task.needsClarification).toBe('lead'); - expect(task.subject).toBe('Rich task'); - expect(task.description).toBe('Detailed desc'); - expect(task.owner).toBe('bob'); - expect(task.blocks).toEqual(['3']); - expect((task.comments as unknown[]).length).toBe(1); - }); - - it('fails on invalid value (shows allowed values)', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-clarification', '1', 'invalid']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Invalid value'); - expect(stderr).toContain('lead, user, clear'); - }); - - it('fails on missing arguments', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'set-clarification']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - }); - - // ========================================================================= - // Task Briefing - // ========================================================================= - describe('task briefing', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Alice in-progress', - status: 'in_progress', - owner: 'alice', - }); - writeTask(claudeDir, '2', { - id: '2', - subject: 'Bob todo', - status: 'pending', - owner: 'bob', - }); - writeTask(claudeDir, '3', { - id: '3', - subject: 'Unassigned', - status: 'pending', - }); - writeTask(claudeDir, '4', { - id: '4', - subject: 'Blocked task', - status: 'in_progress', - owner: 'bob', - needsClarification: 'lead', - }); - }); - - it('shows briefing with correct section placement', () => { - const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(exitCode).toBe(0); - expect(stdout).toContain('Task Briefing for bob'); - - const yourTasksIdx = stdout.indexOf('YOUR TASKS'); - const teamBoardIdx = stdout.indexOf('TEAM BOARD'); - expect(yourTasksIdx).toBeGreaterThan(-1); - expect(teamBoardIdx).toBeGreaterThan(yourTasksIdx); - - // Bob's tasks in YOUR TASKS section - const yourSection = stdout.slice(yourTasksIdx, teamBoardIdx); - expect(yourSection).toContain('Bob todo'); - expect(yourSection).toContain('Blocked task'); - - // Alice's task in TEAM BOARD section - const teamSection = stdout.slice(teamBoardIdx); - expect(teamSection).toContain('Alice in-progress'); - }); - - it('shows needsClarification indicator', () => { - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'alice']); - expect(stdout).toContain('NEEDS CLARIFICATION'); - expect(stdout).toContain('LEAD'); - }); - - it('shows tasks in review kanban column', () => { - writeTask(claudeDir, '5', { - id: '5', - subject: 'Under review task', - status: 'completed', - owner: 'bob', - }); - run(claudeDir, ['kanban', 'set-column', '5', 'review']); - - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(stdout).toContain('Under review task'); - expect(stdout).toContain('REVIEW'); - }); - - it('excludes approved tasks', () => { - writeTask(claudeDir, '5', { - id: '5', - subject: 'Approved task', - status: 'completed', - owner: 'bob', - }); - run(claudeDir, ['kanban', 'set-column', '5', 'approved']); - expect(run(claudeDir, ['task', 'briefing', '--for', 'bob']).stdout).not.toContain( - 'Approved task' - ); - }); - - it('excludes deleted tasks', () => { - writeTask(claudeDir, '5', { - id: '5', - subject: 'Deleted task', - status: 'deleted', - owner: 'bob', - }); - expect(run(claudeDir, ['task', 'briefing', '--for', 'bob']).stdout).not.toContain( - 'Deleted task' - ); - }); - - it('filters out _internal tasks', () => { - writeTask(claudeDir, '_internal_1', { - id: '_internal_1', - subject: 'CLI bookkeeping', - status: 'pending', - metadata: { _internal: true }, - }); - expect(run(claudeDir, ['task', 'briefing', '--for', 'alice']).stdout).not.toContain( - 'CLI bookkeeping' - ); - }); - - it('truncates description to 500 chars', () => { - writeTask(claudeDir, '5', { - id: '5', - subject: 'Long desc', - description: 'X'.repeat(600), - status: 'in_progress', - owner: 'bob', - }); - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(stdout).toContain('X'.repeat(500)); - expect(stdout).not.toContain('X'.repeat(501)); - }); - - it('caps DONE section to 15 tasks', () => { - for (let i = 10; i < 30; i++) { - writeTask(claudeDir, String(i), { - id: String(i), - subject: `Done task ${i}`, - status: 'completed', - owner: 'bob', - }); - } - const matches = - run(claudeDir, ['task', 'briefing', '--for', 'bob']).stdout.match(/Done task \d+/g) ?? []; - expect(matches.length).toBeLessThanOrEqual(15); - }); - - it('shows blockedBy and related info', () => { - writeTask(claudeDir, '5', { - id: '5', - subject: 'Blocked by others', - status: 'pending', - owner: 'bob', - blockedBy: ['1', '2'], - related: ['3'], - }); - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(stdout).toContain('Blocked by: #1, #2'); - expect(stdout).toContain('Related: #3'); - }); - - it('shows comment count and content', () => { - writeTask(claudeDir, '5', { - id: '5', - subject: 'Task with comments', - status: 'in_progress', - owner: 'bob', - comments: [ - { id: 'c1', author: 'alice', text: 'Please fix this', createdAt: '2025-06-01T12:00:00Z' }, - { id: 'c2', author: 'bob', text: 'Working on it', createdAt: '2025-06-01T13:00:00Z' }, - ], - }); - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(stdout).toContain('Comments (2)'); - expect(stdout).toContain('[alice'); - expect(stdout).toContain('Please fix this'); - }); - - it('shows "no tasks assigned" when member has no tasks', () => { - expect(run(claudeDir, ['task', 'briefing', '--for', 'charlie']).stdout).toContain( - 'no tasks assigned to you' - ); - }); - - it('fails without --for', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'briefing']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --for'); - }); - }); - - // ========================================================================= - // Kanban - // ========================================================================= - describe('kanban', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { id: '1', subject: 'Review me', status: 'completed' }); - }); - - it('sets kanban column to review with movedAt timestamp', () => { - const { stdout, exitCode } = run(claudeDir, ['kanban', 'set-column', '1', 'review']); - expect(exitCode).toBe(0); - expect(stdout).toContain('column=review'); - const tasks = readKanban(claudeDir).tasks as Record>; - expect(tasks['1'].column).toBe('review'); - expect(tasks['1'].reviewer).toBeNull(); - expect(String(tasks['1'].movedAt)).toMatch(ISO_RE); - }); - - it('sets kanban column to approved', () => { - run(claudeDir, ['kanban', 'set-column', '1', 'approved']); - const tasks = readKanban(claudeDir).tasks as Record>; - expect(tasks['1'].column).toBe('approved'); - expect(String(tasks['1'].movedAt)).toMatch(ISO_RE); - }); - - it('clears kanban entry with "clear"', () => { - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - expect(run(claudeDir, ['kanban', 'clear', '1']).exitCode).toBe(0); - expect((readKanban(claudeDir).tasks as Record)['1']).toBeUndefined(); - }); - - it('"remove" is alias for "clear"', () => { - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - expect(run(claudeDir, ['kanban', 'remove', '1']).exitCode).toBe(0); - expect((readKanban(claudeDir).tasks as Record)['1']).toBeUndefined(); - }); - - it('fails on invalid column', () => { - const { exitCode, stderr } = run(claudeDir, ['kanban', 'set-column', '1', 'invalid']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Invalid column'); - }); - - it('maintains state for multiple tasks', () => { - writeTask(claudeDir, '2', { id: '2', subject: 'Another', status: 'completed' }); - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - run(claudeDir, ['kanban', 'set-column', '2', 'approved']); - const tasks = readKanban(claudeDir).tasks as Record>; - expect(tasks['1'].column).toBe('review'); - expect(tasks['2'].column).toBe('approved'); - }); - }); - - // ========================================================================= - // Kanban Reviewers - // ========================================================================= - describe('kanban reviewers', () => { - it('lists empty reviewers', () => { - expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual([]); - }); - - it('adds and removes reviewers', () => { - run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']); - run(claudeDir, ['kanban', 'reviewers', 'add', 'bob']); - expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual([ - 'alice', - 'bob', - ]); - - run(claudeDir, ['kanban', 'reviewers', 'remove', 'alice']); - expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual(['bob']); - }); - - it('add is idempotent (Set-based, no duplicates)', () => { - run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']); - run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']); - expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual(['alice']); - }); - - it('remove non-existent reviewer is a no-op', () => { - run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']); - run(claudeDir, ['kanban', 'reviewers', 'remove', 'nonexistent']); - expect(JSON.parse(run(claudeDir, ['kanban', 'reviewers', 'list']).stdout)).toEqual(['alice']); - }); - }); - - // ========================================================================= - // Review - // ========================================================================= - describe('review', () => { - beforeEach(() => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Feature X', - status: 'completed', - owner: 'bob', - }); - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - }); - - it('approves a task -> moves to approved column', () => { - expect(run(claudeDir, ['review', 'approve', '1']).exitCode).toBe(0); - expect( - (readKanban(claudeDir).tasks as Record>)['1'].column - ).toBe('approved'); - }); - - it('approve with --notify-owner sends inbox message with note', () => { - run(claudeDir, [ - 'review', - 'approve', - '1', - '--notify-owner', - '--from', - 'alice', - '--note', - 'Looks great!', - ]); - const inbox = readInbox(claudeDir, 'bob'); - expect(inbox.length).toBe(1); - const text = String((inbox[0] as Record).text); - expect(text).toBe('Task #1 approved.\n\nLooks great!'); - expect((inbox[0] as Record).summary).toBe('Approved #1'); - expect((inbox[0] as Record).source).toBe('system_notification'); - expect((inbox[0] as Record).from).toBe('alice'); - }); - - it('approve with --notify-owner but no --note sends plain message', () => { - run(claudeDir, ['review', 'approve', '1', '--notify-owner', '--from', 'alice']); - const text = String((readInbox(claudeDir, 'bob')[0] as Record).text); - expect(text).toContain('#1 approved'); - }); - - it('request-changes -> clears kanban, sets in_progress, sends inbox with comment', () => { - expect( - run(claudeDir, [ - 'review', - 'request-changes', - '1', - '--comment', - 'Fix the edge case', - '--from', - 'alice', - ]).exitCode - ).toBe(0); - - expect((readKanban(claudeDir).tasks as Record)['1']).toBeUndefined(); - expect(readTask(claudeDir, '1').status).toBe('in_progress'); - const text = String((readInbox(claudeDir, 'bob')[0] as Record).text); - expect(text).toBe( - 'Task #1 needs fixes.\n\nFix the edge case\n\nPlease fix and mark it as completed when ready.' - ); - expect((readInbox(claudeDir, 'bob')[0] as Record).summary).toBe( - 'Fix request for #1' - ); - expect((readInbox(claudeDir, 'bob')[0] as Record).source).toBe( - 'system_notification' - ); - }); - - it('request-changes without --comment uses default text', () => { - run(claudeDir, ['review', 'request-changes', '1', '--from', 'alice']); - const text = String((readInbox(claudeDir, 'bob')[0] as Record).text); - expect(text).toContain('Reviewer requested changes'); - }); - - it('request-changes on task without owner fails', () => { - writeTask(claudeDir, '2', { id: '2', subject: 'No owner', status: 'completed' }); - const { exitCode, stderr } = run(claudeDir, [ - 'review', - 'request-changes', - '2', - '--comment', - 'Fix', - ]); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('No owner found'); - }); - - it('approve fails without task ID', () => { - const { exitCode, stderr } = run(claudeDir, ['review', 'approve']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Usage'); - }); - - it('approve records review_approved comment in task.comments', () => { - run(claudeDir, ['review', 'approve', '1', '--from', 'alice']); - const task = readTask(claudeDir, '1'); - const comments = task.comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].type).toBe('review_approved'); - expect(comments[0].author).toBe('alice'); - expect(comments[0].text).toBe('Approved'); - expect(String(comments[0].id)).toMatch(UUID_RE); - expect(String(comments[0].createdAt)).toMatch(ISO_RE); - }); - - it('approve records review_approved comment with --note text', () => { - run(claudeDir, [ - 'review', - 'approve', - '1', - '--notify-owner', - '--from', - 'alice', - '--note', - 'Looks great!', - ]); - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].type).toBe('review_approved'); - expect(comments[0].text).toBe('Looks great!'); - }); - - it('request-changes records review_request comment in task.comments', () => { - run(claudeDir, [ - 'review', - 'request-changes', - '1', - '--comment', - 'Fix the edge case', - '--from', - 'alice', - ]); - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].type).toBe('review_request'); - expect(comments[0].author).toBe('alice'); - expect(comments[0].text).toBe('Fix the edge case'); - expect(String(comments[0].id)).toMatch(UUID_RE); - expect(String(comments[0].createdAt)).toMatch(ISO_RE); - }); - - it('request-changes without --comment records default text as review_request', () => { - run(claudeDir, ['review', 'request-changes', '1', '--from', 'alice']); - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].type).toBe('review_request'); - expect(comments[0].text).toBe('Reviewer requested changes.'); - }); - - it('review comments preserve existing task comments', () => { - // Add a regular comment first - run(claudeDir, ['task', 'comment', '1', '--text', 'Working on it', '--from', 'bob']); - // Then request changes - run(claudeDir, [ - 'review', - 'request-changes', - '1', - '--comment', - 'Needs tests', - '--from', - 'alice', - ]); - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(2); - expect(comments[0].type).toBe('regular'); - expect(comments[0].text).toBe('Working on it'); - expect(comments[1].type).toBe('review_request'); - expect(comments[1].text).toBe('Needs tests'); - }); - }); - - // ========================================================================= - // Message Send - // ========================================================================= - describe('message send', () => { - it('sends a message with all fields validated', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'message', - 'send', - '--to', - 'bob', - '--text', - 'Hello Bob!', - '--summary', - 'Greeting', - '--from', - 'alice', - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.deliveredToInbox).toBe(true); - expect(String(parsed.messageId)).toMatch(UUID_RE); - - const msg = readInbox(claudeDir, 'bob')[0] as Record; - expect(msg.from).toBe('alice'); - expect(msg.text).toBe('Hello Bob!'); - expect(msg.summary).toBe('Greeting'); - expect(msg.read).toBe(false); - expect(String(msg.timestamp)).toMatch(ISO_RE); - expect(String(msg.messageId)).toMatch(UUID_RE); - }); - - it('infers lead name from config when --from is missing', () => { - run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']); - expect((readInbox(claudeDir, 'bob')[0] as Record).from).toBe('alice'); - }); - - it('multiple messages accumulate with unique IDs', () => { - run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Msg 1', '--from', 'alice']); - run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Msg 2', '--from', 'alice']); - run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Msg 3', '--from', 'alice']); - const inbox = readInbox(claudeDir, 'bob') as Record[]; - expect(inbox.length).toBe(3); - expect(inbox.map((m) => m.text)).toEqual(['Msg 1', 'Msg 2', 'Msg 3']); - expect(new Set(inbox.map((m) => m.messageId)).size).toBe(3); - }); - - it('fails without --to', () => { - const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--text', 'No recipient']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --to'); - }); - - it('fails without --text', () => { - const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--to', 'bob']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --text'); - }); - }); - - // ========================================================================= - // Process Management - // ========================================================================= - describe('process management', () => { - it('registers a process with all optional fields', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'dev-server', - '--port', - '3000', - '--url', - 'http://localhost:3000', - '--claude-process-id', - 'cp-123', - '--from', - 'bob', - '--command', - 'npm run dev', - ]); - expect(exitCode).toBe(0); - expect(stdout).toContain('process registered'); - expect(stdout).toContain(`pid=${process.pid}`); - expect(stdout).toContain('port=3000'); - - const procs = readProcesses(claudeDir) as Record[]; - expect(procs).toHaveLength(1); - expect(procs[0].pid).toBe(process.pid); - expect(procs[0].label).toBe('dev-server'); - expect(procs[0].port).toBe(3000); - expect(procs[0].url).toBe('http://localhost:3000'); - expect(procs[0].claudeProcessId).toBe('cp-123'); - expect(procs[0].registeredBy).toBe('bob'); - expect(procs[0].command).toBe('npm run dev'); - expect(String(procs[0].registeredAt)).toMatch(ISO_RE); - expect(String(procs[0].id)).toMatch(UUID_RE); - }); - - it('registers without port -> no port in stdout', () => { - const { stdout } = run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'worker', - ]); - expect(stdout).toContain('process registered'); - expect(stdout).not.toContain('port='); - }); - - it('re-registration with same PID preserves id and registeredAt', () => { - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'v1', - '--port', - '3000', - ]); - const procs1 = readProcesses(claudeDir) as Record[]; - const originalId = procs1[0].id; - const originalRegisteredAt = procs1[0].registeredAt; - - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'v2', - '--port', - '4000', - ]); - const procs2 = readProcesses(claudeDir) as Record[]; - expect(procs2).toHaveLength(1); - expect(procs2[0].id).toBe(originalId); - expect(procs2[0].registeredAt).toBe(originalRegisteredAt); - expect(procs2[0].label).toBe('v2'); - expect(procs2[0].port).toBe(4000); - }); - - it('lists processes with alive=true for current PID', () => { - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'dev-server', - ]); - const list = JSON.parse(run(claudeDir, ['process', 'list']).stdout) as Record< - string, - unknown - >[]; - expect(list).toHaveLength(1); - expect(list[0].alive).toBe(true); - }); - - it('lists dead process with alive=false', () => { - const deadPid = 2_147_483_647; - run(claudeDir, ['process', 'register', '--pid', String(deadPid), '--label', 'dead-proc']); - const list = JSON.parse(run(claudeDir, ['process', 'list']).stdout) as Record< - string, - unknown - >[]; - expect(list).toHaveLength(1); - expect(list[0].pid).toBe(deadPid); - expect(list[0].alive).toBe(false); - }); - - it('unregisters by --pid', () => { - run(claudeDir, ['process', 'register', '--pid', String(process.pid), '--label', 'dev']); - expect(run(claudeDir, ['process', 'unregister', '--pid', String(process.pid)]).exitCode).toBe( - 0 - ); - expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toHaveLength(0); - }); - - it('unregisters by --id (UUID)', () => { - run(claudeDir, ['process', 'register', '--pid', String(process.pid), '--label', 'dev']); - const procId = String((readProcesses(claudeDir) as Record[])[0].id); - expect(run(claudeDir, ['process', 'unregister', '--id', procId]).exitCode).toBe(0); - expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toHaveLength(0); - }); - - it('"remove" is alias for "unregister"', () => { - run(claudeDir, ['process', 'register', '--pid', String(process.pid), '--label', 'dev']); - expect(run(claudeDir, ['process', 'remove', '--pid', String(process.pid)]).exitCode).toBe(0); - expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toHaveLength(0); - }); - - it('unregister non-existent process fails', () => { - const { exitCode, stderr } = run(claudeDir, ['process', 'unregister', '--pid', '999999']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Process not found'); - }); - - it('unregister without --pid or --id fails', () => { - const { exitCode, stderr } = run(claudeDir, ['process', 'unregister']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --pid or --id'); - }); - - it('fails register without --pid', () => { - const { exitCode, stderr } = run(claudeDir, ['process', 'register', '--label', 'test']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Invalid --pid'); - }); - - it('fails register without --label', () => { - const { exitCode, stderr } = run(claudeDir, ['process', 'register', '--pid', '1234']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --label'); - }); - - it('ignores invalid port (out of range)', () => { - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'test', - '--port', - '0', - ]); - expect((readProcesses(claudeDir) as Record[])[0].port).toBeUndefined(); - }); - }); - - // ========================================================================= - // Highwatermark - // ========================================================================= - describe('highwatermark', () => { - it('respects highwatermark when task file is deleted', () => { - run(claudeDir, ['task', 'create', '--subject', 'Task 1']); - run(claudeDir, ['task', 'create', '--subject', 'Task 2']); - fs.unlinkSync(path.join(claudeDir, 'tasks', TEAM, '2.json')); - expect(JSON.parse(run(claudeDir, ['task', 'create', '--subject', 'Task 3']).stdout).id).toBe( - '3' - ); - }); - - it('handles manually set highwatermark higher than existing files', () => { - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '.highwatermark'), '100'); - expect( - JSON.parse(run(claudeDir, ['task', 'create', '--subject', 'After HWM']).stdout).id - ).toBe('101'); - }); - - it('handles missing highwatermark (uses max file ID)', () => { - writeTask(claudeDir, '5', { id: '5', subject: 'Task 5', status: 'pending' }); - expect(JSON.parse(run(claudeDir, ['task', 'create', '--subject', 'Next']).stdout).id).toBe( - '6' - ); - }); - }); - - // ========================================================================= - // Error handling - // ========================================================================= - describe('error handling', () => { - it('exits with error for unknown domain', () => { - const { exitCode, stderr } = run(claudeDir, ['foobar', 'something']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Unknown domain'); - }); - - it.each([ - ['task', 'foobar', 'Unknown task action'], - ['kanban', 'foobar', 'Unknown kanban action'], - ['review', 'foobar', 'Unknown review action'], - ['message', 'foobar', 'Unknown message action'], - ['process', 'foobar', 'Unknown process action'], - ])('exits with error for unknown %s action "%s"', (domain, action, expected) => { - const { exitCode, stderr } = run(claudeDir, [domain, action]); - expect(exitCode).not.toBe(0); - expect(stderr).toContain(expected); - }); - - it('missing --team flag fails', () => { - try { - execFileSync(process.execPath, [scriptPath, '--claude-dir', claudeDir, 'task', 'list'], { - encoding: 'utf8', - timeout: 10_000, - }); - expect.fail('Expected error'); - } catch (err: unknown) { - const e = err as { stderr?: string; status?: number }; - expect(e.status).not.toBe(0); - expect(e.stderr).toContain('Missing --team'); - } - }); - }); - - // ========================================================================= - // Special characters in arguments - // ========================================================================= - describe('special characters', () => { - it('handles unicode in subject and description', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Задача с юникодом', - '--description', - 'Описание', - ]); - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.subject).toBe('Задача с юникодом'); - expect(parsed.description).toBe('Описание'); - expect(readTask(claudeDir, '1').subject).toBe('Задача с юникодом'); - }); - - it('handles quotes and special shell chars in text', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'pending', owner: 'bob' }); - const specialText = 'He said "hello" & she said \'goodbye\' $HOME `backticks`'; - run(claudeDir, ['task', 'comment', '1', '--text', specialText, '--from', 'alice']); - expect((readTask(claudeDir, '1').comments as Record[])[0].text).toBe( - specialText - ); - }); - - it('handles multi-word subject with spaces', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'This is a long task subject with many words', - ]); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout).subject).toBe('This is a long task subject with many words'); - }); - - it('handles newlines in message text', () => { - const textWithNewlines = 'Line 1\nLine 2\nLine 3'; - run(claudeDir, [ - 'message', - 'send', - '--to', - 'bob', - '--text', - textWithNewlines, - '--from', - 'alice', - ]); - expect((readInbox(claudeDir, 'bob')[0] as Record).text).toBe( - textWithNewlines - ); - }); - }); - - // ========================================================================= - // Corrupted/malformed data - // ========================================================================= - describe('corrupted data', () => { - it('empty task JSON file causes error on get', () => { - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '1.json'), ''); - const { exitCode, stderr } = run(claudeDir, ['task', 'get', '1']); - expect(exitCode).not.toBe(0); - expect(stderr.length).toBeGreaterThan(0); - }); - - it('invalid JSON in task file causes error on get', () => { - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '1.json'), '{invalid!!!}'); - expect(run(claudeDir, ['task', 'get', '1']).exitCode).not.toBe(0); - }); - - it('truncated JSON causes error (partial write scenario)', () => { - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '1.json'), '{"id":"1","subj'); - expect(run(claudeDir, ['task', 'get', '1']).exitCode).not.toBe(0); - }); - - it('task list handles corrupted file without crashing', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'Good', status: 'pending' }); - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '2.json'), 'CORRUPTED'); - - const { stdout, exitCode } = run(claudeDir, ['task', 'list']); - expect(exitCode).toBe(0); - expect((JSON.parse(stdout) as unknown[]).length).toBeGreaterThanOrEqual(1); - }); - - it('briefing handles corrupted task files', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'Good task', status: 'pending', owner: 'bob' }); - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '2.json'), 'NOT_JSON'); - - const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(exitCode).toBe(0); - expect(stdout).toContain('Good task'); - }); - - it('missing kanban-state.json -> creates on first write', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'completed' }); - expect(run(claudeDir, ['kanban', 'set-column', '1', 'review']).exitCode).toBe(0); - expect(readKanban(claudeDir)).toBeDefined(); - }); - - it('missing processes.json -> empty list', () => { - expect(JSON.parse(run(claudeDir, ['process', 'list']).stdout)).toEqual([]); - }); - }); - - // ========================================================================= - // Concurrency - // ========================================================================= - describe('concurrency', () => { - it('parallel task creates all succeed without crashing', async () => { - const promises = Array.from({ length: 5 }, (_, i) => - runAsync(claudeDir, ['task', 'create', '--subject', `Parallel task ${i}`]) - ); - const results = await Promise.all(promises); - - for (const r of results) { - expect(r.exitCode).toBe(0); - const parsed = JSON.parse(r.stdout) as { id: string }; - expect(Number(parsed.id)).toBeGreaterThan(0); - } - - // Note: without inter-process file locking, parallel creates may produce - // duplicate IDs (known pre-existing limitation). We verify that all calls - // succeed and produce valid output — not uniqueness. - const allTasks = JSON.parse(run(claudeDir, ['task', 'list']).stdout) as unknown[]; - expect(allTasks.length).toBeGreaterThanOrEqual(1); - }); - - it('parallel comments on same task — no crash, valid structure', async () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Shared task', - status: 'in_progress', - owner: 'bob', - comments: [], - }); - - const promises = Array.from({ length: 5 }, (_, i) => - runAsync(claudeDir, [ - 'task', - 'comment', - '1', - '--text', - `Comment ${i}`, - '--from', - `agent-${i}`, - ]) - ); - const results = await Promise.all(promises); - - for (const r of results) { - expect(r.exitCode).toBe(0); - } - - // Due to read-modify-write race, not all 5 may persist. - // The important thing: no crash, no data corruption, valid JSON. - const task = readTask(claudeDir, '1'); - const comments = task.comments as Record[]; - expect(comments.length).toBeGreaterThanOrEqual(1); - for (const c of comments) { - expect(c.text).toBeDefined(); - expect(c.author).toBeDefined(); - expect(c.id).toBeDefined(); - } - }); - - it('parallel messages to same inbox — no crash', async () => { - const promises = Array.from({ length: 5 }, (_, i) => - runAsync(claudeDir, [ - 'message', - 'send', - '--to', - 'bob', - '--text', - `Msg ${i}`, - '--from', - `agent-${i}`, - ]) - ); - const results = await Promise.all(promises); - - for (const r of results) { - expect(r.exitCode).toBe(0); - } - - expect(readInbox(claudeDir, 'bob').length).toBeGreaterThanOrEqual(1); - }); - }); - - // ========================================================================= - // Critical nuances - // ========================================================================= - describe('critical nuances', () => { - // --- Numeric sort (not lexicographic) --- - it('task list sorts numerically: 1, 2, 10 (not 1, 10, 2)', () => { - writeTask(claudeDir, '10', { id: '10', subject: 'Ten', status: 'pending' }); - writeTask(claudeDir, '2', { id: '2', subject: 'Two', status: 'pending' }); - writeTask(claudeDir, '1', { id: '1', subject: 'One', status: 'pending' }); - const tasks = JSON.parse(run(claudeDir, ['task', 'list']).stdout) as { id: string }[]; - expect(tasks.map((t) => t.id)).toEqual(['1', '2', '10']); - }); - - // --- createTask rejects duplicate ID --- - it('task create dies if task file already exists at next ID', () => { - // Pre-create task #1 so getNextTaskId returns 1, but file exists - // Actually: getNextTaskId reads highwatermark + max file ID. - // So we need to trick it: set highwatermark to 0, have 1.json exist - writeTask(claudeDir, '1', { id: '1', subject: 'Existing', status: 'pending' }); - // Highwatermark not set → getNextTaskId uses max file ID (1) + 1 = 2 - // So this can't naturally trigger. Let's force: set HWM to 0, file 1 exists. - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '.highwatermark'), '0'); - // Now getNextTaskId: max(files)=1, max(hwm)=0, next=2. Still won't collide. - // This scenario is actually protected by the HWM logic. Good — confirms no dup. - const { stdout, exitCode } = run(claudeDir, ['task', 'create', '--subject', 'New']); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout).id).toBe('2'); - }); - - // --- parseArgs: flag followed by another flag --- - it('parseArgs: --from followed by --text sets from=true, not "--text"', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Task', - status: 'pending', - owner: 'bob', - }); - // --from is immediately followed by --text (which starts with -) - // So parseArgs should set from=true (boolean), text='Hello' - const { exitCode } = run(claudeDir, ['task', 'comment', '1', '--from', '--text', 'Hello']); - // from=true → not a string → defaults to 'agent' - expect(exitCode).toBe(0); - const comments = readTask(claudeDir, '1').comments as { author: string; text: string }[]; - expect(comments[0].author).toBe('alice'); // not "--text" - expect(comments[0].text).toBe('Hello'); - }); - - // --- reviewApprove without --notify-owner creates NO inbox but DOES record comment --- - it('review approve without --notify-owner does NOT create inbox but records comment', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Feature', - status: 'completed', - owner: 'bob', - }); - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - run(claudeDir, ['review', 'approve', '1']); // no --notify-owner - expect(readInbox(claudeDir, 'bob')).toEqual([]); - // Comment is still recorded - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].type).toBe('review_approved'); - }); - - // --- request-changes: verify ALL four side effects --- - it('review request-changes: kanban cleared + status in_progress + comment recorded + inbox sent', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'PR', - status: 'completed', - owner: 'bob', - }); - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - run(claudeDir, [ - 'review', - 'request-changes', - '1', - '--comment', - 'Missing tests', - '--from', - 'alice', - ]); - // 1) Kanban cleared - expect((readKanban(claudeDir).tasks as Record)['1']).toBeUndefined(); - // 2) Status changed to in_progress - expect(readTask(claudeDir, '1').status).toBe('in_progress'); - // 3) Review comment recorded - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].type).toBe('review_request'); - expect(comments[0].author).toBe('alice'); - expect(comments[0].text).toBe('Missing tests'); - // 4) Inbox message sent - const inbox = readInbox(claudeDir, 'bob') as Record[]; - expect(inbox).toHaveLength(1); - expect(inbox[0].from).toBe('alice'); - expect(String(inbox[0].text)).toContain('Missing tests'); - expect(String(inbox[0].text)).toContain('Please fix'); - }); - - // --- Alternative flag names: --teamName --- - it('accepts --teamName as alternative to --team', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'Alt flag', status: 'pending' }); - try { - const stdout = execFileSync( - process.execPath, - [scriptPath, '--claude-dir', claudeDir, '--teamName', TEAM, 'task', 'get', '1'], - { encoding: 'utf8', timeout: 10_000 } - ); - expect(JSON.parse(stdout).subject).toBe('Alt flag'); - } catch { - expect.fail('--teamName flag should be accepted'); - } - }); - - // --- Alternative flag: --claudeDir --- - it('accepts --claudeDir as alternative to --claude-dir', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'claudeDir alt', status: 'pending' }); - try { - const stdout = execFileSync( - process.execPath, - [scriptPath, '--claudeDir', claudeDir, '--team', TEAM, 'task', 'get', '1'], - { encoding: 'utf8', timeout: 10_000 } - ); - expect(JSON.parse(stdout).subject).toBe('claudeDir alt'); - } catch { - expect.fail('--claudeDir flag should be accepted'); - } - }); - - // --- Inbox isolation between members --- - it('messages to different members are isolated', () => { - run(claudeDir, ['message', 'send', '--to', 'alice', '--text', 'For Alice', '--from', 'bob']); - run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'For Bob', '--from', 'alice']); - const aliceInbox = readInbox(claudeDir, 'alice') as Record[]; - const bobInbox = readInbox(claudeDir, 'bob') as Record[]; - expect(aliceInbox).toHaveLength(1); - expect(bobInbox).toHaveLength(1); - expect(aliceInbox[0].text).toBe('For Alice'); - expect(bobInbox[0].text).toBe('For Bob'); - }); - - // --- Empty string arguments rejected --- - it('task create with empty --subject fails', () => { - const { exitCode, stderr } = run(claudeDir, ['task', 'create', '--subject', '']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --subject'); - }); - - it('task comment with empty --text fails', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'T', status: 'pending' }); - const { exitCode, stderr } = run(claudeDir, ['task', 'comment', '1', '--text', '']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --text'); - }); - - it('message send with empty --text fails', () => { - const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--to', 'bob', '--text', '']); - expect(exitCode).not.toBe(0); - expect(stderr).toContain('Missing --text'); - }); - - // --- Invalid explicit status + owner → falls back to in_progress --- - it('task create with invalid --status and --owner defaults to in_progress', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Fallback status', - '--owner', - 'bob', - '--status', - 'bogus', - ]); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout).status).toBe('in_progress'); - }); - - it('task create with invalid --status and NO owner defaults to pending', () => { - const { stdout, exitCode } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Fallback no owner', - '--status', - 'bogus', - ]); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout).status).toBe('pending'); - }); - - // --- writeTask verification: stdout matches disk --- - it('task create stdout is byte-identical to file on disk', () => { - const { stdout } = run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Verify sync', - '--description', - 'Detailed desc', - '--owner', - 'bob', - '--from', - 'alice', - '--active-form', - 'Verifying sync', - ]); - const fromStdout = JSON.parse(stdout); - const fromDisk = readTask(claudeDir, fromStdout.id); - expect(fromDisk.subject).toBe(fromStdout.subject); - expect(fromDisk.description).toBe(fromStdout.description); - expect(fromDisk.owner).toBe(fromStdout.owner); - expect(fromDisk.createdBy).toBe(fromStdout.createdBy); - expect(fromDisk.activeForm).toBe(fromStdout.activeForm); - expect(fromDisk.status).toBe(fromStdout.status); - expect(fromDisk.blocks).toEqual(fromStdout.blocks); - expect(fromDisk.blockedBy).toEqual(fromStdout.blockedBy); - }); - - // --- Comment inbox notification: exact format verification --- - it('comment inbox notification has correct format', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Auth module', - status: 'in_progress', - owner: 'bob', - }); - run(claudeDir, [ - 'task', - 'comment', - '1', - '--text', - 'Please add error handling', - '--from', - 'alice', - ]); - const inbox = readInbox(claudeDir, 'bob') as Record[]; - expect(inbox).toHaveLength(1); - expect(inbox[0].from).toBe('alice'); - expect(String(inbox[0].text)).toContain('Comment on task #1'); - expect(String(inbox[0].text)).toContain('Auth module'); - expect(String(inbox[0].text)).toContain('Please add error handling'); - expect(String(inbox[0].summary)).toContain('#1'); - expect(inbox[0].read).toBe(false); - expect(String(inbox[0].timestamp)).toMatch(ISO_RE); - expect(String(inbox[0].messageId)).toMatch(UUID_RE); - }); - - // --- Comment on task without owner: no crash, no inbox --- - it('comment on task without owner does not crash and sends no inbox', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'Orphan', status: 'pending' }); - const { exitCode } = run(claudeDir, [ - 'task', - 'comment', - '1', - '--text', - 'Note to self', - '--from', - 'alice', - ]); - expect(exitCode).toBe(0); - expect((readTask(claudeDir, '1').comments as unknown[]).length).toBe(1); - // No inboxes dir should exist - const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes'); - try { - expect(fs.readdirSync(inboxDir)).toHaveLength(0); - } catch { - // dir doesn't exist — correct - } - }); - - // --- Briefing kanban override: completed task in review column --- - it('briefing shows kanban column override, not raw status', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'In review task', - status: 'completed', - owner: 'bob', - }); - // Kanban says review, status says completed — briefing should say REVIEW - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - const yourSection = stdout.slice(stdout.indexOf('YOUR TASKS')); - expect(yourSection).toContain('[REVIEW]'); - expect(yourSection).not.toContain('[DONE]'); - }); - - // --- Port boundaries --- - it('process register with port=1 (min valid)', () => { - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'min-port', - '--port', - '1', - ]); - expect((readProcesses(claudeDir) as Record[])[0].port).toBe(1); - }); - - it('process register with port=65535 (max valid)', () => { - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'max-port', - '--port', - '65535', - ]); - expect((readProcesses(claudeDir) as Record[])[0].port).toBe(65535); - }); - - it('process register with port=65536 (over max) -> ignored', () => { - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'over-port', - '--port', - '65536', - ]); - expect((readProcesses(claudeDir) as Record[])[0].port).toBeUndefined(); - }); - - // --- set-status idempotent --- - it('set-status to same value is idempotent', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'in_progress' }); - expect(run(claudeDir, ['task', 'set-status', '1', 'in_progress']).exitCode).toBe(0); - expect(readTask(claudeDir, '1').status).toBe('in_progress'); - }); - - // --- Corrupted highwatermark --- - it('corrupted highwatermark (non-numeric) falls back to max file ID', () => { - writeTask(claudeDir, '3', { id: '3', subject: 'Three', status: 'pending' }); - fs.writeFileSync(path.join(claudeDir, 'tasks', TEAM, '.highwatermark'), 'garbage'); - // readJson parses "garbage" → JSON.parse throws. readJson only catches ENOENT. - // This SHOULD crash the script on task create. Let's verify behavior. - const result = run(claudeDir, ['task', 'create', '--subject', 'After garbage HWM']); - // If script handles it gracefully, ID should be > 3. If it crashes, exitCode != 0. - if (result.exitCode === 0) { - expect(Number(JSON.parse(result.stdout).id)).toBeGreaterThan(3); - } else { - // Script crashes on invalid JSON in .highwatermark — this IS a bug. - // Document the behavior: corrupted HWM causes task create to fail. - expect(result.stderr.length).toBeGreaterThan(0); - } - }); - - // --- Briefing: effective column logic per status --- - it('briefing: pending→TODO, in_progress→IN PROGRESS, completed→DONE columns', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Pending task', - status: 'pending', - owner: 'bob', - }); - writeTask(claudeDir, '2', { - id: '2', - subject: 'Active task', - status: 'in_progress', - owner: 'bob', - }); - writeTask(claudeDir, '3', { - id: '3', - subject: 'Done task', - status: 'completed', - owner: 'bob', - }); - - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(stdout).toContain('#1 [TODO]'); - expect(stdout).toContain('#2 [IN_PROGRESS]'); - expect(stdout).toContain('#3 [DONE]'); - }); - - // --- Comment self-notification: owner comments on own task --- - it('comment by task owner does NOT self-notify', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'My task', - status: 'in_progress', - owner: 'bob', - }); - run(claudeDir, ['task', 'comment', '1', '--text', 'Progress note', '--from', 'bob']); - expect(readInbox(claudeDir, 'bob')).toEqual([]); - }); - - // --- Kanban set-column updates movedAt on re-set --- - it('kanban set-column updates movedAt timestamp on re-assignment', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'T', status: 'completed' }); - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - const movedAt1 = (readKanban(claudeDir).tasks as Record>)['1'] - .movedAt; - - // Small delay to ensure different timestamp - const start = Date.now(); - while (Date.now() - start < 5); - - run(claudeDir, ['kanban', 'set-column', '1', 'approved']); - const movedAt2 = (readKanban(claudeDir).tasks as Record>)['1'] - .movedAt; - - expect(movedAt1).not.toBe(movedAt2); - expect(String(movedAt2)).toMatch(ISO_RE); - }); - - // --- Task create notification: AGENT_BLOCK markers --- - it('task create notification contains AGENT_BLOCK markers and tool instructions', () => { - run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Build feature', - '--owner', - 'bob', - '--notify', - '--from', - 'alice', - ]); - const inbox = readInbox(claudeDir, 'bob') as Record[]; - const text = String(inbox[0].text); - // Must contain agent block markers ( ... ) - expect(text).toContain('info_for_agent'); - expect(text).toContain('task start'); - expect(text).toContain('task complete'); - }); - - // --- readKanbanState fallback with corrupted kanban --- - it('kanban set-column works even with corrupted kanban-state.json', () => { - writeTask(claudeDir, '1', { id: '1', subject: 'T', status: 'completed' }); - const kanbanPath = path.join(claudeDir, 'teams', TEAM, 'kanban-state.json'); - fs.writeFileSync(kanbanPath, '{corrupted!!!}'); - // readKanbanState: readJson will throw (not ENOENT) → script crashes - const result = run(claudeDir, ['kanban', 'set-column', '1', 'review']); - if (result.exitCode === 0) { - expect( - (readKanban(claudeDir).tasks as Record>)['1'].column - ).toBe('review'); - } else { - // Documents that corrupted kanban-state.json crashes kanban operations - expect(result.stderr.length).toBeGreaterThan(0); - } - }); - - // --- Multiple processes in same team --- - it('multiple processes coexist independently', () => { - run(claudeDir, [ - 'process', - 'register', - '--pid', - String(process.pid), - '--label', - 'server-1', - '--port', - '3000', - ]); - run(claudeDir, [ - 'process', - 'register', - '--pid', - '99998', - '--label', - 'server-2', - '--port', - '3001', - ]); - const procs = readProcesses(claudeDir) as Record[]; - expect(procs).toHaveLength(2); - expect(procs.map((p) => p.label)).toEqual(['server-1', 'server-2']); - // Unregister one, other stays - run(claudeDir, ['process', 'unregister', '--pid', '99998']); - const after = readProcesses(claudeDir) as Record[]; - expect(after).toHaveLength(1); - expect(after[0].label).toBe('server-1'); - }); - - // --- review approve also writes to kanban (column=approved) + comment --- - it('review approve sets kanban column to approved with movedAt and records comment', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'PR task', - status: 'completed', - owner: 'bob', - }); - run(claudeDir, ['kanban', 'set-column', '1', 'review']); - run(claudeDir, ['review', 'approve', '1']); - const entry = (readKanban(claudeDir).tasks as Record>)['1']; - expect(entry.column).toBe('approved'); - expect(String(entry.movedAt)).toMatch(ISO_RE); - // Review comment recorded - const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments).toHaveLength(1); - expect(comments[0].type).toBe('review_approved'); - }); - - // --- Task create without --description defaults to subject --- - it('task description defaults to subject when omitted', () => { - run(claudeDir, ['task', 'create', '--subject', 'Self-describing task']); - expect(readTask(claudeDir, '1').description).toBe('Self-describing task'); - }); - - // --- Task create with --description different from subject --- - it('task description stored separately from subject when provided', () => { - run(claudeDir, [ - 'task', - 'create', - '--subject', - 'Short title', - '--description', - 'A much longer and more detailed description of the work', - ]); - const task = readTask(claudeDir, '1'); - expect(task.subject).toBe('Short title'); - expect(task.description).toBe('A much longer and more detailed description of the work'); - }); - - // --- Briefing: description != subject shown, description == subject hidden --- - it('briefing hides description when identical to subject', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Same as desc', - description: 'Same as desc', - status: 'in_progress', - owner: 'bob', - }); - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(stdout).toContain('Same as desc'); - expect(stdout).not.toContain('Description:'); - }); - - it('briefing shows description when different from subject', () => { - writeTask(claudeDir, '1', { - id: '1', - subject: 'Title', - description: 'A detailed description that differs from the title', - status: 'in_progress', - owner: 'bob', - }); - const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); - expect(stdout).toContain('Description:'); - expect(stdout).toContain('A detailed description that differs from the title'); - }); - - // --- Inbox corrupted: sendInboxMessage with corrupted existing inbox --- - it('message send to inbox with non-array content recovers', () => { - // Pre-write corrupted inbox (object instead of array) - const inboxDir = path.join(claudeDir, 'teams', TEAM, 'inboxes'); - fs.mkdirSync(inboxDir, { recursive: true }); - fs.writeFileSync(path.join(inboxDir, 'bob.json'), '{"not": "an array"}'); - - const { exitCode } = run(claudeDir, [ - 'message', - 'send', - '--to', - 'bob', - '--text', - 'Recovery msg', - '--from', - 'alice', - ]); - expect(exitCode).toBe(0); - // readJson returns the object, `Array.isArray` fails → uses empty list. - // So message is the only item. - const inbox = readInbox(claudeDir, 'bob') as Record[]; - expect(inbox.length).toBe(1); - expect(inbox[0].text).toBe('Recovery msg'); - }); - }); - - // ========================================================================= - // Edge cases - // ========================================================================= - describe('edge cases', () => { - it('empty tasks dir -> empty list', () => { - expect(JSON.parse(run(claudeDir, ['task', 'list']).stdout)).toEqual([]); - }); - - it('missing tasks dir -> empty list', () => { - fs.rmSync(path.join(claudeDir, 'tasks', TEAM), { recursive: true }); - expect(JSON.parse(run(claudeDir, ['task', 'list']).stdout)).toEqual([]); - }); - - it('missing tasks dir -> briefing still works', () => { - fs.rmSync(path.join(claudeDir, 'tasks', TEAM), { recursive: true }); - const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'alice']); - expect(exitCode).toBe(0); - expect(stdout).toContain('no tasks assigned to you'); - }); - - it('task create auto-creates tasks directory', () => { - fs.rmSync(path.join(claudeDir, 'tasks', TEAM), { recursive: true }); - expect(run(claudeDir, ['task', 'create', '--subject', 'Auto-dir']).exitCode).toBe(0); - expect(readTask(claudeDir, '1').subject).toBe('Auto-dir'); - }); - - it('lead name inference falls back to first member when no lead role', () => { - fs.writeFileSync( - path.join(claudeDir, 'teams', TEAM, 'config.json'), - JSON.stringify({ name: TEAM, members: [{ name: 'charlie' }, { name: 'diana' }] }) - ); - run(claudeDir, ['message', 'send', '--to', 'diana', '--text', 'Hi']); - expect((readInbox(claudeDir, 'diana')[0] as Record).from).toBe('charlie'); - }); - - it('lead name inference falls back to "team-lead" with empty members', () => { - fs.writeFileSync( - path.join(claudeDir, 'teams', TEAM, 'config.json'), - JSON.stringify({ name: TEAM, members: [] }) - ); - run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']); - expect((readInbox(claudeDir, 'bob')[0] as Record).from).toBe('team-lead'); - }); - - it('lead name inference falls back to "team-lead" with missing config', () => { - fs.unlinkSync(path.join(claudeDir, 'teams', TEAM, 'config.json')); - run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']); - expect((readInbox(claudeDir, 'bob')[0] as Record).from).toBe('team-lead'); - }); - }); -});