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.
This commit is contained in:
iliya 2026-03-07 16:01:32 +02:00
parent 6a95eceb4f
commit 4cf330e8cc
41 changed files with 1575 additions and 406 deletions

0
agent-teams-controller/src/cli.js Normal file → Executable file
View file

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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 = {

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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');
});
});

View file

@ -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,

View file

@ -8,13 +8,20 @@ declare module 'agent-teams-controller' {
createTask(flags: Record<string, unknown>): 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<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
attachCommentFile(taskId: string, commentId: string, flags: Record<string, unknown>): unknown;
addTaskAttachmentMeta(taskId: string, meta: Record<string, unknown>): 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<string, unknown>): unknown;
stopProcess(flags: Record<string, unknown>): unknown;
unregisterProcess(flags: Record<string, unknown>): unknown;
listProcesses(): unknown[];
}

View file

@ -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({

View file

@ -67,4 +67,15 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
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 })),
});
}

View file

@ -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');
});
});

View file

@ -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",

View file

@ -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

View file

@ -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<string> {
}
export function validateTaskId(taskId: unknown): ValidationResult<string> {
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 };

View file

@ -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<TeamTask, 'id' | 'displayId'>): string {
return formatTaskDisplayLabel(task);
}
async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams();
}
@ -508,42 +527,7 @@ export class TeamDataService {
private async processHealthTick(): Promise<void> {
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<TeamProcess[]> {
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<void> {
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<TeamTask> {
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<void> {
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<void> {
await this.taskWriter.softDelete(teamName, taskId, 'user');
this.getController(teamName).tasks.softDeleteTask(taskId, 'user');
}
async restoreTask(teamName: string, taskId: string): Promise<void> {
await this.taskWriter.restoreTask(teamName, taskId, 'user');
this.getController(teamName).tasks.restoreTask(taskId, 'user');
}
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
@ -1036,7 +936,7 @@ export class TeamDataService {
}
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
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<void> {
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<void> {
await this.taskWriter.addAttachment(teamName, taskId, meta);
this.getController(teamName).tasks.addTaskAttachmentMeta(
taskId,
meta as unknown as Record<string, unknown>
);
}
async removeTaskAttachment(
@ -1060,7 +963,7 @@ export class TeamDataService {
taskId: string,
attachmentId: string
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<TaskComment> {
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: "<your reply>", from: "<your-name>" }`,
@ -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: "<your reply>", 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<void> {
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<boolean> {
const TASK_ID_PATTERN = /#(\d+)/g;
const TASK_ID_PATTERN = /#([A-Za-z0-9-]+)/g;
let synced = false;
const tasksById = new Map<string, TeamTask>();
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<void> {
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<void> {
await this.kanbanManager.updateColumnOrder(teamName, columnId, orderedTaskIds);
this.getController(teamName).kanban.updateColumnOrder(columnId, orderedTaskIds);
}
}

View file

@ -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: "<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: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
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 #<taskId> in your SendMessage summary field for traceability.
8. When sending a message about a specific task, include its short display label like #<displayId> 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: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<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: <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',

View file

@ -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,

View file

@ -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,

View file

@ -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<void> => {
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',

View file

@ -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<string, string>()),
@ -85,13 +83,10 @@ export const TaskTooltip = ({
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side={side}
className="max-w-xs space-y-1.5 p-2.5"
>
<TooltipContent side={side} className="max-w-xs space-y-1.5 p-2.5">
{/* Subject */}
<div className="text-xs font-medium text-[var(--color-text)]">
<span className="text-[var(--color-text-muted)]">#{taskId}</span>{' '}
<span className="text-[var(--color-text-muted)]">{formatTaskDisplayLabel(task)}</span>{' '}
{task.subject}
</div>
@ -106,10 +101,7 @@ export const TaskTooltip = ({
{/* Owner */}
{task.owner ? (
<MemberBadge
name={task.owner}
color={colorMap.get(task.owner)}
/>
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
) : (
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span>
)}

View file

@ -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);
}}
/>

View file

@ -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}
</button>
) : (
<span
@ -108,7 +109,7 @@ export const ActiveTasksBlock = ({
style={{ border: `1px solid ${colors.border}40` }}
title={task.subject}
>
#{task.id} {task.subject}
{formatTaskDisplayLabel(task)} {task.subject}
</span>
))}
</div>

View file

@ -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 `#<digits>` in plain text to markdown links with task:// protocol. */
/** Convert `#<task-display-id>` 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 `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
/** Render `#<task-display-id>` 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 <span key={i}>{part}</span>;
const taskId = match[1];
return (

View file

@ -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)}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
@ -411,7 +413,8 @@ export const CreateTaskDialog = ({
</div>
{blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300">
Task will be blocked by: {blockedBy.map((id) => `#${id}`).join(', ')}
Task will be blocked by:{' '}
{blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</p>
) : null}
</div>
@ -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)}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
@ -480,7 +484,7 @@ export const CreateTaskDialog = ({
</div>
{related.length > 0 ? (
<p className="text-[11px] text-purple-300">
Related: {related.map((id) => `#${id}`).join(', ')}
Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</p>
) : null}
</div>

View file

@ -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 = ({
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Request Changes</DialogTitle>
<DialogDescription>Task #{taskId}</DialogDescription>
<DialogDescription>Task #{taskId ? deriveTaskDisplayId(taskId) : ''}</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">

View file

@ -55,9 +55,9 @@ interface TaskCommentsSectionProps {
containerClassName?: string;
}
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
/** Convert `#<task-display-id>` 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. */

View file

@ -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)}`}
</button>
);
})}
@ -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)}`}
</button>
);
})}
@ -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)}`}
</button>
);
})}
@ -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)}`}
</button>
);
})}

