From 4cf330e8cc143d56988bde041f651b75ea490404 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 16:01:32 +0200 Subject: [PATCH] feat: integrate agent-teams-controller and enhance task management - Added agent-teams-controller as a dependency and updated the bundling configuration to exclude it. - Refactored task management functions to utilize the new taskStore, improving code organization and maintainability. - Introduced new methods for handling task states, including soft deletion and restoration. - Enhanced kanban functionality with improved reviewer management and task column updates. - Updated tests to reflect changes in task creation and approval processes, ensuring robust coverage. - Improved task ID handling and display logic for better user experience across components. --- agent-teams-controller/src/cli.js | 0 agent-teams-controller/src/internal/kanban.js | 21 +- .../src/internal/kanbanStore.js | 112 +++ .../src/internal/processStore.js | 159 +++++ .../src/internal/processes.js | 20 +- agent-teams-controller/src/internal/review.js | 63 +- .../src/internal/taskStore.js | 651 ++++++++++++++++++ agent-teams-controller/src/internal/tasks.js | 129 ++-- .../test/controller.test.js | 25 +- electron.vite.config.ts | 2 +- mcp-server/src/agent-teams-controller.d.ts | 9 + mcp-server/src/controller.ts | 4 +- mcp-server/src/tools/processTools.ts | 11 + mcp-server/test/tools.test.ts | 18 +- package.json | 1 + pnpm-lock.yaml | 3 + src/main/ipc/guards.ts | 6 +- src/main/services/team/TeamDataService.ts | 353 ++++------ .../services/team/TeamProvisioningService.ts | 26 +- src/main/services/team/TeamTaskReader.ts | 33 +- src/main/workers/team-fs-worker.ts | 18 + .../components/sidebar/GlobalTaskList.tsx | 3 +- src/renderer/components/team/TaskTooltip.tsx | 20 +- .../components/team/TeamDetailView.tsx | 20 +- .../team/activity/ActiveTasksBlock.tsx | 5 +- .../components/team/activity/ActivityItem.tsx | 19 +- .../team/dialogs/CreateTaskDialog.tsx | 16 +- .../components/team/dialogs/ReviewDialog.tsx | 3 +- .../team/dialogs/TaskCommentsSection.tsx | 4 +- .../team/dialogs/TaskDetailDialog.tsx | 37 +- .../components/team/kanban/KanbanTaskCard.tsx | 11 +- .../components/team/kanban/TrashDialog.tsx | 5 +- .../team/members/MemberTasksTab.tsx | 3 +- .../components/team/tasks/TaskRow.tsx | 11 +- src/renderer/store/slices/teamSlice.ts | 8 +- src/shared/types/team.ts | 2 + src/shared/utils/taskIdentity.ts | 32 + src/types/agent-teams-controller.d.ts | 66 ++ test/main/ipc/teams.test.ts | 2 +- .../services/team/TeamDataService.test.ts | 46 +- .../TeamProvisioningServicePrompts.test.ts | 4 +- 41 files changed, 1575 insertions(+), 406 deletions(-) mode change 100644 => 100755 agent-teams-controller/src/cli.js create mode 100644 agent-teams-controller/src/internal/kanbanStore.js create mode 100644 agent-teams-controller/src/internal/processStore.js create mode 100644 agent-teams-controller/src/internal/taskStore.js create mode 100644 src/shared/utils/taskIdentity.ts create mode 100644 src/types/agent-teams-controller.d.ts diff --git a/agent-teams-controller/src/cli.js b/agent-teams-controller/src/cli.js old mode 100644 new mode 100755 diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js index 073b3a73..c38744d8 100644 --- a/agent-teams-controller/src/internal/kanban.js +++ b/agent-teams-controller/src/internal/kanban.js @@ -1,16 +1,19 @@ -const legacy = require('../legacy/teamctl.cli.js'); +const kanbanStore = require('./kanbanStore.js'); +const tasks = require('./tasks.js'); function getKanbanState(context) { - return legacy.readKanbanState(context.paths, context.teamName); + return kanbanStore.readKanbanState(context.paths, context.teamName); } function setKanbanColumn(context, taskId, column) { - legacy.setKanbanColumn(context.paths, context.teamName, String(taskId), String(column)); + const canonicalTaskId = tasks.resolveTaskId(context, taskId); + kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column)); return getKanbanState(context); } function clearKanban(context, taskId) { - legacy.clearKanban(context.paths, context.teamName, String(taskId)); + const canonicalTaskId = tasks.resolveTaskId(context, taskId); + kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId); return getKanbanState(context); } @@ -22,7 +25,7 @@ function addReviewer(context, reviewer) { const state = getKanbanState(context); const next = new Set(state.reviewers); next.add(String(reviewer)); - legacy.writeKanbanState(context.paths, { + kanbanStore.writeKanbanState(context.paths, context.teamName, { ...state, reviewers: [...next], }); @@ -32,13 +35,18 @@ function addReviewer(context, reviewer) { function removeReviewer(context, reviewer) { const state = getKanbanState(context); const next = state.reviewers.filter((entry) => entry !== reviewer); - legacy.writeKanbanState(context.paths, { + kanbanStore.writeKanbanState(context.paths, context.teamName, { ...state, reviewers: next, }); return listReviewers(context); } +function updateColumnOrder(context, columnId, orderedTaskIds) { + const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId)); + return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds); +} + module.exports = { getKanbanState, setKanbanColumn, @@ -46,4 +54,5 @@ module.exports = { listReviewers, addReviewer, removeReviewer, + updateColumnOrder, }; diff --git a/agent-teams-controller/src/internal/kanbanStore.js b/agent-teams-controller/src/internal/kanbanStore.js new file mode 100644 index 00000000..69de480f --- /dev/null +++ b/agent-teams-controller/src/internal/kanbanStore.js @@ -0,0 +1,112 @@ +const fs = require('fs'); +const path = require('path'); + +function nowIso() { + return new Date().toISOString(); +} + +function readJson(filePath, fallbackValue) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return fallbackValue; + } +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); + fs.renameSync(tempPath, filePath); +} + +function getDefaultState(teamName) { + return { + teamName, + reviewers: [], + tasks: {}, + }; +} + +function sanitizeState(teamName, rawState) { + const state = rawState && typeof rawState === 'object' ? rawState : {}; + const tasks = {}; + if (state.tasks && typeof state.tasks === 'object') { + for (const [taskId, entry] of Object.entries(state.tasks)) { + if (!entry || typeof entry !== 'object') continue; + if (entry.column !== 'review' && entry.column !== 'approved') continue; + if (typeof entry.movedAt !== 'string') continue; + tasks[String(taskId)] = { + column: entry.column, + movedAt: entry.movedAt, + ...(entry.reviewer === null || typeof entry.reviewer === 'string' + ? { reviewer: entry.reviewer } + : {}), + ...(typeof entry.errorDescription === 'string' + ? { errorDescription: entry.errorDescription } + : {}), + }; + } + } + + return { + teamName, + reviewers: Array.isArray(state.reviewers) + ? state.reviewers.filter((entry) => typeof entry === 'string' && entry.trim()) + : [], + tasks, + ...(state.columnOrder && typeof state.columnOrder === 'object' + ? { columnOrder: state.columnOrder } + : {}), + }; +} + +function readKanbanState(paths, teamName) { + return sanitizeState(teamName, readJson(paths.kanbanPath, getDefaultState(teamName))); +} + +function writeKanbanState(paths, teamName, state) { + writeJson(paths.kanbanPath, sanitizeState(teamName, state)); +} + +function setKanbanColumn(paths, teamName, taskId, column) { + if (column !== 'review' && column !== 'approved') { + throw new Error(`Invalid kanban column: ${String(column)}`); + } + + const state = readKanbanState(paths, teamName); + state.tasks[String(taskId)] = + column === 'review' + ? { column: 'review', reviewer: null, movedAt: nowIso() } + : { column: 'approved', movedAt: nowIso() }; + writeKanbanState(paths, teamName, state); + return state; +} + +function clearKanban(paths, teamName, taskId) { + const state = readKanbanState(paths, teamName); + delete state.tasks[String(taskId)]; + writeKanbanState(paths, teamName, state); + return state; +} + +function updateColumnOrder(paths, teamName, columnId, orderedTaskIds) { + const state = readKanbanState(paths, teamName); + const nextColumnOrder = { ...(state.columnOrder || {}) }; + if (Array.isArray(orderedTaskIds) && orderedTaskIds.length > 0) { + nextColumnOrder[columnId] = orderedTaskIds.map((entry) => String(entry)); + } else { + delete nextColumnOrder[columnId]; + } + state.columnOrder = Object.keys(nextColumnOrder).length > 0 ? nextColumnOrder : undefined; + writeKanbanState(paths, teamName, state); + return state; +} + +module.exports = { + clearKanban, + readKanbanState, + setKanbanColumn, + updateColumnOrder, + writeKanbanState, +}; diff --git a/agent-teams-controller/src/internal/processStore.js b/agent-teams-controller/src/internal/processStore.js new file mode 100644 index 00000000..2455bf12 --- /dev/null +++ b/agent-teams-controller/src/internal/processStore.js @@ -0,0 +1,159 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const legacy = require('../legacy/teamctl.cli.js'); + +function nowIso() { + return new Date().toISOString(); +} + +function readJson(filePath, fallbackValue) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return fallbackValue; + } +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); + fs.renameSync(tempPath, filePath); +} + +function readProcesses(paths) { + const rows = readJson(paths.processesPath, []); + if (!Array.isArray(rows)) return []; + return rows.filter((entry) => entry && typeof entry === 'object' && Number.isInteger(entry.pid)); +} + +function writeProcesses(paths, processes) { + writeJson(paths.processesPath, processes); +} + +function listProcesses(paths) { + const existing = readProcesses(paths); + const processes = existing.map((entry) => { + const alive = + !entry.stoppedAt && + Number.isFinite(Number(entry.pid)) && + legacy.isProcessAlive(Number(entry.pid)); + + if (!alive && !entry.stoppedAt) { + return { + ...entry, + stoppedAt: nowIso(), + alive: false, + }; + } + + return { + ...entry, + alive, + }; + }); + + const changed = processes.some((entry, index) => entry.stoppedAt !== existing[index]?.stoppedAt); + if (changed) { + writeProcesses( + paths, + processes.map(({ alive, ...rest }) => rest) + ); + } + + return processes; +} + +function registerProcess(paths, flags) { + const pid = Number(flags.pid); + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error('Invalid pid'); + } + + const label = typeof flags.label === 'string' && flags.label.trim() ? flags.label.trim() : ''; + if (!label) { + throw new Error('Missing label'); + } + + const list = readProcesses(paths); + const existingActiveIndex = list.findIndex((entry) => entry.pid === pid && !entry.stoppedAt); + const existingActive = existingActiveIndex >= 0 ? list[existingActiveIndex] : null; + const now = nowIso(); + const entry = { + id: existingActive ? existingActive.id : crypto.randomUUID(), + label, + pid, + ...(flags.port != null ? { port: Number(flags.port) } : {}), + ...(typeof flags.url === 'string' && flags.url.trim() ? { url: flags.url.trim() } : {}), + ...(typeof flags['claude-process-id'] === 'string' && flags['claude-process-id'].trim() + ? { claudeProcessId: flags['claude-process-id'].trim() } + : {}), + ...(typeof flags.from === 'string' && flags.from.trim() ? { registeredBy: flags.from.trim() } : {}), + ...(typeof flags.command === 'string' && flags.command.trim() + ? { command: flags.command.trim() } + : {}), + registeredAt: existingActive ? existingActive.registeredAt : now, + }; + + if (existingActiveIndex >= 0) { + list[existingActiveIndex] = entry; + } else { + list.push(entry); + } + + writeProcesses(paths, list); + return entry; +} + +function stopProcess(paths, flags) { + const pid = flags.pid != null ? Number(flags.pid) : null; + const id = + typeof flags.id === 'string' && flags.id.trim().length > 0 ? flags.id.trim() : null; + if (!pid && !id) { + throw new Error('Missing pid or id'); + } + + const list = readProcesses(paths); + const index = list.findIndex((entry) => { + if (pid) return entry.pid === pid && !entry.stoppedAt; + return entry.id === id && !entry.stoppedAt; + }); + if (index < 0) { + throw new Error('Process not found'); + } + + list[index] = { + ...list[index], + stoppedAt: list[index].stoppedAt || nowIso(), + }; + writeProcesses(paths, list); + return list[index]; +} + +function unregisterProcess(paths, flags) { + const pid = flags.pid != null ? Number(flags.pid) : null; + const id = + typeof flags.id === 'string' && flags.id.trim().length > 0 ? flags.id.trim() : null; + if (!pid && !id) { + throw new Error('Missing pid or id'); + } + + const list = readProcesses(paths); + const next = list.filter((entry) => { + if (pid) return entry.pid !== pid; + return entry.id !== id; + }); + writeProcesses(paths, next); + return next; +} + +module.exports = { + listProcesses, + readProcesses, + registerProcess, + stopProcess, + unregisterProcess, + writeProcesses, +}; diff --git a/agent-teams-controller/src/internal/processes.js b/agent-teams-controller/src/internal/processes.js index c33d4611..8cbd7331 100644 --- a/agent-teams-controller/src/internal/processes.js +++ b/agent-teams-controller/src/internal/processes.js @@ -1,27 +1,25 @@ -const legacy = require('../legacy/teamctl.cli.js'); -const { captureStreamOutput } = require('./capture.js'); +const processStore = require('./processStore.js'); function registerProcess(context, flags) { - captureStreamOutput(process.stdout, () => legacy.processRegister(context.paths, flags)); - return listProcesses(context).find((entry) => entry.pid === Number(flags.pid)) || null; + return processStore.registerProcess(context.paths, flags); } function unregisterProcess(context, flags) { - captureStreamOutput(process.stdout, () => legacy.processUnregister(context.paths, flags)); + processStore.unregisterProcess(context.paths, flags); return listProcesses(context); } function listProcesses(context) { - return legacy.readProcessesSafe(context.paths.processesPath).map((entry) => ({ - ...entry, - alive: Number.isFinite(Number(entry && entry.pid)) - ? legacy.isProcessAlive(Number(entry.pid)) - : false, - })); + return processStore.listProcesses(context.paths); +} + +function stopProcess(context, flags) { + return processStore.stopProcess(context.paths, flags); } module.exports = { registerProcess, + stopProcess, unregisterProcess, listProcesses, }; diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index dce440b9..e3875589 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -1,14 +1,67 @@ -const legacy = require('../legacy/teamctl.cli.js'); +const kanban = require('./kanban.js'); +const messages = require('./messages.js'); const tasks = require('./tasks.js'); function approveReview(context, taskId, flags = {}) { - legacy.reviewApprove(context.paths, context.teamName, String(taskId), flags); - return tasks.getTask(context, taskId); + const task = tasks.getTask(context, taskId); + const from = + typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; + const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved'; + + kanban.setKanbanColumn(context, task.id, 'approved'); + tasks.addTaskComment(context, task.id, { + text: note, + from, + type: 'review_approved', + }); + + if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) { + messages.sendMessage(context, { + to: task.owner, + from, + text: + note && note !== 'Approved' + ? `Task ${task.displayId || task.id} approved.\n\n${note}` + : `Task ${task.displayId || task.id} approved.`, + summary: `Approved ${task.displayId || task.id}`, + source: 'system_notification', + }); + } + + return tasks.getTask(context, task.id); } function requestChanges(context, taskId, flags = {}) { - legacy.reviewRequestChanges(context.paths, context.teamName, String(taskId), flags); - return tasks.getTask(context, taskId); + const task = tasks.getTask(context, taskId); + if (!task.owner) { + throw new Error(`No owner found for task ${String(taskId)}`); + } + + const from = + typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; + const comment = + typeof flags.comment === 'string' && flags.comment.trim() + ? flags.comment.trim() + : 'Reviewer requested changes.'; + + kanban.clearKanban(context, task.id); + tasks.setTaskStatus(context, task.id, 'in_progress', from); + tasks.addTaskComment(context, task.id, { + text: comment, + from, + type: 'review_request', + }); + messages.sendMessage(context, { + to: task.owner, + from, + text: + `Task ${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` + + 'Please fix and mark it as completed when ready.', + summary: `Fix request for ${task.displayId || task.id}`, + source: 'system_notification', + }); + + return tasks.getTask(context, task.id); } module.exports = { diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js new file mode 100644 index 00000000..af472723 --- /dev/null +++ b/agent-teams-controller/src/internal/taskStore.js @@ -0,0 +1,651 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']); +const UUID_TASK_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function nowIso() { + return new Date().toISOString(); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function readJson(filePath, fallbackValue) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return fallbackValue; + } +} + +function writeJson(filePath, value) { + ensureDir(path.dirname(filePath)); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); + fs.renameSync(tempPath, filePath); +} + +function getTaskPath(paths, taskId) { + return path.join(paths.tasksDir, `${String(taskId)}.json`); +} + +function looksLikeCanonicalTaskId(taskId) { + return UUID_TASK_ID_PATTERN.test(String(taskId || '').trim()); +} + +function deriveDisplayId(taskId) { + const normalized = String(taskId || '').trim(); + if (!normalized) return normalized; + return looksLikeCanonicalTaskId(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized; +} + +function normalizeTask(rawTask, filePath) { + if (!rawTask || typeof rawTask !== 'object') { + throw new Error(`Invalid task payload${filePath ? `: ${filePath}` : ''}`); + } + + const id = + typeof rawTask.id === 'string' || typeof rawTask.id === 'number' ? String(rawTask.id) : ''; + if (!id) { + throw new Error(`Task is missing id${filePath ? `: ${filePath}` : ''}`); + } + + const task = { + ...rawTask, + id, + displayId: + typeof rawTask.displayId === 'string' && rawTask.displayId.trim() + ? rawTask.displayId.trim() + : deriveDisplayId(id), + }; + + return task; +} + +function listRawTasks(paths) { + ensureDir(paths.tasksDir); + const entries = fs.readdirSync(paths.tasksDir); + const out = []; + + for (const fileName of entries) { + if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue; + const filePath = path.join(paths.tasksDir, fileName); + const rawTask = readJson(filePath, null); + if (!rawTask) continue; + if (rawTask.metadata && rawTask.metadata._internal === true) continue; + try { + out.push(normalizeTask(rawTask, filePath)); + } catch { + // Skip unreadable task rows. + } + } + + out.sort((a, b) => { + const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { + numeric: true, + sensitivity: 'base', + }); + if (byDisplay !== 0) return byDisplay; + return String(a.id).localeCompare(String(b.id), undefined, { + numeric: true, + sensitivity: 'base', + }); + }); + + return out; +} + +function listTasks(paths, options = {}) { + const includeDeleted = options.includeDeleted === true; + return listRawTasks(paths).filter((task) => includeDeleted || task.status !== 'deleted'); +} + +function resolveTaskRef(paths, taskRef, options = {}) { + const normalizedRef = String(taskRef || '').trim(); + if (!normalizedRef) { + throw new Error('Missing taskId'); + } + + const includeDeleted = options.includeDeleted === true; + const tasks = listRawTasks(paths); + const exact = tasks.find((task) => task.id === normalizedRef); + if (exact && (includeDeleted || exact.status !== 'deleted')) { + return exact.id; + } + + const byDisplay = tasks.find( + (task) => + task.displayId === normalizedRef && + (includeDeleted || task.status !== 'deleted') + ); + if (byDisplay) { + return byDisplay.id; + } + + throw new Error(`Task not found: ${normalizedRef}`); +} + +function readTask(paths, taskRef, options = {}) { + const taskId = resolveTaskRef(paths, taskRef, options); + const taskPath = getTaskPath(paths, taskId); + const rawTask = readJson(taskPath, null); + if (!rawTask) { + throw new Error(`Task not found: ${String(taskRef)}`); + } + return normalizeTask(rawTask, taskPath); +} + +function createStatusTransition(history, from, to, actor, timestamp) { + return [...(Array.isArray(history) ? history : []), { from, to, timestamp, ...(actor ? { actor } : {}) }]; +} + +function normalizeStatus(status) { + const normalized = String(status || '').trim(); + return TASK_STATUSES.has(normalized) ? normalized : null; +} + +function parseRelationshipList(paths, value) { + const rawValues = Array.isArray(value) + ? value + : typeof value === 'string' + ? value.split(',').map((entry) => entry.trim()).filter(Boolean) + : []; + + return rawValues.map((entry) => resolveTaskRef(paths, entry)); +} + +function computeInitialStatus(paths, input, owner, blockedByIds) { + const explicit = normalizeStatus(input.status); + if (explicit) return explicit; + if (blockedByIds.length > 0) return 'pending'; + return owner ? 'in_progress' : 'pending'; +} + +function pickTaskId(input) { + if (typeof input.id === 'string' && input.id.trim()) { + return input.id.trim(); + } + return crypto.randomUUID(); +} + +function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) { + const preferred = + typeof explicitDisplayId === 'string' && explicitDisplayId.trim() + ? explicitDisplayId.trim() + : deriveDisplayId(canonicalId); + + const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))); + if (!existing.has(preferred)) { + return preferred; + } + + let length = Math.max(preferred.length, 8); + while (length < canonicalId.length) { + const candidate = canonicalId.slice(0, length).toLowerCase(); + if (!existing.has(candidate)) { + return candidate; + } + length += 1; + } + + return canonicalId.toLowerCase(); +} + +function wouldCreateBlockCycle(paths, sourceId, targetId) { + const visited = new Set(); + const stack = [targetId]; + + while (stack.length > 0) { + const currentId = stack.pop(); + if (!currentId || visited.has(currentId)) continue; + if (currentId === sourceId) return true; + visited.add(currentId); + try { + const currentTask = readTask(paths, currentId, { includeDeleted: true }); + for (const depId of currentTask.blockedBy || []) { + stack.push(depId); + } + } catch { + // Ignore unreadable dependency rows during cycle probe. + } + } + + return false; +} + +function writeTask(paths, task) { + writeJson(getTaskPath(paths, task.id), task); +} + +function createTask(paths, input = {}) { + ensureDir(paths.tasksDir); + + const canonicalId = pickTaskId(input); + if (fs.existsSync(getTaskPath(paths, canonicalId))) { + throw new Error(`Task already exists: ${canonicalId}`); + } + + const blockedByIds = parseRelationshipList(paths, input['blocked-by'] ?? input.blockedBy); + const relatedIds = parseRelationshipList(paths, input.related); + const owner = + typeof input.owner === 'string' && input.owner.trim() ? input.owner.trim() : undefined; + const createdBy = + typeof input.from === 'string' && input.from.trim() + ? input.from.trim() + : typeof input.createdBy === 'string' && input.createdBy.trim() + ? input.createdBy.trim() + : undefined; + const createdAt = + typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso(); + const status = computeInitialStatus(paths, input, owner, blockedByIds); + const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId); + + for (const depId of blockedByIds) { + if (wouldCreateBlockCycle(paths, canonicalId, depId)) { + throw new Error(`Circular dependency: ${depId} already depends on ${canonicalId}`); + } + } + + const task = normalizeTask({ + id: canonicalId, + displayId, + subject: + typeof input.subject === 'string' && input.subject.trim() + ? input.subject.trim() + : String(input.subject || '').trim(), + description: + typeof input.description === 'string' && input.description.length > 0 + ? input.description + : String(input.subject || '').trim(), + activeForm: + typeof input.activeForm === 'string' + ? input.activeForm + : typeof input['active-form'] === 'string' + ? input['active-form'] + : undefined, + owner, + createdBy, + status, + createdAt, + updatedAt: createdAt, + workIntervals: + status === 'in_progress' + ? [{ startedAt: createdAt }] + : Array.isArray(input.workIntervals) + ? input.workIntervals + : undefined, + statusHistory: createStatusTransition(input.statusHistory, null, status, createdBy, createdAt), + blocks: Array.isArray(input.blocks) ? [...input.blocks] : [], + blockedBy: blockedByIds, + related: relatedIds.length > 0 ? relatedIds : undefined, + projectPath: + typeof input.projectPath === 'string' && input.projectPath.trim() + ? input.projectPath.trim() + : undefined, + comments: Array.isArray(input.comments) ? input.comments : undefined, + needsClarification: + input.needsClarification === 'lead' || input.needsClarification === 'user' + ? input.needsClarification + : undefined, + deletedAt: + status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined, + attachments: Array.isArray(input.attachments) ? input.attachments : undefined, + }); + + if (!task.subject) { + throw new Error('Missing subject'); + } + + writeTask(paths, task); + + for (const depId of blockedByIds) { + const dependencyTask = readTask(paths, depId, { includeDeleted: true }); + const dependencyBlocks = Array.isArray(dependencyTask.blocks) ? dependencyTask.blocks : []; + if (!dependencyBlocks.includes(task.id)) { + dependencyTask.blocks = dependencyBlocks.concat([task.id]); + dependencyTask.updatedAt = nowIso(); + writeTask(paths, dependencyTask); + } + } + + for (const relatedId of relatedIds) { + const relatedTask = readTask(paths, relatedId, { includeDeleted: true }); + const existingRelated = Array.isArray(relatedTask.related) ? relatedTask.related : []; + if (!existingRelated.includes(task.id)) { + relatedTask.related = existingRelated.concat([task.id]); + relatedTask.updatedAt = nowIso(); + writeTask(paths, relatedTask); + } + } + + return task; +} + +function updateTask(paths, taskRef, updater, options = {}) { + const existingTask = readTask(paths, taskRef, { includeDeleted: true }); + const nextTask = normalizeTask(updater({ ...existingTask }) || existingTask); + nextTask.updatedAt = nowIso(); + writeTask(paths, nextTask); + return nextTask; +} + +function setTaskStatus(paths, taskRef, nextStatus, actor) { + const status = normalizeStatus(nextStatus); + if (!status) { + throw new Error(`Invalid status: ${String(nextStatus)}`); + } + + return updateTask(paths, taskRef, (task) => { + if (task.status === status) return task; + const timestamp = nowIso(); + const workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : []; + const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null; + + if (task.status !== 'in_progress' && status === 'in_progress') { + if (!lastInterval || typeof lastInterval.completedAt === 'string') { + workIntervals.push({ startedAt: timestamp }); + } + } else if (task.status === 'in_progress' && status !== 'in_progress') { + if (lastInterval && lastInterval.completedAt === undefined) { + lastInterval.completedAt = timestamp; + } + } + + task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined; + task.statusHistory = createStatusTransition(task.statusHistory, task.status, status, actor, timestamp); + task.status = status; + + if (status === 'deleted') { + task.deletedAt = timestamp; + } else if (task.deletedAt) { + delete task.deletedAt; + } + + return task; + }); +} + +function setTaskOwner(paths, taskRef, owner) { + return updateTask(paths, taskRef, (task) => { + if (owner == null || owner === 'clear' || owner === 'none') { + delete task.owner; + } else { + task.owner = String(owner).trim(); + } + return task; + }); +} + +function updateTaskFields(paths, taskRef, fields) { + return updateTask(paths, taskRef, (task) => { + if (fields.subject !== undefined) { + task.subject = fields.subject; + } + if (fields.description !== undefined) { + task.description = fields.description; + } + return task; + }); +} + +function addTaskComment(paths, taskRef, text, options = {}) { + if (typeof text !== 'string' || !text.trim()) { + throw new Error('Missing comment text'); + } + + const comment = { + id: options.id || crypto.randomUUID(), + author: + typeof options.author === 'string' && options.author.trim() + ? options.author.trim() + : 'user', + text, + createdAt: + typeof options.createdAt === 'string' && options.createdAt.trim() + ? options.createdAt.trim() + : nowIso(), + type: options.type || 'regular', + ...(Array.isArray(options.attachments) && options.attachments.length > 0 + ? { attachments: options.attachments } + : {}), + }; + + const task = updateTask(paths, taskRef, (currentTask) => { + const comments = Array.isArray(currentTask.comments) ? currentTask.comments : []; + if (comments.some((entry) => entry.id === comment.id)) { + return currentTask; + } + + if (currentTask.needsClarification === 'lead' && comment.author !== currentTask.owner) { + delete currentTask.needsClarification; + } + + currentTask.comments = comments.concat([comment]); + return currentTask; + }); + + return { comment, task }; +} + +function setNeedsClarification(paths, taskRef, value) { + return updateTask(paths, taskRef, (task) => { + if (value === null || value === 'clear') { + delete task.needsClarification; + } else if (value === 'lead' || value === 'user') { + task.needsClarification = value; + } else { + throw new Error(`Invalid clarification value: ${String(value)}`); + } + return task; + }); +} + +function addTaskAttachmentMeta(paths, taskRef, meta) { + return updateTask(paths, taskRef, (task) => { + const attachments = Array.isArray(task.attachments) ? task.attachments : []; + if (!attachments.some((entry) => entry.id === meta.id)) { + task.attachments = attachments.concat([meta]); + } + return task; + }); +} + +function removeTaskAttachment(paths, taskRef, attachmentId) { + return updateTask(paths, taskRef, (task) => { + const attachments = Array.isArray(task.attachments) ? task.attachments : []; + const filtered = attachments.filter((entry) => entry.id !== attachmentId); + if (filtered.length > 0) task.attachments = filtered; + else delete task.attachments; + return task; + }); +} + +function addCommentAttachmentMeta(paths, taskRef, commentRef, meta) { + return updateTask(paths, taskRef, (task) => { + const comments = Array.isArray(task.comments) ? [...task.comments] : []; + const commentIndex = comments.findIndex((entry) => String(entry.id) === String(commentRef)); + if (commentIndex < 0) { + throw new Error(`Comment not found: ${String(commentRef)}`); + } + const comment = { ...comments[commentIndex] }; + const attachments = Array.isArray(comment.attachments) ? comment.attachments : []; + if (!attachments.some((entry) => entry.id === meta.id)) { + comment.attachments = attachments.concat([meta]); + } + comments[commentIndex] = comment; + task.comments = comments; + return task; + }); +} + +function linkTask(paths, taskRef, targetRef, relationship) { + const sourceId = resolveTaskRef(paths, taskRef); + const targetId = resolveTaskRef(paths, targetRef); + if (sourceId === targetId) { + throw new Error('Cannot link a task to itself'); + } + + if (relationship === 'blocks') { + return linkTask(paths, targetId, sourceId, 'blocked-by'); + } + + if (relationship === 'blocked-by') { + if (wouldCreateBlockCycle(paths, sourceId, targetId)) { + throw new Error(`Circular dependency: ${targetId} already depends on ${sourceId}`); + } + + const sourceTask = readTask(paths, sourceId, { includeDeleted: true }); + const targetTask = readTask(paths, targetId, { includeDeleted: true }); + if (!(sourceTask.blockedBy || []).includes(targetId)) { + sourceTask.blockedBy = [...(sourceTask.blockedBy || []), targetId]; + writeTask(paths, sourceTask); + } + if (!(targetTask.blocks || []).includes(sourceId)) { + targetTask.blocks = [...(targetTask.blocks || []), sourceId]; + writeTask(paths, targetTask); + } + return readTask(paths, sourceId, { includeDeleted: true }); + } + + if (relationship !== 'related') { + throw new Error(`Unsupported relationship: ${String(relationship)}`); + } + + const sourceTask = readTask(paths, sourceId, { includeDeleted: true }); + const targetTask = readTask(paths, targetId, { includeDeleted: true }); + if (!(sourceTask.related || []).includes(targetId)) { + sourceTask.related = [...(sourceTask.related || []), targetId]; + writeTask(paths, sourceTask); + } + if (!(targetTask.related || []).includes(sourceId)) { + targetTask.related = [...(targetTask.related || []), sourceId]; + writeTask(paths, targetTask); + } + return readTask(paths, sourceId, { includeDeleted: true }); +} + +function unlinkTask(paths, taskRef, targetRef, relationship) { + const sourceId = resolveTaskRef(paths, taskRef, { includeDeleted: true }); + const targetId = resolveTaskRef(paths, targetRef, { includeDeleted: true }); + + if (relationship === 'blocks') { + return unlinkTask(paths, targetId, sourceId, 'blocked-by'); + } + + const sourceTask = readTask(paths, sourceId, { includeDeleted: true }); + if (relationship === 'blocked-by') { + sourceTask.blockedBy = (sourceTask.blockedBy || []).filter((entry) => entry !== targetId); + writeTask(paths, sourceTask); + try { + const targetTask = readTask(paths, targetId, { includeDeleted: true }); + targetTask.blocks = (targetTask.blocks || []).filter((entry) => entry !== sourceId); + writeTask(paths, targetTask); + } catch { + // Ignore missing reverse link target. + } + return readTask(paths, sourceId, { includeDeleted: true }); + } + + if (relationship !== 'related') { + throw new Error(`Unsupported relationship: ${String(relationship)}`); + } + + sourceTask.related = (sourceTask.related || []).filter((entry) => entry !== targetId); + writeTask(paths, sourceTask); + try { + const targetTask = readTask(paths, targetId, { includeDeleted: true }); + targetTask.related = (targetTask.related || []).filter((entry) => entry !== sourceId); + writeTask(paths, targetTask); + } catch { + // Ignore missing reverse link target. + } + return readTask(paths, sourceId, { includeDeleted: true }); +} + +function buildTaskReference(task) { + return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`; +} + +function formatTaskBriefing(paths, teamName, memberName) { + const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), { + teamName, + reviewers: [], + tasks: {}, + }); + const activeTasks = listTasks(paths) + .filter((task) => task.owner === memberName && task.status !== 'deleted') + .sort((a, b) => String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, { + numeric: true, + sensitivity: 'base', + })); + + if (activeTasks.length === 0) { + return `No pending tasks for ${memberName}.`; + } + + const lines = []; + for (const task of activeTasks) { + const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined; + const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : ''; + lines.push( + `${buildTaskReference(task)} [status=${task.status}${reviewState}] ${task.subject}` + ); + if (task.description) lines.push(` Description: ${task.description}`); + if (task.blockedBy && task.blockedBy.length > 0) { + const blockedLabels = task.blockedBy + .map((depId) => { + try { + return buildTaskReference(readTask(paths, depId, { includeDeleted: true })); + } catch { + return depId; + } + }) + .join(', '); + lines.push(` Blocked by: ${blockedLabels}`); + } + if (task.related && task.related.length > 0) { + const relatedLabels = task.related + .map((relatedId) => { + try { + return buildTaskReference(readTask(paths, relatedId, { includeDeleted: true })); + } catch { + return relatedId; + } + }) + .join(', '); + lines.push(` Related: ${relatedLabels}`); + } + if (Array.isArray(task.comments) && task.comments.length > 0) { + for (const comment of task.comments.slice(-3)) { + lines.push(` Comment by ${comment.author}: ${comment.text}`); + } + } + } + + return lines.join('\n'); +} + +module.exports = { + addCommentAttachmentMeta, + addTaskAttachmentMeta, + addTaskComment, + buildTaskReference, + createTask, + deriveDisplayId, + formatTaskBriefing, + linkTask, + listTasks, + readTask, + removeTaskAttachment, + resolveTaskRef, + setNeedsClarification, + setTaskOwner, + setTaskStatus, + unlinkTask, + updateTask, + updateTaskFields, +}; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 8a847851..be02b124 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -1,21 +1,30 @@ const legacy = require('../legacy/teamctl.cli.js'); -const { captureStreamOutput } = require('./capture.js'); +const taskStore = require('./taskStore.js'); -function createTask(context, flags) { - return legacy.createTask(context.paths, flags); +function createTask(context, input) { + return taskStore.createTask(context.paths, input); } function getTask(context, taskId) { - return legacy.readTask(context.paths, String(taskId)).task; + return taskStore.readTask(context.paths, taskId, { includeDeleted: true }); } function listTasks(context) { - return legacy.listTaskIds(context.paths.tasksDir).map((taskId) => getTask(context, taskId)); + return taskStore.listTasks(context.paths); +} + +function listDeletedTasks(context) { + return taskStore.listTasks(context.paths, { includeDeleted: true }).filter( + (task) => task.status === 'deleted' + ); +} + +function resolveTaskId(context, taskRef) { + return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true }); } function setTaskStatus(context, taskId, status, actor) { - legacy.setTaskStatus(context.paths, String(taskId), String(status), actor); - return getTask(context, taskId); + return taskStore.setTaskStatus(context.paths, taskId, status, actor); } function startTask(context, taskId, actor) { @@ -26,70 +35,108 @@ function completeTask(context, taskId, actor) { return setTaskStatus(context, taskId, 'completed', actor); } +function softDeleteTask(context, taskId, actor) { + return setTaskStatus(context, taskId, 'deleted', actor); +} + +function restoreTask(context, taskId, actor) { + return setTaskStatus(context, taskId, 'pending', actor || 'user'); +} + function setTaskOwner(context, taskId, owner) { - return legacy.setTaskOwner( - context.paths, - String(taskId), - owner == null || owner === 'clear' || owner === 'none' ? null : String(owner) - ); + return taskStore.setTaskOwner(context.paths, taskId, owner); +} + +function updateTaskFields(context, taskId, fields) { + return taskStore.updateTaskFields(context.paths, taskId, fields); } function addTaskComment(context, taskId, flags) { - const result = legacy.addTaskComment(context.paths, String(taskId), flags); + const result = taskStore.addTaskComment(context.paths, taskId, flags.text, { + author: + typeof flags.from === 'string' && flags.from.trim() + ? flags.from.trim() + : legacy.inferLeadName(context.paths), + ...(flags.id ? { id: flags.id } : {}), + ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), + ...(flags.type ? { type: flags.type } : {}), + ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), + }); + return { - ...result, - task: getTask(context, taskId), + commentId: result.comment.id, + taskId: result.task.id, + subject: result.task.subject, + owner: result.task.owner, + task: result.task, + comment: result.comment, }; } function attachTaskFile(context, taskId, flags) { - const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags); - legacy.addAttachmentToTask(context.paths, String(taskId), saved.meta); - return saved.meta; + const canonicalTaskId = resolveTaskId(context, taskId); + const saved = legacy.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); + const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta); + return { + ...saved.meta, + task, + }; } function attachCommentFile(context, taskId, commentId, flags) { - const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags); - legacy.addAttachmentToComment(context.paths, String(taskId), String(commentId), saved.meta); - return saved.meta; + const canonicalTaskId = resolveTaskId(context, taskId); + const saved = legacy.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); + const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta); + return { + ...saved.meta, + task, + }; +} + +function addTaskAttachmentMeta(context, taskId, meta) { + return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta); +} + +function removeTaskAttachment(context, taskId, attachmentId) { + return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId); } function setNeedsClarification(context, taskId, value) { - const normalized = value == null ? 'clear' : String(value); - legacy.setNeedsClarification(context.paths, String(taskId), normalized); - return getTask(context, taskId); + return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value)); } function linkTask(context, taskId, targetId, linkType) { - legacy.linkTasks(context.paths, String(taskId), String(targetId), String(linkType)); - return getTask(context, taskId); + return taskStore.linkTask(context.paths, taskId, targetId, String(linkType)); } function unlinkTask(context, taskId, targetId, linkType) { - legacy.unlinkTasks(context.paths, String(taskId), String(targetId), String(linkType)); - return getTask(context, taskId); + return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType)); } async function taskBriefing(context, memberName) { - const { output } = await captureStreamOutput(process.stdout, () => - legacy.taskBriefing(context.paths, context.teamName, { for: memberName }) - ); - return output; + return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName)); } module.exports = { - createTask, - getTask, - listTasks, - setTaskStatus, - startTask, - completeTask, - setTaskOwner, + addTaskAttachmentMeta, addTaskComment, attachTaskFile, attachCommentFile, - setNeedsClarification, + completeTask, + createTask, + getTask, linkTask, - unlinkTask, + listDeletedTasks, + listTasks, + removeTaskAttachment, + resolveTaskId, + restoreTask, + setNeedsClarification, + setTaskOwner, + setTaskStatus, + softDeleteTask, + startTask, taskBriefing, + unlinkTask, + updateTaskFields, }; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 2f7ac5d5..9049b5ea 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -30,27 +30,30 @@ describe('agent-teams-controller API', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); - controller.tasks.createTask({ subject: 'Base task' }); - controller.tasks.createTask({ subject: 'Dependency task' }); + const base = controller.tasks.createTask({ subject: 'Base task' }); + const dependency = controller.tasks.createTask({ subject: 'Dependency task' }); const created = controller.tasks.createTask({ subject: 'Blocked task', owner: 'bob', - 'blocked-by': '1,2', - related: '1', + 'blocked-by': `${base.displayId},${dependency.displayId}`, + related: base.displayId, }); - expect(created.id).toBe('3'); + expect(created.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + expect(created.displayId).toHaveLength(8); expect(created.status).toBe('pending'); - expect(controller.tasks.getTask('1').blocks).toEqual(['3']); - expect(controller.tasks.getTask('3').blockedBy).toEqual(['1', '2']); + expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]); + expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]); controller.kanban.addReviewer('alice'); - controller.kanban.setKanbanColumn('3', 'review'); - controller.review.approveReview('3', { 'notify-owner': true, from: 'alice' }); + controller.kanban.setKanbanColumn(created.id, 'review'); + controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' }); const kanbanState = controller.kanban.getKanbanState(); expect(kanbanState.reviewers).toEqual(['alice']); - expect(kanbanState.tasks['3'].column).toBe('approved'); + expect(kanbanState.tasks[created.id].column).toBe('approved'); const proc = controller.processes.registerProcess({ pid: process.pid, @@ -59,5 +62,7 @@ describe('agent-teams-controller API', () => { }); expect(proc.port).toBe(3000); expect(controller.processes.listProcesses()).toHaveLength(1); + const stopped = controller.processes.stopProcess({ pid: process.pid }); + expect(typeof stopped.stoppedAt).toBe('string'); }); }); diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 485af723..579bf168 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -12,7 +12,7 @@ const prodDeps = Object.keys(pkg.dependencies || {}) // node-pty is a native addon that cannot be bundled by Rollup. // It must remain external and be loaded at runtime via require(). -const bundledDeps = prodDeps.filter(d => d !== 'node-pty') +const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-controller') // Rollup plugin: stub out native .node addon imports with empty modules. // ssh2 and cpu-features use optional native bindings that can't be bundled, diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 9ce6b678..71c99de1 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -8,13 +8,20 @@ declare module 'agent-teams-controller' { createTask(flags: Record): unknown; getTask(taskId: string): unknown; listTasks(): unknown[]; + listDeletedTasks(): unknown[]; + resolveTaskId(taskRef: string): string; setTaskStatus(taskId: string, status: string, actor?: string): unknown; startTask(taskId: string, actor?: string): unknown; completeTask(taskId: string, actor?: string): unknown; + softDeleteTask(taskId: string, actor?: string): unknown; + restoreTask(taskId: string, actor?: string): unknown; setTaskOwner(taskId: string, owner: string | null): unknown; + updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown; addTaskComment(taskId: string, flags: Record): unknown; attachTaskFile(taskId: string, flags: Record): unknown; attachCommentFile(taskId: string, commentId: string, flags: Record): unknown; + addTaskAttachmentMeta(taskId: string, meta: Record): unknown; + removeTaskAttachment(taskId: string, attachmentId: string): unknown; setNeedsClarification(taskId: string, value: string | null): unknown; linkTask(taskId: string, targetId: string, linkType: string): unknown; unlinkTask(taskId: string, targetId: string, linkType: string): unknown; @@ -28,6 +35,7 @@ declare module 'agent-teams-controller' { listReviewers(): string[]; addReviewer(reviewer: string): string[]; removeReviewer(reviewer: string): string[]; + updateColumnOrder(columnId: string, orderedTaskIds: string[]): unknown; } export interface ControllerReviewApi { @@ -41,6 +49,7 @@ declare module 'agent-teams-controller' { export interface ControllerProcessApi { registerProcess(flags: Record): unknown; + stopProcess(flags: Record): unknown; unregisterProcess(flags: Record): unknown; listProcesses(): unknown[]; } diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts index 0163c289..1ab4b07e 100644 --- a/mcp-server/src/controller.ts +++ b/mcp-server/src/controller.ts @@ -1,4 +1,6 @@ -import { createController } from 'agent-teams-controller'; +import * as agentTeamsControllerModule from 'agent-teams-controller'; + +const { createController } = agentTeamsControllerModule; export function getController(teamName: string, claudeDir?: string) { return createController({ diff --git a/mcp-server/src/tools/processTools.ts b/mcp-server/src/tools/processTools.ts index 2b622ed3..dc9b7f3e 100644 --- a/mcp-server/src/tools/processTools.ts +++ b/mcp-server/src/tools/processTools.ts @@ -67,4 +67,15 @@ export function registerProcessTools(server: Pick) { execute: async ({ teamName, claudeDir, pid }) => jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid })), }); + + server.addTool({ + name: 'process_stop', + description: 'Mark a registered process as stopped while preserving history', + parameters: z.object({ + ...toolContextSchema, + pid: z.number().int().positive(), + }), + execute: async ({ teamName, claudeDir, pid }) => + jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid })), + }); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index b95bd08d..9a1901df 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -100,15 +100,16 @@ describe('agent-teams-mcp tools', () => { expect(loadedTask.comments[0].attachments).toHaveLength(1); }); - it('covers process register/list/unregister without legacy stdout leaking into results', async () => { + it('covers process register/list/stop without legacy stdout leaking into results', async () => { const claudeDir = makeClaudeDir(); const teamName = 'beta'; + const pid = process.pid; const registered = parseJsonToolResult( await getTool('process_register').execute({ claudeDir, teamName, - pid: 43210, + pid, label: 'vite', command: 'pnpm dev', from: 'lead', @@ -116,7 +117,7 @@ describe('agent-teams-mcp tools', () => { }) ); - expect(registered.pid).toBe(43210); + expect(registered.pid).toBe(pid); expect(registered.label).toBe('vite'); const listed = parseJsonToolResult( @@ -127,16 +128,17 @@ describe('agent-teams-mcp tools', () => { ); expect(listed).toHaveLength(1); - expect(listed[0].pid).toBe(43210); + expect(listed[0].pid).toBe(pid); - const afterUnregister = parseJsonToolResult( - await getTool('process_unregister').execute({ + const stopped = parseJsonToolResult( + await getTool('process_stop').execute({ claudeDir, teamName, - pid: 43210, + pid, }) ); - expect(afterUnregister).toEqual([]); + expect(stopped.pid).toBe(pid); + expect(typeof stopped.stoppedAt).toBe('string'); }); }); diff --git a/package.json b/package.json index 90a5b52c..6fc83b91 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ ] }, "dependencies": { + "agent-teams-controller": "workspace:*", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-cpp": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43b56408..ab8c9cf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 + agent-teams-controller: + specifier: workspace:* + version: link:agent-teams-controller chokidar: specifier: ^4.0.3 version: 4.0.3 diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts index fc716724..f9d7d6cc 100644 --- a/src/main/ipc/guards.ts +++ b/src/main/ipc/guards.ts @@ -13,7 +13,7 @@ const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; -const TASK_ID_PATTERN = /^\d{1,10}$/; +const TASK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/; const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; @@ -130,13 +130,13 @@ export function validateTeamName(teamName: unknown): ValidationResult { } export function validateTaskId(taskId: unknown): ValidationResult { - const basic = validateString(taskId, 'taskId', 16); + const basic = validateString(taskId, 'taskId', 64); if (!basic.valid) { return basic; } if (!TASK_ID_PATTERN.test(basic.value!)) { - return { valid: false, error: 'taskId must contain only digits' }; + return { valid: false, error: 'taskId contains invalid characters' }; } return { valid: true, value: basic.value }; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index b2723657..eee8b2a6 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -3,6 +3,7 @@ import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { encodePath, extractBaseDir, + getClaudeBasePath, getProjectsBasePath, getTasksBasePath, getTeamsBasePath, @@ -16,8 +17,10 @@ import { } from '@shared/constants/agentBlocks'; import { getMemberColor } from '@shared/constants/memberColors'; import { createLogger } from '@shared/utils/logger'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; +import * as agentTeamsControllerModule from 'agent-teams-controller'; import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -61,6 +64,9 @@ import type { ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; +import type { AgentTeamsController } from 'agent-teams-controller'; + +const { createController } = agentTeamsControllerModule; const logger = createLogger('Service:TeamDataService'); @@ -86,9 +92,22 @@ export class TeamDataService { private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(), _legacyToolsInstaller: unknown = null, private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), - private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore() + private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(), + private readonly controllerFactory: (teamName: string) => AgentTeamsController = (teamName) => + createController({ + teamName, + claudeDir: getClaudeBasePath(), + }) ) {} + private getController(teamName: string): AgentTeamsController { + return this.controllerFactory(teamName); + } + + private getTaskLabel(task: Pick): string { + return formatTaskDisplayLabel(task); + } + async listTeams(): Promise { return this.configReader.listTeams(); } @@ -508,42 +527,7 @@ export class TeamDataService { private async processHealthTick(): Promise { for (const teamName of this.processHealthTeams) { try { - const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); - let raw: unknown[]; - try { - const stat = await fs.promises.stat(processesPath); - if (!stat.isFile() || stat.size > MAX_PROCESSES_FILE_BYTES) { - continue; - } - const content = await readFileUtf8WithTimeout(processesPath, 5_000); - const parsed: unknown = JSON.parse(content); - raw = Array.isArray(parsed) ? (parsed as unknown[]) : []; - } catch { - continue; - } - - const processes = raw.filter( - (p): p is TeamProcess => - !!p && - typeof p === 'object' && - 'pid' in p && - typeof (p as TeamProcess).pid === 'number' && - (p as TeamProcess).pid > 0 - ); - - let dirty = false; - for (const proc of processes) { - if (!proc.stoppedAt && !isProcessAlive(proc.pid)) { - proc.stoppedAt = new Date().toISOString(); - dirty = true; - } - } - - if (dirty) { - await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2)); - // atomicWrite triggers FileWatcher → team-change 'process' → UI refresh - // No need to emit manually — FileWatcher handles it. - } + this.getController(teamName).processes.listProcesses(); } catch { // best-effort per team } @@ -551,54 +535,13 @@ export class TeamDataService { } private async readProcesses(teamName: string): Promise { - const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); - let raw: unknown[]; - try { - const stat = await fs.promises.stat(processesPath); - if (!stat.isFile() || stat.size > MAX_PROCESSES_FILE_BYTES) { - return []; - } - const content = await readFileUtf8WithTimeout(processesPath, 5_000); - const parsed: unknown = JSON.parse(content); - raw = Array.isArray(parsed) ? (parsed as unknown[]) : []; - } catch { - return []; - } - - const processes = raw.filter( - (p): p is TeamProcess => - !!p && - typeof p === 'object' && - 'pid' in p && - typeof (p as TeamProcess).pid === 'number' && - (p as TeamProcess).pid > 0 - ); - - let dirty = false; - for (const proc of processes) { - if (!proc.stoppedAt && !isProcessAlive(proc.pid)) { - proc.stoppedAt = new Date().toISOString(); - dirty = true; - } - } - - if (dirty) { - try { - await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2)); - } catch { - // best-effort write-back - } - } - - return processes; + return this.getController(teamName).processes.listProcesses() as TeamProcess[]; } /** * Kill a registered CLI process by PID (SIGTERM) and mark it as stopped in processes.json. */ async killProcess(teamName: string, pid: number): Promise { - const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); - // Try to kill the process (cross-platform: SIGTERM on Unix, taskkill on Windows) try { killProcessByPid(pid); @@ -613,32 +556,10 @@ export class TeamDataService { } } - // Update processes.json to set stoppedAt - let raw: unknown[]; try { - const content = await readFileUtf8WithTimeout(processesPath, 5_000); - const parsed: unknown = JSON.parse(content); - raw = Array.isArray(parsed) ? (parsed as unknown[]) : []; + this.getController(teamName).processes.stopProcess({ pid }); } catch { - return; // No processes file — nothing to update - } - - let dirty = false; - for (const entry of raw) { - if ( - entry && - typeof entry === 'object' && - 'pid' in entry && - (entry as TeamProcess).pid === pid && - !(entry as TeamProcess).stoppedAt - ) { - (entry as TeamProcess).stoppedAt = new Date().toISOString(); - dirty = true; - } - } - - if (dirty) { - await atomicWriteAsync(processesPath, JSON.stringify(raw, null, 2)); + // Ignore missing persisted registry rows after OS-level stop. } } @@ -840,20 +761,9 @@ export class TeamDataService { } async createTask(teamName: string, request: CreateTaskRequest): Promise { - const nextId = await this.taskReader.getNextTaskId(teamName); - + const controller = this.getController(teamName); const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? []; - const related = request.related?.filter((id) => id.length > 0 && id !== nextId) ?? []; - - let description = request.description - ? `${request.subject}\n\n${request.description}` - : request.subject; - - if (request.prompt?.trim()) { - description = description - ? `${description}\n\n---\nPrompt: ${request.prompt.trim()}` - : `Prompt: ${request.prompt.trim()}`; - } + const related = request.related?.filter((id) => id.length > 0) ?? []; let projectPath: string | undefined; try { @@ -864,26 +774,16 @@ export class TeamDataService { } const shouldStart = request.owner && request.startImmediately !== false; - - const task: TeamTask = { - id: nextId, + const task = controller.tasks.createTask({ subject: request.subject, - description, - owner: request.owner, + ...(request.description?.trim() ? { description: request.description.trim() } : {}), + ...(request.owner ? { owner: request.owner } : {}), + ...(blockedBy.length > 0 ? { blockedBy } : {}), + ...(related.length > 0 ? { related } : {}), + ...(projectPath ? { projectPath } : {}), createdBy: 'user', - status: shouldStart ? 'in_progress' : 'pending', - blocks: [], - blockedBy, - related: related.length > 0 ? related : undefined, - projectPath, - }; - - await this.taskWriter.createTask(teamName, task); - - // Update blocks[] on each referenced task so the reverse link exists - for (const depId of blockedBy) { - await this.taskWriter.addBlocksEntry(teamName, depId, nextId); - } + ...(shouldStart ? { status: 'in_progress' } : { status: 'pending' }), + }) as TeamTask; if (shouldStart && request.owner) { try { @@ -893,7 +793,7 @@ export class TeamDataService { if (!this.isLeadOwner(request.owner, leadName)) { // Build notification with full context — inbox is the primary delivery // channel to agents (Claude Code monitors inbox via fs.watch) - const parts = [`New task assigned to you: #${task.id} "${task.subject}".`]; + const parts = [`New task assigned to you: ${this.getTaskLabel(task)} "${task.subject}".`]; if (request.description?.trim()) { parts.push(`\nDescription:\n${request.description.trim()}`); @@ -915,7 +815,7 @@ export class TeamDataService { member: request.owner, from: leadName, text: parts.join('\n'), - summary: `New task #${task.id} assigned`, + summary: `New task ${this.getTaskLabel(task)} assigned`, source: 'system_notification', }); } @@ -937,7 +837,7 @@ export class TeamDataService { throw new Error(`Task #${taskId} is not pending (current: ${task.status})`); } - await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'user'); + this.getController(teamName).tasks.startTask(taskId, 'user'); if (task.owner) { try { @@ -945,7 +845,7 @@ export class TeamDataService { // Skip inbox notification when lead starts their own task (solo teams) if (!this.isLeadOwner(task.owner, leadName)) { - const parts = [`Task #${task.id} "${task.subject}" has been started.`]; + const parts = [`Task ${this.getTaskLabel(task)} "${task.subject}" has been started.`]; if (task.description?.trim()) { parts.push(`\nDetails:\n${task.description.trim()}`); } @@ -959,7 +859,7 @@ export class TeamDataService { member: task.owner, from: leadName, text: parts.join('\n'), - summary: `Task #${task.id} started`, + summary: `Task ${this.getTaskLabel(task)} started`, source: 'system_notification', }); } @@ -977,7 +877,7 @@ export class TeamDataService { status: TeamTaskStatus, actor?: string ): Promise { - await this.taskWriter.updateStatus(teamName, taskId, status, actor); + this.getController(teamName).tasks.setTaskStatus(taskId, status, actor); } /** @@ -1014,8 +914,8 @@ export class TeamDataService { await this.sendMessage(teamName, { member: leadName, from: last.actor, - text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`, - summary: `Task #${task.id} started`, + text: `Task ${this.getTaskLabel(task)} "${task.subject}" has been started by ${last.actor}.`, + summary: `Task ${this.getTaskLabel(task)} started`, source: 'system_notification', }); } catch (error) { @@ -1024,11 +924,11 @@ export class TeamDataService { } async softDeleteTask(teamName: string, taskId: string): Promise { - await this.taskWriter.softDelete(teamName, taskId, 'user'); + this.getController(teamName).tasks.softDeleteTask(taskId, 'user'); } async restoreTask(teamName: string, taskId: string): Promise { - await this.taskWriter.restoreTask(teamName, taskId, 'user'); + this.getController(teamName).tasks.restoreTask(taskId, 'user'); } async getDeletedTasks(teamName: string): Promise { @@ -1036,7 +936,7 @@ export class TeamDataService { } async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise { - await this.taskWriter.updateOwner(teamName, taskId, owner); + this.getController(teamName).tasks.setTaskOwner(taskId, owner); } async updateTaskFields( @@ -1044,7 +944,7 @@ export class TeamDataService { taskId: string, fields: { subject?: string; description?: string } ): Promise { - await this.taskWriter.updateFields(teamName, taskId, fields); + this.getController(teamName).tasks.updateTaskFields(taskId, fields); } async addTaskAttachment( @@ -1052,7 +952,10 @@ export class TeamDataService { taskId: string, meta: TaskAttachmentMeta ): Promise { - await this.taskWriter.addAttachment(teamName, taskId, meta); + this.getController(teamName).tasks.addTaskAttachmentMeta( + taskId, + meta as unknown as Record + ); } async removeTaskAttachment( @@ -1060,7 +963,7 @@ export class TeamDataService { taskId: string, attachmentId: string ): Promise { - await this.taskWriter.removeAttachment(teamName, taskId, attachmentId); + this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId); } async setTaskNeedsClarification( @@ -1068,7 +971,7 @@ export class TeamDataService { taskId: string, value: 'lead' | 'user' | null ): Promise { - await this.taskWriter.setNeedsClarification(teamName, taskId, value); + this.getController(teamName).tasks.setNeedsClarification(taskId, value); } async addTaskRelationship( @@ -1077,7 +980,11 @@ export class TeamDataService { targetId: string, type: 'blockedBy' | 'blocks' | 'related' ): Promise { - await this.taskWriter.addRelationship(teamName, taskId, targetId, type); + this.getController(teamName).tasks.linkTask( + taskId, + targetId, + type === 'blockedBy' ? 'blocked-by' : type + ); } async removeTaskRelationship( @@ -1086,7 +993,11 @@ export class TeamDataService { targetId: string, type: 'blockedBy' | 'blocks' | 'related' ): Promise { - await this.taskWriter.removeRelationship(teamName, taskId, targetId, type); + this.getController(teamName).tasks.unlinkTask( + taskId, + targetId, + type === 'blockedBy' ? 'blocked-by' : type + ); } async addTaskComment( @@ -1095,28 +1006,40 @@ export class TeamDataService { text: string, attachments?: TaskAttachmentMeta[] ): Promise { - const comment = await this.taskWriter.addComment(teamName, taskId, text, { + const controller = this.getController(teamName); + const addResult = controller.tasks.addTaskComment(taskId, { + text, attachments, - }); + }) as { task?: TeamTask; comment?: TaskComment }; + const comment = + addResult.comment ?? + ({ + id: randomUUID(), + author: 'user', + text, + createdAt: new Date().toISOString(), + type: 'regular', + ...(attachments && attachments.length > 0 ? { attachments } : {}), + } as TaskComment); try { const [tasks, config] = await Promise.all([ this.taskReader.getTasks(teamName), this.configReader.getConfig(teamName).catch(() => null), ]); - const task = tasks.find((t) => t.id === taskId); + const task = addResult.task ?? tasks.find((t) => t.id === taskId); const leadName = this.resolveLeadNameFromConfig(config); const owner = task?.owner?.trim() || null; // Auto-clear needsClarification: "user" on UI comment // UI comments always have author "user" (TeamTaskWriter default) if (task?.needsClarification === 'user') { - await this.taskWriter.setNeedsClarification(teamName, taskId, null); + controller.tasks.setNeedsClarification(taskId, null); } if (task && owner && !this.isLeadOwner(owner, leadName)) { // Notify non-lead task owner via inbox (lead → member message) const parts = [ - `Comment on task #${taskId} "${task.subject}":\n\n${text}`, + `Comment on task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`, `\n${AGENT_BLOCK_OPEN}`, `Reply to this comment using MCP tool task_add_comment:`, `{ teamName: "${teamName}", taskId: "${taskId}", text: "", from: "" }`, @@ -1126,14 +1049,14 @@ export class TeamDataService { member: owner, from: leadName, text: parts.join('\n'), - summary: `Comment on #${taskId}`, + summary: `Comment on ${this.getTaskLabel(task)}`, source: 'system_notification', }); } else if (task && owner && this.isLeadOwner(owner, leadName)) { // Notify lead about user's comment on their own task. // Write to lead's inbox — relay delivers to stdin when process is alive. const parts = [ - `New comment from user on your task #${taskId} "${task.subject}":\n\n${text}`, + `New comment from user on your task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`, `\n${AGENT_BLOCK_OPEN}`, `Reply to this comment using MCP tool task_add_comment:`, `{ teamName: "${teamName}", taskId: "${taskId}", text: "", from: "${leadName}" }`, @@ -1143,7 +1066,7 @@ export class TeamDataService { member: leadName, from: 'user', text: parts.join('\n'), - summary: `Comment on #${taskId}`, + summary: `Comment on ${this.getTaskLabel(task)}`, source: 'system_notification', }); } @@ -1252,7 +1175,16 @@ export class TeamDataService { } async requestReview(teamName: string, taskId: string): Promise { - await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' }); + const tasks = await this.taskReader.getTasks(teamName); + const task = tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + if (task.status !== 'completed') { + throw new Error(`Task ${this.getTaskLabel(task)} must be completed before review`); + } + + this.getController(teamName).kanban.setKanbanColumn(taskId, 'review'); const state = await this.kanbanManager.getState(teamName); const reviewer = state.reviewers[0]; @@ -1266,20 +1198,18 @@ export class TeamDataService { member: reviewer, from: leadName, text: - `Please review task #${taskId}.\n\n` + + `Please review task ${this.getTaskLabel(task)}.\n\n` + `${AGENT_BLOCK_OPEN}\n` + `When approved, use MCP tool review_approve:\n` + `{ teamName: "${teamName}", taskId: "${taskId}", notifyOwner: true }\n\n` + `If changes are needed, use MCP tool review_request_changes:\n` + `{ teamName: "${teamName}", taskId: "${taskId}", comment: "..." }\n` + AGENT_BLOCK_CLOSE, - summary: `Review request for #${taskId}`, + summary: `Review request for ${this.getTaskLabel(task)}`, source: 'system_notification', }); } catch (error) { - await this.kanbanManager - .updateTask(teamName, taskId, { op: 'remove' }) - .catch(() => undefined); + this.getController(teamName).kanban.clearKanban(taskId); throw error; } } @@ -1347,12 +1277,15 @@ export class TeamDataService { tasks: TeamTask[], messages: InboxMessage[] ): Promise { - const TASK_ID_PATTERN = /#(\d+)/g; + const TASK_ID_PATTERN = /#([A-Za-z0-9-]+)/g; let synced = false; const tasksById = new Map(); for (const t of tasks) { tasksById.set(t.id, t); + if (t.displayId) { + tasksById.set(t.displayId, t); + } } // Dedup broadcasts: same sender + same text → process only once @@ -1360,7 +1293,7 @@ export class TeamDataService { function isAutomatedCommentNotification(msg: InboxMessage): boolean { const summary = typeof msg.summary === 'string' ? msg.summary : ''; - if (!/^Comment on #\d+/.test(summary)) return false; + if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false; const text = typeof msg.text === 'string' ? msg.text : ''; if (!text) return false; // These are system-generated inbox messages that already correspond to a real task comment. @@ -1396,7 +1329,7 @@ export class TeamDataService { if (existing.some((c) => c.id === commentId)) continue; try { - await this.taskWriter.addComment(teamName, taskId, msg.text, { + await this.taskWriter.addComment(teamName, task.id, msg.text, { id: commentId, author: msg.from, createdAt: msg.timestamp, @@ -1569,74 +1502,32 @@ export class TeamDataService { } async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise { - if (patch.op !== 'request_changes') { - // Keep kanban + task.status consistent: - // - moving a task into kanban review/approved implies the work is complete - // - request_changes already moves it back to in_progress and clears kanban entry - if (patch.op !== 'set_column') { - await this.kanbanManager.updateTask(teamName, taskId, patch); - return; - } + const controller = this.getController(teamName); - const previousState = await this.kanbanManager.getState(teamName); - const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId]; + if (patch.op === 'remove') { + controller.kanban.clearKanban(taskId); + return; + } - await this.kanbanManager.updateTask(teamName, taskId, patch); - - try { - await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user'); - } catch (error) { - // Best-effort rollback of kanban move if task status update failed. - if (previousKanbanEntry) { - await this.kanbanManager - .updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column }) - .catch(() => undefined); - } else { - await this.kanbanManager - .updateTask(teamName, taskId, { op: 'remove' }) - .catch(() => undefined); - } - throw error; + if (patch.op === 'set_column') { + if (patch.column === 'review') { + controller.kanban.setKanbanColumn(taskId, 'review'); + } else { + const leadName = await this.resolveLeadName(teamName); + controller.review.approveReview(taskId, { + from: leadName, + note: 'Approved from kanban', + 'notify-owner': true, + }); } return; } - const tasks = await this.taskReader.getTasks(teamName); - const task = tasks.find((candidate) => candidate.id === taskId); - if (!task?.owner) { - throw new Error(`No owner found for task ${taskId}`); - } - - const previousStatus: TeamTaskStatus = task.status; - const previousState = await this.kanbanManager.getState(teamName); - const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId]; - - await this.kanbanManager.updateTask(teamName, taskId, { op: 'remove' }); - - try { - await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'reviewer'); - const leadName = await this.resolveLeadName(teamName); - await this.sendMessage(teamName, { - member: task.owner, - from: leadName, - text: - `Task #${taskId} needs fixes.\n\n` + - `${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` + - `Please fix and mark it as completed when ready.`, - summary: `Fix request for #${taskId}`, - source: 'system_notification', - }); - } catch (error) { - await this.taskWriter - .updateStatus(teamName, taskId, previousStatus, 'system') - .catch(() => undefined); - if (previousKanbanEntry) { - await this.kanbanManager - .updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column }) - .catch(() => undefined); - } - throw error; - } + const leadName = await this.resolveLeadName(teamName); + controller.review.requestChanges(taskId, { + from: leadName, + comment: patch.comment?.trim() || 'Reviewer requested changes.', + }); } async updateKanbanColumnOrder( @@ -1644,6 +1535,6 @@ export class TeamDataService { columnId: KanbanColumnId, orderedTaskIds: string[] ): Promise { - await this.kanbanManager.updateColumnOrder(teamName, columnId, orderedTaskIds); + this.getController(teamName).kanban.updateColumnOrder(columnId, orderedTaskIds); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 03c5a878..69c1abaf 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -21,6 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { createLogger } from '@shared/utils/logger'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { spawn } from 'child_process'; @@ -452,6 +453,9 @@ ${processRegistration}`; function buildTaskStatusProtocol(teamName: string): string { return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: +0. IMPORTANT ID RULE: + - In MCP tool calls, ALWAYS use the exact canonical taskId value shown in the board/task snapshot. + - Human-facing summaries may use the short display label like #abcd1234 for readability. 1. Use MCP tool task_start to mark task started: { teamName: "${teamName}", taskId: "" } - Start the task ONLY when you are actually beginning work on it. @@ -469,7 +473,7 @@ function buildTaskStatusProtocol(teamName: string): string { 7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: { teamName: "${teamName}", taskId: "", text: "", from: "" } Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. -8. When sending a message about a specific task, include # in your SendMessage summary field for traceability. +8. When sending a message about a specific task, include its short display label like # in your SendMessage summary field for traceability. 9. Review workflow clarity (IMPORTANT): - The work task (e.g. #1) is the thing that must end up APPROVED after review. - If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task). @@ -501,7 +505,7 @@ function buildProcessRegistrationProtocol(teamName: string): string { { teamName: "${teamName}", pid: , label: "", from: "", port?: , url?: "http://localhost:", command?: "" } 3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list: { teamName: "${teamName}" } -4. When stopping a process, use MCP tool process_unregister: +4. When stopping a process, use MCP tool process_stop: { teamName: "${teamName}", pid: } If verification in step 3 fails or the process is missing from the list, re-register it.`); } @@ -703,9 +707,13 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string const lines = activeTasks.map((t) => { const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; const deps = t.blockedBy?.length - ? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]` + ? ` [blocked by: ${t.blockedBy + .map((id) => tasks.find((candidate) => candidate.id === id)) + .filter((task): task is TeamTask => Boolean(task)) + .map((task) => formatTaskDisplayLabel(task)) + .join(', ')}]` : ''; - return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`; + return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}] ${t.subject}${deps}${desc}`; }); return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`; } @@ -721,9 +729,13 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)'; const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; const deps = t.blockedBy?.length - ? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]` + ? ` [blocked by: ${t.blockedBy + .map((id) => tasks.find((candidate) => candidate.id === id)) + .filter((task): task is TeamTask => Boolean(task)) + .map((task) => formatTaskDisplayLabel(task)) + .join(', ')}]` : ''; - return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`; + return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}]${owner} ${t.subject}${deps}${desc}`; }); return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`; } @@ -1822,7 +1834,6 @@ export class TeamProvisioningService { '--verbose', '--setting-sources', 'user,project,local', - '--strict-mcp-config', '--mcp-config', mcpConfigPath, '--disallowedTools', @@ -2154,7 +2165,6 @@ export class TeamProvisioningService { '--verbose', '--setting-sources', 'user,project,local', - '--strict-mcp-config', '--mcp-config', mcpConfigPath, '--disallowedTools', diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 89a1dde6..e888b80d 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -2,6 +2,7 @@ import { yieldToEventLoop } from '@main/utils/asyncYield'; import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTasksBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import * as fs from 'fs'; import * as path from 'path'; @@ -165,6 +166,14 @@ export class TeamTaskReader { const task: TeamTask = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', + displayId: + typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0 + ? parsed.displayId.trim() + : deriveTaskDisplayId( + typeof parsed.id === 'string' || typeof parsed.id === 'number' + ? String(parsed.id) + : '' + ), subject, description: typeof parsed.description === 'string' ? parsed.description : undefined, activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, @@ -280,14 +289,20 @@ export class TeamTaskReader { } } - // Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11). - // Fall back to stable lexicographic ordering for unexpected non-numeric IDs. + // Sort by display ID first for stable human-facing ordering, then canonical id. tasks.sort((a, b) => { - const aIsNumeric = /^\d+$/.test(a.id); - const bIsNumeric = /^\d+$/.test(b.id); - if (aIsNumeric && bIsNumeric) return Number(a.id) - Number(b.id); + const aLabel = a.displayId ?? a.id; + const bLabel = b.displayId ?? b.id; + const aIsNumeric = /^\d+$/.test(aLabel); + const bIsNumeric = /^\d+$/.test(bLabel); + if (aIsNumeric && bIsNumeric) return Number(aLabel) - Number(bLabel); if (aIsNumeric) return -1; if (bIsNumeric) return 1; + const byDisplay = aLabel.localeCompare(bLabel, undefined, { + numeric: true, + sensitivity: 'base', + }); + if (byDisplay !== 0) return byDisplay; return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' }); }); @@ -347,6 +362,14 @@ export class TeamTaskReader { const task: TeamTask = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', + displayId: + typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0 + ? parsed.displayId.trim() + : deriveTaskDisplayId( + typeof parsed.id === 'string' || typeof parsed.id === 'number' + ? String(parsed.id) + : '' + ), subject, description: typeof parsed.description === 'string' ? parsed.description : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 117ad118..fc86fbf0 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -29,6 +29,15 @@ type WorkerResponse = | { id: string; ok: true; result: unknown; diag?: unknown } | { id: string; ok: false; error: string }; +const UUID_TASK_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function deriveTaskDisplayId(taskId: string): string { + const normalized = taskId.trim(); + if (!normalized) return normalized; + return UUID_TASK_ID_PATTERN.test(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized; +} + // --------------------------------------------------------------------------- // Diagnostic types // --------------------------------------------------------------------------- @@ -93,6 +102,7 @@ interface RawMember { interface ParsedTask { id?: unknown; + displayId?: unknown; subject?: unknown; title?: unknown; description?: unknown; @@ -593,6 +603,14 @@ async function readTasksDirForTeam( tasks.push({ id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', + displayId: + typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0 + ? parsed.displayId.trim() + : deriveTaskDisplayId( + typeof parsed.id === 'string' || typeof parsed.id === 'number' + ? String(parsed.id) + : '' + ), subject, description: typeof parsed.description === 'string' ? parsed.description : undefined, activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 76514a9d..1e9edc02 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -8,6 +8,7 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { projectColor } from '@renderer/utils/projectColor'; +import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { getNonEmptyTaskCategories, groupTasksByDate, @@ -222,7 +223,7 @@ export const GlobalTaskList = ({ const handleDeleteTask = async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ title: 'Delete task', - message: `Move task #${taskId} to trash?`, + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, confirmLabel: 'Delete', cancelLabel: 'Cancel', variant: 'danger', diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index f260dccc..27d82840 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -5,6 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -45,7 +46,7 @@ function getStatusLabel(column: string): string { } interface TaskTooltipProps { - /** The task ID (number string, e.g. "10"). */ + /** Canonical task id or short display id reference. */ taskId: string; /** Rendered trigger element. */ children: React.ReactElement; @@ -65,10 +66,7 @@ export const TaskTooltip = ({ const tasks = useStore((s) => s.selectedTeamData?.tasks); const members = useStore((s) => s.selectedTeamData?.members); - const task = useMemo( - () => tasks?.find((t) => t.id === taskId), - [tasks, taskId] - ); + const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]); const colorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), @@ -85,13 +83,10 @@ export const TaskTooltip = ({ return ( {children} - + {/* Subject */}
- #{taskId}{' '} + {formatTaskDisplayLabel(task)}{' '} {task.subject}
@@ -106,10 +101,7 @@ export const TaskTooltip = ({ {/* Owner */} {task.owner ? ( - + ) : ( Unassigned )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 575ed393..ff094235 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -30,6 +30,7 @@ import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, Bell, @@ -114,12 +115,13 @@ interface TimeWindow { function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] { if (query.startsWith('#')) { const id = query.slice(1); - return tasks.filter((t) => t.id === id); + return tasks.filter((t) => t.id === id || t.displayId === id); } const lower = query.toLowerCase(); return tasks.filter( (t) => t.id.toLowerCase().includes(lower) || + (t.displayId?.toLowerCase().includes(lower) ?? false) || t.subject.toLowerCase().includes(lower) || (t.owner?.toLowerCase().includes(lower) ?? false) ); @@ -770,7 +772,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele void (async () => { const confirmed = await confirm({ title: 'Delete task', - message: `Move task #${taskId} to trash?`, + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, confirmLabel: 'Delete', cancelLabel: 'Cancel', variant: 'danger', @@ -1307,7 +1309,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele if (result.notifiedOwner && task?.owner) { await api.teams.processSend( teamName, - `Task #${taskId} "${task.subject}" has started. Please begin working on it.` + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` ); } else if (!result.notifiedOwner) { const desc = task?.description?.trim() @@ -1315,7 +1317,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele : ''; await api.teams.processSend( teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` ); } } catch { @@ -1347,8 +1349,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele try { await api.teams.sendMessage(teamName, { member: task.owner, - text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task #${taskId} cancelled`, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, }); } catch { // best-effort @@ -1363,7 +1365,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele : ''; await api.teams.processSend( teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); } catch { // best-effort @@ -1582,7 +1584,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onMessageVisible={handleMessageVisible} onRestartTeam={() => setLaunchDialogOpen(true)} onTaskIdClick={(taskId) => { - const task = taskMap.get(taskId); + const task = + taskMap.get(taskId) ?? + data.tasks.find((candidate) => candidate.displayId === taskId); if (task) setSelectedTask(task); }} /> diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index 76fd2f7d..3943fa7c 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -3,6 +3,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { Loader2 } from 'lucide-react'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -100,7 +101,7 @@ export const ActiveTasksBlock = ({ onClick={() => onTaskClick(task)} title={task.subject} > - #{task.id} {task.subject} + {formatTaskDisplayLabel(task)} {task.subject} ) : ( - #{task.id} {task.subject} + {formatTaskDisplayLabel(task)} {task.subject} ))} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index d46c8106..f1cf76e1 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -23,6 +23,7 @@ import { } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react'; @@ -94,7 +95,9 @@ function getNoiseLabel(parsed: StructuredMessage): string | null { const rawTaskId = parsed.taskId; const taskId = typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null; - return taskId !== null ? `Completed task #${taskId}` : 'Completed a task'; + return taskId !== null + ? `Completed task ${formatTaskDisplayLabel({ id: String(taskId) })}` + : 'Completed a task'; } return null; @@ -132,8 +135,8 @@ const NoiseRow = ({ const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [ { pattern: /^New task assigned to you:/, label: 'Task assignment' }, - { pattern: /^Task #\d+\s+approved/, label: 'Task approved' }, - { pattern: /^Task #\d+\s+needs fixes/, label: 'Review changes requested' }, + { pattern: /^Task #[A-Za-z0-9-]+\s+approved/, label: 'Task approved' }, + { pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' }, ]; function getSystemMessageLabel(text: string): string | null { @@ -184,9 +187,9 @@ const AUTH_ERROR_PATTERNS = [ // Full message card — left colored border, name badge, collapsible content // --------------------------------------------------------------------------- -/** Convert `#` in plain text to markdown links with task:// protocol. */ +/** Convert `#` in plain text to markdown links with task:// protocol. */ export function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#(\d+)\b/g, '[#$1](task://$1)'); + return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)'); } /** @@ -211,10 +214,10 @@ export function linkifyMentionsInMarkdown( return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`; }); } -/** Render `#` in plain text as clickable inline elements with TaskTooltip. */ +/** Render `#` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { - return text.split(/(#\d+\b)/g).map((part, i) => { - const match = /^#(\d+)$/.exec(part); + return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => { + const match = /^#([A-Za-z0-9-]+)$/.exec(part); if (!match) return {part}; const taskId = match[1]; return ( diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 91dcc6e1..7acde5c9 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -28,6 +28,7 @@ import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertTriangle, Search } from 'lucide-react'; @@ -373,7 +374,8 @@ export const CreateTaskDialog = ({ (t) => !blockedBySearch || t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) || - t.id.includes(blockedBySearch) + t.id.includes(blockedBySearch) || + t.displayId?.includes(blockedBySearch) ) .map((t) => { const isSelected = blockedBy.includes(t.id); @@ -401,7 +403,7 @@ export const CreateTaskDialog = ({ variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal" > - #{t.id} + {formatTaskDisplayLabel(t)} {t.subject} @@ -411,7 +413,8 @@ export const CreateTaskDialog = ({ {blockedBy.length > 0 ? (

- Task will be blocked by: {blockedBy.map((id) => `#${id}`).join(', ')} + Task will be blocked by:{' '} + {blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}

) : null} @@ -442,7 +445,8 @@ export const CreateTaskDialog = ({ (t) => !relatedSearch || t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) || - t.id.includes(relatedSearch) + t.id.includes(relatedSearch) || + t.displayId?.includes(relatedSearch) ) .map((t) => { const isSelected = related.includes(t.id); @@ -470,7 +474,7 @@ export const CreateTaskDialog = ({ variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal" > - #{t.id} + {formatTaskDisplayLabel(t)} {t.subject} @@ -480,7 +484,7 @@ export const CreateTaskDialog = ({ {related.length > 0 ? (

- Related: {related.map((id) => `#${id}`).join(', ')} + Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}

) : null} diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 83a5a50f..49efbe22 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -15,6 +15,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember } from '@shared/types'; @@ -76,7 +77,7 @@ export const ReviewDialog = ({ Request Changes - Task #{taskId} + Task #{taskId ? deriveTaskDisplayId(taskId) : ''}
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 4302ae9b..dec8471d 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -55,9 +55,9 @@ interface TaskCommentsSectionProps { containerClassName?: string; } -/** Convert `#` in plain text to markdown links with task:// protocol. */ +/** Convert `#` in plain text to markdown links with task:// protocol. */ function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#(\d+)\b/g, '[#$1](task://$1)'); + return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)'); } /** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */ diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 5cafc18f..c8406e73 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -22,6 +22,7 @@ import { Textarea } from '@renderer/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { markAsRead } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { buildMemberColorMap, @@ -755,10 +756,14 @@ export const TaskDetailDialog = ({ ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' : 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25' } cursor-pointer`} - title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`} + title={ + depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(id)}` + } onClick={() => handleDependencyClick(id)} > - #{id} + {depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`} ); })} @@ -783,10 +788,14 @@ export const TaskDetailDialog = ({ ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' : 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25' } cursor-pointer`} - title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`} + title={ + depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(id)}` + } onClick={() => handleDependencyClick(id)} > - #{id} + {depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`} ); })} @@ -811,10 +820,16 @@ export const TaskDetailDialog = ({ key={`related:${currentTask.id}:${id}`} type="button" className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25" - title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`} + title={ + depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(id)}` + } onClick={() => handleDependencyClick(id)} > - #{id} + {depTask + ? formatTaskDisplayLabel(depTask) + : `#${deriveTaskDisplayId(id)}`} ); })} @@ -831,10 +846,16 @@ export const TaskDetailDialog = ({ key={`related-by:${currentTask.id}:${id}`} type="button" className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25" - title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`} + title={ + depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(id)}` + } onClick={() => handleDependencyClick(id)} > - #{id} + {depTask + ? formatTaskDisplayLabel(depTask) + : `#${deriveTaskDisplayId(id)}`} ); })} diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 302cb4a3..08b84b51 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useStore } from '@renderer/store'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { ArrowLeftFromLine, ArrowRightFromLine, @@ -65,13 +66,17 @@ const DependencyBadge = ({ ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' : 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25' } ${onScrollToTask ? 'cursor-pointer' : ''}`} - title={depTask ? `#${taskId}: ${depTask.subject}` : `#${taskId}`} + title={ + depTask + ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` + : `#${deriveTaskDisplayId(taskId)}` + } onClick={(e) => { e.stopPropagation(); onScrollToTask?.(taskId); }} > - #{taskId} + {depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(taskId)}`} ); }; @@ -264,7 +269,7 @@ export const KanbanTaskCard = ({ }} > - #{task.id} + {formatTaskDisplayLabel(task)}
diff --git a/src/renderer/components/team/kanban/TrashDialog.tsx b/src/renderer/components/team/kanban/TrashDialog.tsx index fc87384a..064b8b94 100644 --- a/src/renderer/components/team/kanban/TrashDialog.tsx +++ b/src/renderer/components/team/kanban/TrashDialog.tsx @@ -12,6 +12,7 @@ import { TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatDistanceToNow } from 'date-fns'; import { RotateCcw, Trash2 } from 'lucide-react'; @@ -63,7 +64,9 @@ export const TrashDialog = ({ key={task.id} className="border-b border-[var(--color-border-subtle)] last:border-0" > - {task.id} + + {formatTaskDisplayLabel(task)} + {task.subject} {task.owner ?? 'Unassigned'} diff --git a/src/renderer/components/team/members/MemberTasksTab.tsx b/src/renderer/components/team/members/MemberTasksTab.tsx index 2a2ccb0d..f71676bc 100644 --- a/src/renderer/components/team/members/MemberTasksTab.tsx +++ b/src/renderer/components/team/members/MemberTasksTab.tsx @@ -6,6 +6,7 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -58,7 +59,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea onClick={() => onTaskClick?.(task)} > - #{task.id} + {formatTaskDisplayLabel(task)} {task.subject} diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index cfb5d0e1..97bd11cb 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -1,4 +1,5 @@ import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -12,7 +13,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { return ( - {task.id} + + {formatTaskDisplayLabel(task)} + {task.subject} {task.owner ?? 'Unassigned'} @@ -24,7 +27,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {blockedByIds.length > 0 ? ( - {blockedByIds.map((id) => `#${id}`).join(', ')} + + {blockedByIds.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')} + ) : ( {'\u2014'} )} @@ -32,7 +37,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {blocksIds.length > 0 ? ( - {blocksIds.map((id) => `#${id}`).join(', ')} + {blocksIds.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')} ) : ( {'\u2014'} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 6aef3824..00d18fbf 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -2,6 +2,7 @@ import { api } from '@renderer/api'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { createLogger } from '@shared/utils/logger'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -118,7 +119,8 @@ function detectClarificationNotifications( function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void { // Delegate to main process for native OS notification (cross-platform, no permission needed) const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined; - const body = latestComment?.text || task.description || `Task #${task.id}: ${task.subject}`; + const body = + latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`; void api.teams ?.showMessageNotification({ @@ -126,7 +128,7 @@ function fireClarificationNotification(task: GlobalTask, suppressToast: boolean) teamDisplayName: task.teamDisplayName, from: latestComment?.author || 'team-lead', to: 'user', - summary: `Clarification needed — Task #${task.id}`, + summary: `Clarification needed — Task ${formatTaskDisplayLabel(task)}`, body, teamEventType: 'task_clarification', dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`, @@ -204,7 +206,7 @@ function fireStatusChangeNotification( teamDisplayName: task.teamDisplayName, from: task.owner ?? 'system', to: 'user', - summary: `Task #${task.id}: ${from} → ${to}`, + summary: `Task ${formatTaskDisplayLabel(task)}: ${from} → ${to}`, body: task.subject, teamEventType: 'task_status_change', dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 84658599..78eb8539 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -92,6 +92,8 @@ export interface TaskComment { // Adding a field here without mapping it there will cause a compile error. export interface TeamTask { id: string; + /** Human-friendly short task label shown in UI. Canonical identity remains `id`. */ + displayId?: string; subject: string; description?: string; activeForm?: string; diff --git a/src/shared/utils/taskIdentity.ts b/src/shared/utils/taskIdentity.ts new file mode 100644 index 00000000..f235c279 --- /dev/null +++ b/src/shared/utils/taskIdentity.ts @@ -0,0 +1,32 @@ +import type { TeamTask } from '@shared/types'; + +const UUID_TASK_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function looksLikeCanonicalTaskId(taskId: string): boolean { + return UUID_TASK_ID_PATTERN.test(taskId.trim()); +} + +export function deriveTaskDisplayId(taskId: string): string { + const normalized = taskId.trim(); + if (!normalized) return normalized; + return looksLikeCanonicalTaskId(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized; +} + +export function getTaskDisplayId(task: Pick): string { + return task.displayId?.trim() || deriveTaskDisplayId(task.id); +} + +export function formatTaskDisplayLabel(task: Pick): string { + return `#${getTaskDisplayId(task)}`; +} + +export function taskMatchesRef( + task: Pick, + ref: string | null | undefined +): boolean { + if (!ref) return false; + const normalized = ref.trim(); + if (!normalized) return false; + return task.id === normalized || getTaskDisplayId(task) === normalized; +} diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts new file mode 100644 index 00000000..71c99de1 --- /dev/null +++ b/src/types/agent-teams-controller.d.ts @@ -0,0 +1,66 @@ +declare module 'agent-teams-controller' { + export interface ControllerContextOptions { + teamName: string; + claudeDir?: string; + } + + export interface ControllerTaskApi { + createTask(flags: Record): unknown; + getTask(taskId: string): unknown; + listTasks(): unknown[]; + listDeletedTasks(): unknown[]; + resolveTaskId(taskRef: string): string; + setTaskStatus(taskId: string, status: string, actor?: string): unknown; + startTask(taskId: string, actor?: string): unknown; + completeTask(taskId: string, actor?: string): unknown; + softDeleteTask(taskId: string, actor?: string): unknown; + restoreTask(taskId: string, actor?: string): unknown; + setTaskOwner(taskId: string, owner: string | null): unknown; + updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown; + addTaskComment(taskId: string, flags: Record): unknown; + attachTaskFile(taskId: string, flags: Record): unknown; + attachCommentFile(taskId: string, commentId: string, flags: Record): unknown; + addTaskAttachmentMeta(taskId: string, meta: Record): unknown; + removeTaskAttachment(taskId: string, attachmentId: string): unknown; + setNeedsClarification(taskId: string, value: string | null): unknown; + linkTask(taskId: string, targetId: string, linkType: string): unknown; + unlinkTask(taskId: string, targetId: string, linkType: string): unknown; + taskBriefing(memberName: string): Promise; + } + + export interface ControllerKanbanApi { + getKanbanState(): unknown; + setKanbanColumn(taskId: string, column: string): unknown; + clearKanban(taskId: string): unknown; + listReviewers(): string[]; + addReviewer(reviewer: string): string[]; + removeReviewer(reviewer: string): string[]; + updateColumnOrder(columnId: string, orderedTaskIds: string[]): unknown; + } + + export interface ControllerReviewApi { + approveReview(taskId: string, flags?: Record): unknown; + requestChanges(taskId: string, flags?: Record): unknown; + } + + export interface ControllerMessageApi { + sendMessage(flags: Record): unknown; + } + + export interface ControllerProcessApi { + registerProcess(flags: Record): unknown; + stopProcess(flags: Record): unknown; + unregisterProcess(flags: Record): unknown; + listProcesses(): unknown[]; + } + + export interface AgentTeamsController { + tasks: ControllerTaskApi; + kanban: ControllerKanbanApi; + review: ControllerReviewApi; + messages: ControllerMessageApi; + processes: ControllerProcessApi; + } + + export function createController(options: ControllerContextOptions): AgentTeamsController; +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index dcbe0f61..26ac5d8c 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -566,7 +566,7 @@ describe('ipc teams handlers', () => { it('rejects invalid task id', async () => { const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; - const result = (await handler({} as never, 'my-team', 'abc', '2', 'blockedBy')) as { + const result = (await handler({} as never, 'my-team', 'bad/id', '2', 'blockedBy')) as { success: boolean; }; expect(result.success).toBe(false); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index b69b798b..77bf569d 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -146,7 +146,7 @@ describe('TeamDataService', () => { }); it('includes projectPath from config when creating a task', async () => { - const createTaskMock = vi.fn(async () => undefined); + const createTaskMock = vi.fn((task) => task); const service = new TeamDataService( { @@ -176,20 +176,28 @@ describe('TeamDataService', () => { { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), garbageCollect: vi.fn(async () => undefined), - } as never + } as never, + {} as never, + {} as never, + {} as never, + (teamName: string) => + ({ + tasks: { + createTask: createTaskMock, + }, + }) as never ); const result = await service.createTask('my-team', { subject: 'Test' }); expect(result.projectPath).toBe('/Users/dev/my-project'); expect(createTaskMock).toHaveBeenCalledWith( - 'my-team', expect.objectContaining({ projectPath: '/Users/dev/my-project' }) ); }); it('creates task with status pending when startImmediately is false', async () => { - const createTaskMock = vi.fn(async () => undefined); + const createTaskMock = vi.fn((task) => task); const service = new TeamDataService( { listTeams: vi.fn(), @@ -214,7 +222,16 @@ describe('TeamDataService', () => { { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), garbageCollect: vi.fn(async () => undefined), - } as never + } as never, + {} as never, + {} as never, + {} as never, + (teamName: string) => + ({ + tasks: { + createTask: createTaskMock, + }, + }) as never ); const result = await service.createTask('my-team', { @@ -225,13 +242,12 @@ describe('TeamDataService', () => { expect(result.status).toBe('pending'); expect(createTaskMock).toHaveBeenCalledWith( - 'my-team', expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' }) ); }); it('persists explicit related task links when creating a task', async () => { - const createTaskMock = vi.fn(async () => undefined); + const createTaskMock = vi.fn((task) => task); const service = new TeamDataService( { listTeams: vi.fn(), @@ -256,7 +272,16 @@ describe('TeamDataService', () => { { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), garbageCollect: vi.fn(async () => undefined), - } as never + } as never, + {} as never, + {} as never, + {} as never, + (teamName: string) => + ({ + tasks: { + createTask: createTaskMock, + }, + }) as never ); const result = await service.createTask('my-team', { @@ -265,9 +290,6 @@ describe('TeamDataService', () => { }); expect(result.related).toEqual(['1', '2']); - expect(createTaskMock).toHaveBeenCalledWith( - 'my-team', - expect.objectContaining({ related: ['1', '2'] }) - ); + expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] })); }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 120a9e3c..0c8e358a 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -116,8 +116,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).not.toContain('.claude/tools'); const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; - expect(launchArgs).toContain('--strict-mcp-config'); expect(launchArgs).toContain('--mcp-config'); + expect(launchArgs).not.toContain('--strict-mcp-config'); await svc.cancelProvisioning(runId); }); @@ -176,8 +176,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).not.toContain('.claude/tools'); const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; - expect(launchArgs).toContain('--strict-mcp-config'); expect(launchArgs).toContain('--mcp-config'); + expect(launchArgs).not.toContain('--strict-mcp-config'); await svc.cancelProvisioning(runId); });