View file

@ -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)}`}
</button>
);
};
@ -264,7 +269,7 @@ export const KanbanTaskCard = ({
}}
>
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
#{task.id}
{formatTaskDisplayLabel(task)}
</span>
<div className="mb-2 pt-2">
<div className="flex items-center gap-1">

View file

@ -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"
>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
{formatTaskDisplayLabel(task)}
</td>
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? 'Unassigned'}

View file

@ -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)}
>
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
#{task.id}
{formatTaskDisplayLabel(task)}
</Badge>
<span className="min-w-0 flex-1 truncate text-sm text-[var(--color-text)]">
{task.subject}

View file

@ -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 (
<tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{formatTaskDisplayLabel(task)}
</td>
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.owner ?? 'Unassigned'}
@ -24,7 +27,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
</td>
<td className="px-3 py-2 text-xs">
{blockedByIds.length > 0 ? (
<span className="text-yellow-300">{blockedByIds.map((id) => `#${id}`).join(', ')}</span>
<span className="text-yellow-300">
{blockedByIds.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</span>
) : (
<span className="text-[var(--color-text-muted)]">{'\u2014'}</span>
)}
@ -32,7 +37,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
<td className="px-3 py-2 text-xs">
{blocksIds.length > 0 ? (
<span className="text-blue-600 dark:text-blue-400">
{blocksIds.map((id) => `#${id}`).join(', ')}
{blocksIds.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</span>
) : (
<span className="text-[var(--color-text-muted)]">{'\u2014'}</span>

View file

@ -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()}`,

View file

@ -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;

View file

@ -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<TeamTask, 'id' | 'displayId'>): string {
return task.displayId?.trim() || deriveTaskDisplayId(task.id);
}
export function formatTaskDisplayLabel(task: Pick<TeamTask, 'id' | 'displayId'>): string {
return `#${getTaskDisplayId(task)}`;
}
export function taskMatchesRef(
task: Pick<TeamTask, 'id' | 'displayId'>,
ref: string | null | undefined
): boolean {
if (!ref) return false;
const normalized = ref.trim();
if (!normalized) return false;
return task.id === normalized || getTaskDisplayId(task) === normalized;
}

66
src/types/agent-teams-controller.d.ts vendored Normal file
View file

@ -0,0 +1,66 @@
declare module 'agent-teams-controller' {
export interface ControllerContextOptions {
teamName: string;
claudeDir?: string;
}
export interface ControllerTaskApi {
createTask(flags: Record<string, unknown>): 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<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
attachCommentFile(taskId: string, commentId: string, flags: Record<string, unknown>): unknown;
addTaskAttachmentMeta(taskId: string, meta: Record<string, unknown>): 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<string>;
}
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<string, unknown>): unknown;
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
}
export interface ControllerMessageApi {
sendMessage(flags: Record<string, unknown>): unknown;
}
export interface ControllerProcessApi {
registerProcess(flags: Record<string, unknown>): unknown;
stopProcess(flags: Record<string, unknown>): unknown;
unregisterProcess(flags: Record<string, unknown>): unknown;
listProcesses(): unknown[];
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
review: ControllerReviewApi;
messages: ControllerMessageApi;
processes: ControllerProcessApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;
}

View file

@ -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);

View file

@ -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'] }));
});
});

View file

@ -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);
});