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) { function getKanbanState(context) {
return legacy.readKanbanState(context.paths, context.teamName); return kanbanStore.readKanbanState(context.paths, context.teamName);
} }
function setKanbanColumn(context, taskId, column) { 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); return getKanbanState(context);
} }
function clearKanban(context, taskId) { 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); return getKanbanState(context);
} }
@ -22,7 +25,7 @@ function addReviewer(context, reviewer) {
const state = getKanbanState(context); const state = getKanbanState(context);
const next = new Set(state.reviewers); const next = new Set(state.reviewers);
next.add(String(reviewer)); next.add(String(reviewer));
legacy.writeKanbanState(context.paths, { kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state, ...state,
reviewers: [...next], reviewers: [...next],
}); });
@ -32,13 +35,18 @@ function addReviewer(context, reviewer) {
function removeReviewer(context, reviewer) { function removeReviewer(context, reviewer) {
const state = getKanbanState(context); const state = getKanbanState(context);
const next = state.reviewers.filter((entry) => entry !== reviewer); const next = state.reviewers.filter((entry) => entry !== reviewer);
legacy.writeKanbanState(context.paths, { kanbanStore.writeKanbanState(context.paths, context.teamName, {
...state, ...state,
reviewers: next, reviewers: next,
}); });
return listReviewers(context); 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 = { module.exports = {
getKanbanState, getKanbanState,
setKanbanColumn, setKanbanColumn,
@ -46,4 +54,5 @@ module.exports = {
listReviewers, listReviewers,
addReviewer, addReviewer,
removeReviewer, 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 processStore = require('./processStore.js');
const { captureStreamOutput } = require('./capture.js');
function registerProcess(context, flags) { function registerProcess(context, flags) {
captureStreamOutput(process.stdout, () => legacy.processRegister(context.paths, flags)); return processStore.registerProcess(context.paths, flags);
return listProcesses(context).find((entry) => entry.pid === Number(flags.pid)) || null;
} }
function unregisterProcess(context, flags) { function unregisterProcess(context, flags) {
captureStreamOutput(process.stdout, () => legacy.processUnregister(context.paths, flags)); processStore.unregisterProcess(context.paths, flags);
return listProcesses(context); return listProcesses(context);
} }
function listProcesses(context) { function listProcesses(context) {
return legacy.readProcessesSafe(context.paths.processesPath).map((entry) => ({ return processStore.listProcesses(context.paths);
...entry, }
alive: Number.isFinite(Number(entry && entry.pid))
? legacy.isProcessAlive(Number(entry.pid)) function stopProcess(context, flags) {
: false, return processStore.stopProcess(context.paths, flags);
}));
} }
module.exports = { module.exports = {
registerProcess, registerProcess,
stopProcess,
unregisterProcess, unregisterProcess,
listProcesses, 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'); const tasks = require('./tasks.js');
function approveReview(context, taskId, flags = {}) { function approveReview(context, taskId, flags = {}) {
legacy.reviewApprove(context.paths, context.teamName, String(taskId), flags); const task = tasks.getTask(context, taskId);
return 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 = {}) { function requestChanges(context, taskId, flags = {}) {
legacy.reviewRequestChanges(context.paths, context.teamName, String(taskId), flags); const task = tasks.getTask(context, taskId);
return 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 = { 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 legacy = require('../legacy/teamctl.cli.js');
const { captureStreamOutput } = require('./capture.js'); const taskStore = require('./taskStore.js');
function createTask(context, flags) { function createTask(context, input) {
return legacy.createTask(context.paths, flags); return taskStore.createTask(context.paths, input);
} }
function getTask(context, taskId) { function getTask(context, taskId) {
return legacy.readTask(context.paths, String(taskId)).task; return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
} }
function listTasks(context) { 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) { function setTaskStatus(context, taskId, status, actor) {
legacy.setTaskStatus(context.paths, String(taskId), String(status), actor); return taskStore.setTaskStatus(context.paths, taskId, status, actor);
return getTask(context, taskId);
} }
function startTask(context, taskId, actor) { function startTask(context, taskId, actor) {
@ -26,70 +35,108 @@ function completeTask(context, taskId, actor) {
return setTaskStatus(context, taskId, 'completed', 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) { function setTaskOwner(context, taskId, owner) {
return legacy.setTaskOwner( return taskStore.setTaskOwner(context.paths, taskId, owner);
context.paths, }
String(taskId),
owner == null || owner === 'clear' || owner === 'none' ? null : String(owner) function updateTaskFields(context, taskId, fields) {
); return taskStore.updateTaskFields(context.paths, taskId, fields);
} }
function addTaskComment(context, taskId, flags) { 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 { return {
...result, commentId: result.comment.id,
task: getTask(context, taskId), taskId: result.task.id,
subject: result.task.subject,
owner: result.task.owner,
task: result.task,
comment: result.comment,
}; };
} }
function attachTaskFile(context, taskId, flags) { function attachTaskFile(context, taskId, flags) {
const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags); const canonicalTaskId = resolveTaskId(context, taskId);
legacy.addAttachmentToTask(context.paths, String(taskId), saved.meta); const saved = legacy.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
return saved.meta; const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
return {
...saved.meta,
task,
};
} }
function attachCommentFile(context, taskId, commentId, flags) { function attachCommentFile(context, taskId, commentId, flags) {
const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags); const canonicalTaskId = resolveTaskId(context, taskId);
legacy.addAttachmentToComment(context.paths, String(taskId), String(commentId), saved.meta); const saved = legacy.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
return saved.meta; 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) { function setNeedsClarification(context, taskId, value) {
const normalized = value == null ? 'clear' : String(value); return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
legacy.setNeedsClarification(context.paths, String(taskId), normalized);
return getTask(context, taskId);
} }
function linkTask(context, taskId, targetId, linkType) { function linkTask(context, taskId, targetId, linkType) {
legacy.linkTasks(context.paths, String(taskId), String(targetId), String(linkType)); return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
return getTask(context, taskId);
} }
function unlinkTask(context, taskId, targetId, linkType) { function unlinkTask(context, taskId, targetId, linkType) {
legacy.unlinkTasks(context.paths, String(taskId), String(targetId), String(linkType)); return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
return getTask(context, taskId);
} }
async function taskBriefing(context, memberName) { async function taskBriefing(context, memberName) {
const { output } = await captureStreamOutput(process.stdout, () => return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
legacy.taskBriefing(context.paths, context.teamName, { for: memberName })
);
return output;
} }
module.exports = { module.exports = {
createTask, addTaskAttachmentMeta,
getTask,
listTasks,
setTaskStatus,
startTask,
completeTask,
setTaskOwner,
addTaskComment, addTaskComment,
attachTaskFile, attachTaskFile,
attachCommentFile, attachCommentFile,
setNeedsClarification, completeTask,
createTask,
getTask,
linkTask, linkTask,
unlinkTask, listDeletedTasks,
listTasks,
removeTaskAttachment,
resolveTaskId,
restoreTask,
setNeedsClarification,
setTaskOwner,
setTaskStatus,
softDeleteTask,
startTask,
taskBriefing, taskBriefing,
unlinkTask,
updateTaskFields,
}; };

View file

@ -30,27 +30,30 @@ describe('agent-teams-controller API', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); const controller = createController({ teamName: 'my-team', claudeDir });
controller.tasks.createTask({ subject: 'Base task' }); const base = controller.tasks.createTask({ subject: 'Base task' });
controller.tasks.createTask({ subject: 'Dependency task' }); const dependency = controller.tasks.createTask({ subject: 'Dependency task' });
const created = controller.tasks.createTask({ const created = controller.tasks.createTask({
subject: 'Blocked task', subject: 'Blocked task',
owner: 'bob', owner: 'bob',
'blocked-by': '1,2', 'blocked-by': `${base.displayId},${dependency.displayId}`,
related: '1', 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(created.status).toBe('pending');
expect(controller.tasks.getTask('1').blocks).toEqual(['3']); expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
expect(controller.tasks.getTask('3').blockedBy).toEqual(['1', '2']); expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
controller.kanban.addReviewer('alice'); controller.kanban.addReviewer('alice');
controller.kanban.setKanbanColumn('3', 'review'); controller.kanban.setKanbanColumn(created.id, 'review');
controller.review.approveReview('3', { 'notify-owner': true, from: 'alice' }); controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
const kanbanState = controller.kanban.getKanbanState(); const kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.reviewers).toEqual(['alice']); 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({ const proc = controller.processes.registerProcess({
pid: process.pid, pid: process.pid,
@ -59,5 +62,7 @@ describe('agent-teams-controller API', () => {
}); });
expect(proc.port).toBe(3000); expect(proc.port).toBe(3000);
expect(controller.processes.listProcesses()).toHaveLength(1); 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. // node-pty is a native addon that cannot be bundled by Rollup.
// It must remain external and be loaded at runtime via require(). // 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. // Rollup plugin: stub out native .node addon imports with empty modules.
// ssh2 and cpu-features use optional native bindings that can't be bundled, // 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; createTask(flags: Record<string, unknown>): unknown;
getTask(taskId: string): unknown; getTask(taskId: string): unknown;
listTasks(): unknown[]; listTasks(): unknown[];
listDeletedTasks(): unknown[];
resolveTaskId(taskRef: string): string;
setTaskStatus(taskId: string, status: string, actor?: string): unknown; setTaskStatus(taskId: string, status: string, actor?: string): unknown;
startTask(taskId: string, actor?: string): unknown; startTask(taskId: string, actor?: string): unknown;
completeTask(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; setTaskOwner(taskId: string, owner: string | null): unknown;
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown; addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
attachTaskFile(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; 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; setNeedsClarification(taskId: string, value: string | null): unknown;
linkTask(taskId: string, targetId: string, linkType: string): unknown; linkTask(taskId: string, targetId: string, linkType: string): unknown;
unlinkTask(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[]; listReviewers(): string[];
addReviewer(reviewer: string): string[]; addReviewer(reviewer: string): string[];
removeReviewer(reviewer: string): string[]; removeReviewer(reviewer: string): string[];
updateColumnOrder(columnId: string, orderedTaskIds: string[]): unknown;
} }
export interface ControllerReviewApi { export interface ControllerReviewApi {
@ -41,6 +49,7 @@ declare module 'agent-teams-controller' {
export interface ControllerProcessApi { export interface ControllerProcessApi {
registerProcess(flags: Record<string, unknown>): unknown; registerProcess(flags: Record<string, unknown>): unknown;
stopProcess(flags: Record<string, unknown>): unknown;
unregisterProcess(flags: Record<string, unknown>): unknown; unregisterProcess(flags: Record<string, unknown>): unknown;
listProcesses(): 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) { export function getController(teamName: string, claudeDir?: string) {
return createController({ return createController({

View file

@ -67,4 +67,15 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
execute: async ({ teamName, claudeDir, pid }) => execute: async ({ teamName, claudeDir, pid }) =>
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ 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); 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 claudeDir = makeClaudeDir();
const teamName = 'beta'; const teamName = 'beta';
const pid = process.pid;
const registered = parseJsonToolResult( const registered = parseJsonToolResult(
await getTool('process_register').execute({ await getTool('process_register').execute({
claudeDir, claudeDir,
teamName, teamName,
pid: 43210, pid,
label: 'vite', label: 'vite',
command: 'pnpm dev', command: 'pnpm dev',
from: 'lead', 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'); expect(registered.label).toBe('vite');
const listed = parseJsonToolResult( const listed = parseJsonToolResult(
@ -127,16 +128,17 @@ describe('agent-teams-mcp tools', () => {
); );
expect(listed).toHaveLength(1); expect(listed).toHaveLength(1);
expect(listed[0].pid).toBe(43210); expect(listed[0].pid).toBe(pid);
const afterUnregister = parseJsonToolResult( const stopped = parseJsonToolResult(
await getTool('process_unregister').execute({ await getTool('process_stop').execute({
claudeDir, claudeDir,
teamName, 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": { "dependencies": {
"agent-teams-controller": "workspace:*",
"@codemirror/autocomplete": "^6.20.0", "@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2", "@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",

View file

@ -143,6 +143,9 @@ importers:
'@xterm/xterm': '@xterm/xterm':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
agent-teams-controller:
specifier: workspace:*
version: link:agent-teams-controller
chokidar: chokidar:
specifier: ^4.0.3 specifier: ^4.0.3
version: 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 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 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 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 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}$/; 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> { export function validateTaskId(taskId: unknown): ValidationResult<string> {
const basic = validateString(taskId, 'taskId', 16); const basic = validateString(taskId, 'taskId', 64);
if (!basic.valid) { if (!basic.valid) {
return basic; return basic;
} }
if (!TASK_ID_PATTERN.test(basic.value!)) { 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 }; return { valid: true, value: basic.value };

View file

@ -3,6 +3,7 @@ import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { import {
encodePath, encodePath,
extractBaseDir, extractBaseDir,
getClaudeBasePath,
getProjectsBasePath, getProjectsBasePath,
getTasksBasePath, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
@ -16,8 +17,10 @@ import {
} from '@shared/constants/agentBlocks'; } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -61,6 +64,9 @@ import type {
ToolCallMeta, ToolCallMeta,
UpdateKanbanPatch, UpdateKanbanPatch,
} from '@shared/types'; } from '@shared/types';
import type { AgentTeamsController } from 'agent-teams-controller';
const { createController } = agentTeamsControllerModule;
const logger = createLogger('Service:TeamDataService'); const logger = createLogger('Service:TeamDataService');
@ -86,9 +92,22 @@ export class TeamDataService {
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(), private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
_legacyToolsInstaller: unknown = null, _legacyToolsInstaller: unknown = null,
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), 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[]> { async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams(); return this.configReader.listTeams();
} }
@ -508,42 +527,7 @@ export class TeamDataService {
private async processHealthTick(): Promise<void> { private async processHealthTick(): Promise<void> {
for (const teamName of this.processHealthTeams) { for (const teamName of this.processHealthTeams) {
try { try {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); this.getController(teamName).processes.listProcesses();
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.
}
} catch { } catch {
// best-effort per team // best-effort per team
} }
@ -551,54 +535,13 @@ export class TeamDataService {
} }
private async readProcesses(teamName: string): Promise<TeamProcess[]> { private async readProcesses(teamName: string): Promise<TeamProcess[]> {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); return this.getController(teamName).processes.listProcesses() as TeamProcess[];
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;
} }
/** /**
* Kill a registered CLI process by PID (SIGTERM) and mark it as stopped in processes.json. * Kill a registered CLI process by PID (SIGTERM) and mark it as stopped in processes.json.
*/ */
async killProcess(teamName: string, pid: number): Promise<void> { 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 to kill the process (cross-platform: SIGTERM on Unix, taskkill on Windows)
try { try {
killProcessByPid(pid); killProcessByPid(pid);
@ -613,32 +556,10 @@ export class TeamDataService {
} }
} }
// Update processes.json to set stoppedAt
let raw: unknown[];
try { try {
const content = await readFileUtf8WithTimeout(processesPath, 5_000); this.getController(teamName).processes.stopProcess({ pid });
const parsed: unknown = JSON.parse(content);
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
} catch { } catch {
return; // No processes file — nothing to update // Ignore missing persisted registry rows after OS-level stop.
}
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));
} }
} }
@ -840,20 +761,9 @@ export class TeamDataService {
} }
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> { 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 blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? [];
const related = request.related?.filter((id) => id.length > 0 && id !== nextId) ?? []; const related = request.related?.filter((id) => id.length > 0) ?? [];
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()}`;
}
let projectPath: string | undefined; let projectPath: string | undefined;
try { try {
@ -864,26 +774,16 @@ export class TeamDataService {
} }
const shouldStart = request.owner && request.startImmediately !== false; const shouldStart = request.owner && request.startImmediately !== false;
const task = controller.tasks.createTask({
const task: TeamTask = {
id: nextId,
subject: request.subject, subject: request.subject,
description, ...(request.description?.trim() ? { description: request.description.trim() } : {}),
owner: request.owner, ...(request.owner ? { owner: request.owner } : {}),
...(blockedBy.length > 0 ? { blockedBy } : {}),
...(related.length > 0 ? { related } : {}),
...(projectPath ? { projectPath } : {}),
createdBy: 'user', createdBy: 'user',
status: shouldStart ? 'in_progress' : 'pending', ...(shouldStart ? { status: 'in_progress' } : { status: 'pending' }),
blocks: [], }) as TeamTask;
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);
}
if (shouldStart && request.owner) { if (shouldStart && request.owner) {
try { try {
@ -893,7 +793,7 @@ export class TeamDataService {
if (!this.isLeadOwner(request.owner, leadName)) { if (!this.isLeadOwner(request.owner, leadName)) {
// Build notification with full context — inbox is the primary delivery // Build notification with full context — inbox is the primary delivery
// channel to agents (Claude Code monitors inbox via fs.watch) // 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()) { if (request.description?.trim()) {
parts.push(`\nDescription:\n${request.description.trim()}`); parts.push(`\nDescription:\n${request.description.trim()}`);
@ -915,7 +815,7 @@ export class TeamDataService {
member: request.owner, member: request.owner,
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `New task #${task.id} assigned`, summary: `New task ${this.getTaskLabel(task)} assigned`,
source: 'system_notification', source: 'system_notification',
}); });
} }
@ -937,7 +837,7 @@ export class TeamDataService {
throw new Error(`Task #${taskId} is not pending (current: ${task.status})`); 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) { if (task.owner) {
try { try {
@ -945,7 +845,7 @@ export class TeamDataService {
// Skip inbox notification when lead starts their own task (solo teams) // Skip inbox notification when lead starts their own task (solo teams)
if (!this.isLeadOwner(task.owner, leadName)) { 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()) { if (task.description?.trim()) {
parts.push(`\nDetails:\n${task.description.trim()}`); parts.push(`\nDetails:\n${task.description.trim()}`);
} }
@ -959,7 +859,7 @@ export class TeamDataService {
member: task.owner, member: task.owner,
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `Task #${task.id} started`, summary: `Task ${this.getTaskLabel(task)} started`,
source: 'system_notification', source: 'system_notification',
}); });
} }
@ -977,7 +877,7 @@ export class TeamDataService {
status: TeamTaskStatus, status: TeamTaskStatus,
actor?: string actor?: string
): Promise<void> { ): 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, { await this.sendMessage(teamName, {
member: leadName, member: leadName,
from: last.actor, from: last.actor,
text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`, text: `Task ${this.getTaskLabel(task)} "${task.subject}" has been started by ${last.actor}.`,
summary: `Task #${task.id} started`, summary: `Task ${this.getTaskLabel(task)} started`,
source: 'system_notification', source: 'system_notification',
}); });
} catch (error) { } catch (error) {
@ -1024,11 +924,11 @@ export class TeamDataService {
} }
async softDeleteTask(teamName: string, taskId: string): Promise<void> { 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> { 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[]> { async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
@ -1036,7 +936,7 @@ export class TeamDataService {
} }
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> { 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( async updateTaskFields(
@ -1044,7 +944,7 @@ export class TeamDataService {
taskId: string, taskId: string,
fields: { subject?: string; description?: string } fields: { subject?: string; description?: string }
): Promise<void> { ): Promise<void> {
await this.taskWriter.updateFields(teamName, taskId, fields); this.getController(teamName).tasks.updateTaskFields(taskId, fields);
} }
async addTaskAttachment( async addTaskAttachment(
@ -1052,7 +952,10 @@ export class TeamDataService {
taskId: string, taskId: string,
meta: TaskAttachmentMeta meta: TaskAttachmentMeta
): Promise<void> { ): Promise<void> {
await this.taskWriter.addAttachment(teamName, taskId, meta); this.getController(teamName).tasks.addTaskAttachmentMeta(
taskId,
meta as unknown as Record<string, unknown>
);
} }
async removeTaskAttachment( async removeTaskAttachment(
@ -1060,7 +963,7 @@ export class TeamDataService {
taskId: string, taskId: string,
attachmentId: string attachmentId: string
): Promise<void> { ): Promise<void> {
await this.taskWriter.removeAttachment(teamName, taskId, attachmentId); this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId);
} }
async setTaskNeedsClarification( async setTaskNeedsClarification(
@ -1068,7 +971,7 @@ export class TeamDataService {
taskId: string, taskId: string,
value: 'lead' | 'user' | null value: 'lead' | 'user' | null
): Promise<void> { ): Promise<void> {
await this.taskWriter.setNeedsClarification(teamName, taskId, value); this.getController(teamName).tasks.setNeedsClarification(taskId, value);
} }
async addTaskRelationship( async addTaskRelationship(
@ -1077,7 +980,11 @@ export class TeamDataService {
targetId: string, targetId: string,
type: 'blockedBy' | 'blocks' | 'related' type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> { ): Promise<void> {
await this.taskWriter.addRelationship(teamName, taskId, targetId, type); this.getController(teamName).tasks.linkTask(
taskId,
targetId,
type === 'blockedBy' ? 'blocked-by' : type
);
} }
async removeTaskRelationship( async removeTaskRelationship(
@ -1086,7 +993,11 @@ export class TeamDataService {
targetId: string, targetId: string,
type: 'blockedBy' | 'blocks' | 'related' type: 'blockedBy' | 'blocks' | 'related'
): Promise<void> { ): Promise<void> {
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type); this.getController(teamName).tasks.unlinkTask(
taskId,
targetId,
type === 'blockedBy' ? 'blocked-by' : type
);
} }
async addTaskComment( async addTaskComment(
@ -1095,28 +1006,40 @@ export class TeamDataService {
text: string, text: string,
attachments?: TaskAttachmentMeta[] attachments?: TaskAttachmentMeta[]
): Promise<TaskComment> { ): Promise<TaskComment> {
const comment = await this.taskWriter.addComment(teamName, taskId, text, { const controller = this.getController(teamName);
const addResult = controller.tasks.addTaskComment(taskId, {
text,
attachments, 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 { try {
const [tasks, config] = await Promise.all([ const [tasks, config] = await Promise.all([
this.taskReader.getTasks(teamName), this.taskReader.getTasks(teamName),
this.configReader.getConfig(teamName).catch(() => null), 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 leadName = this.resolveLeadNameFromConfig(config);
const owner = task?.owner?.trim() || null; const owner = task?.owner?.trim() || null;
// Auto-clear needsClarification: "user" on UI comment // Auto-clear needsClarification: "user" on UI comment
// UI comments always have author "user" (TeamTaskWriter default) // UI comments always have author "user" (TeamTaskWriter default)
if (task?.needsClarification === 'user') { if (task?.needsClarification === 'user') {
await this.taskWriter.setNeedsClarification(teamName, taskId, null); controller.tasks.setNeedsClarification(taskId, null);
} }
if (task && owner && !this.isLeadOwner(owner, leadName)) { if (task && owner && !this.isLeadOwner(owner, leadName)) {
// Notify non-lead task owner via inbox (lead → member message) // Notify non-lead task owner via inbox (lead → member message)
const parts = [ 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}`, `\n${AGENT_BLOCK_OPEN}`,
`Reply to this comment using MCP tool task_add_comment:`, `Reply to this comment using MCP tool task_add_comment:`,
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "<your-name>" }`, `{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "<your-name>" }`,
@ -1126,14 +1049,14 @@ export class TeamDataService {
member: owner, member: owner,
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `Comment on #${taskId}`, summary: `Comment on ${this.getTaskLabel(task)}`,
source: 'system_notification', source: 'system_notification',
}); });
} else if (task && owner && this.isLeadOwner(owner, leadName)) { } else if (task && owner && this.isLeadOwner(owner, leadName)) {
// Notify lead about user's comment on their own task. // Notify lead about user's comment on their own task.
// Write to lead's inbox — relay delivers to stdin when process is alive. // Write to lead's inbox — relay delivers to stdin when process is alive.
const parts = [ 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}`, `\n${AGENT_BLOCK_OPEN}`,
`Reply to this comment using MCP tool task_add_comment:`, `Reply to this comment using MCP tool task_add_comment:`,
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "${leadName}" }`, `{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "${leadName}" }`,
@ -1143,7 +1066,7 @@ export class TeamDataService {
member: leadName, member: leadName,
from: 'user', from: 'user',
text: parts.join('\n'), text: parts.join('\n'),
summary: `Comment on #${taskId}`, summary: `Comment on ${this.getTaskLabel(task)}`,
source: 'system_notification', source: 'system_notification',
}); });
} }
@ -1252,7 +1175,16 @@ export class TeamDataService {
} }
async requestReview(teamName: string, taskId: string): Promise<void> { 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 state = await this.kanbanManager.getState(teamName);
const reviewer = state.reviewers[0]; const reviewer = state.reviewers[0];
@ -1266,20 +1198,18 @@ export class TeamDataService {
member: reviewer, member: reviewer,
from: leadName, from: leadName,
text: text:
`Please review task #${taskId}.\n\n` + `Please review task ${this.getTaskLabel(task)}.\n\n` +
`${AGENT_BLOCK_OPEN}\n` + `${AGENT_BLOCK_OPEN}\n` +
`When approved, use MCP tool review_approve:\n` + `When approved, use MCP tool review_approve:\n` +
`{ teamName: "${teamName}", taskId: "${taskId}", notifyOwner: true }\n\n` + `{ teamName: "${teamName}", taskId: "${taskId}", notifyOwner: true }\n\n` +
`If changes are needed, use MCP tool review_request_changes:\n` + `If changes are needed, use MCP tool review_request_changes:\n` +
`{ teamName: "${teamName}", taskId: "${taskId}", comment: "..." }\n` + `{ teamName: "${teamName}", taskId: "${taskId}", comment: "..." }\n` +
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
summary: `Review request for #${taskId}`, summary: `Review request for ${this.getTaskLabel(task)}`,
source: 'system_notification', source: 'system_notification',
}); });
} catch (error) { } catch (error) {
await this.kanbanManager this.getController(teamName).kanban.clearKanban(taskId);
.updateTask(teamName, taskId, { op: 'remove' })
.catch(() => undefined);
throw error; throw error;
} }
} }
@ -1347,12 +1277,15 @@ export class TeamDataService {
tasks: TeamTask[], tasks: TeamTask[],
messages: InboxMessage[] messages: InboxMessage[]
): Promise<boolean> { ): Promise<boolean> {
const TASK_ID_PATTERN = /#(\d+)/g; const TASK_ID_PATTERN = /#([A-Za-z0-9-]+)/g;
let synced = false; let synced = false;
const tasksById = new Map<string, TeamTask>(); const tasksById = new Map<string, TeamTask>();
for (const t of tasks) { for (const t of tasks) {
tasksById.set(t.id, t); tasksById.set(t.id, t);
if (t.displayId) {
tasksById.set(t.displayId, t);
}
} }
// Dedup broadcasts: same sender + same text → process only once // Dedup broadcasts: same sender + same text → process only once
@ -1360,7 +1293,7 @@ export class TeamDataService {
function isAutomatedCommentNotification(msg: InboxMessage): boolean { function isAutomatedCommentNotification(msg: InboxMessage): boolean {
const summary = typeof msg.summary === 'string' ? msg.summary : ''; 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 : ''; const text = typeof msg.text === 'string' ? msg.text : '';
if (!text) return false; if (!text) return false;
// These are system-generated inbox messages that already correspond to a real task comment. // 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; if (existing.some((c) => c.id === commentId)) continue;
try { try {
await this.taskWriter.addComment(teamName, taskId, msg.text, { await this.taskWriter.addComment(teamName, task.id, msg.text, {
id: commentId, id: commentId,
author: msg.from, author: msg.from,
createdAt: msg.timestamp, createdAt: msg.timestamp,
@ -1569,74 +1502,32 @@ export class TeamDataService {
} }
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> { async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
if (patch.op !== 'request_changes') { const controller = this.getController(teamName);
// 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 previousState = await this.kanbanManager.getState(teamName); if (patch.op === 'remove') {
const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId]; controller.kanban.clearKanban(taskId);
return;
}
await this.kanbanManager.updateTask(teamName, taskId, patch); if (patch.op === 'set_column') {
if (patch.column === 'review') {
try { controller.kanban.setKanbanColumn(taskId, 'review');
await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user'); } else {
} catch (error) { const leadName = await this.resolveLeadName(teamName);
// Best-effort rollback of kanban move if task status update failed. controller.review.approveReview(taskId, {
if (previousKanbanEntry) { from: leadName,
await this.kanbanManager note: 'Approved from kanban',
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column }) 'notify-owner': true,
.catch(() => undefined); });
} else {
await this.kanbanManager
.updateTask(teamName, taskId, { op: 'remove' })
.catch(() => undefined);
}
throw error;
} }
return; return;
} }
const tasks = await this.taskReader.getTasks(teamName); const leadName = await this.resolveLeadName(teamName);
const task = tasks.find((candidate) => candidate.id === taskId); controller.review.requestChanges(taskId, {
if (!task?.owner) { from: leadName,
throw new Error(`No owner found for task ${taskId}`); comment: patch.comment?.trim() || 'Reviewer requested changes.',
} });
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;
}
} }
async updateKanbanColumnOrder( async updateKanbanColumnOrder(
@ -1644,6 +1535,6 @@ export class TeamDataService {
columnId: KanbanColumnId, columnId: KanbanColumnId,
orderedTaskIds: string[] orderedTaskIds: string[]
): Promise<void> { ): 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 { resolveLanguageName } from '@shared/utils/agentLanguage';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
@ -452,6 +453,9 @@ ${processRegistration}`;
function buildTaskStatusProtocol(teamName: string): string { function buildTaskStatusProtocol(teamName: string): string {
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 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: 1. Use MCP tool task_start to mark task started:
{ teamName: "${teamName}", taskId: "<taskId>" } { teamName: "${teamName}", taskId: "<taskId>" }
- Start the task ONLY when you are actually beginning work on it. - 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: 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>" } { 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. 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): 9. Review workflow clarity (IMPORTANT):
- The work task (e.g. #1) is the thing that must end up APPROVED after review. - 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). - 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>" } { 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: 3. VERIFY registration succeeded (MANDATORY never skip this step) using MCP tool process_list:
{ teamName: "${teamName}" } { 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> } { teamName: "${teamName}", pid: <PID> }
If verification in step 3 fails or the process is missing from the list, re-register it.`); 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 lines = activeTasks.map((t) => {
const desc = t.description ? `${t.description.slice(0, 120)}` : ''; const desc = t.description ? `${t.description.slice(0, 120)}` : '';
const deps = t.blockedBy?.length 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`; 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 owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
const desc = t.description ? `${t.description.slice(0, 120)}` : ''; const desc = t.description ? `${t.description.slice(0, 120)}` : '';
const deps = t.blockedBy?.length 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`; return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
} }
@ -1822,7 +1834,6 @@ export class TeamProvisioningService {
'--verbose', '--verbose',
'--setting-sources', '--setting-sources',
'user,project,local', 'user,project,local',
'--strict-mcp-config',
'--mcp-config', '--mcp-config',
mcpConfigPath, mcpConfigPath,
'--disallowedTools', '--disallowedTools',
@ -2154,7 +2165,6 @@ export class TeamProvisioningService {
'--verbose', '--verbose',
'--setting-sources', '--setting-sources',
'user,project,local', 'user,project,local',
'--strict-mcp-config',
'--mcp-config', '--mcp-config',
mcpConfigPath, mcpConfigPath,
'--disallowedTools', '--disallowedTools',

View file

@ -2,6 +2,7 @@ import { yieldToEventLoop } from '@main/utils/asyncYield';
import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTasksBasePath } from '@main/utils/pathDecoder'; import { getTasksBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -165,6 +166,14 @@ export class TeamTaskReader {
const task: TeamTask = { const task: TeamTask = {
id: id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.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, subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined, description: typeof parsed.description === 'string' ? parsed.description : undefined,
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : 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). // Sort by display ID first for stable human-facing ordering, then canonical id.
// Fall back to stable lexicographic ordering for unexpected non-numeric IDs.
tasks.sort((a, b) => { tasks.sort((a, b) => {
const aIsNumeric = /^\d+$/.test(a.id); const aLabel = a.displayId ?? a.id;
const bIsNumeric = /^\d+$/.test(b.id); const bLabel = b.displayId ?? b.id;
if (aIsNumeric && bIsNumeric) return Number(a.id) - Number(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 (aIsNumeric) return -1;
if (bIsNumeric) 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' }); return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' });
}); });
@ -347,6 +362,14 @@ export class TeamTaskReader {
const task: TeamTask = { const task: TeamTask = {
id: id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.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, subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined, description: typeof parsed.description === 'string' ? parsed.description : undefined,
owner: typeof parsed.owner === 'string' ? parsed.owner : 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: true; result: unknown; diag?: unknown }
| { id: string; ok: false; error: string }; | { 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 // Diagnostic types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -93,6 +102,7 @@ interface RawMember {
interface ParsedTask { interface ParsedTask {
id?: unknown; id?: unknown;
displayId?: unknown;
subject?: unknown; subject?: unknown;
title?: unknown; title?: unknown;
description?: unknown; description?: unknown;
@ -593,6 +603,14 @@ async function readTasksDirForTeam(
tasks.push({ tasks.push({
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', 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, subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined, description: typeof parsed.description === 'string' ? parsed.description : undefined,
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : 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 { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { import {
getNonEmptyTaskCategories, getNonEmptyTaskCategories,
groupTasksByDate, groupTasksByDate,
@ -222,7 +223,7 @@ export const GlobalTaskList = ({
const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => { const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => {
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Delete task', title: 'Delete task',
message: `Move task #${taskId} to trash?`, message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
confirmLabel: 'Delete', confirmLabel: 'Delete',
cancelLabel: 'Cancel', cancelLabel: 'Cancel',
variant: 'danger', 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -45,7 +46,7 @@ function getStatusLabel(column: string): string {
} }
interface TaskTooltipProps { interface TaskTooltipProps {
/** The task ID (number string, e.g. "10"). */ /** Canonical task id or short display id reference. */
taskId: string; taskId: string;
/** Rendered trigger element. */ /** Rendered trigger element. */
children: React.ReactElement; children: React.ReactElement;
@ -65,10 +66,7 @@ export const TaskTooltip = ({
const tasks = useStore((s) => s.selectedTeamData?.tasks); const tasks = useStore((s) => s.selectedTeamData?.tasks);
const members = useStore((s) => s.selectedTeamData?.members); const members = useStore((s) => s.selectedTeamData?.members);
const task = useMemo( const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]);
() => tasks?.find((t) => t.id === taskId),
[tasks, taskId]
);
const colorMap = useMemo( const colorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()), () => (members ? buildMemberColorMap(members) : new Map<string, string>()),
@ -85,13 +83,10 @@ export const TaskTooltip = ({
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent <TooltipContent side={side} className="max-w-xs space-y-1.5 p-2.5">
side={side}
className="max-w-xs space-y-1.5 p-2.5"
>
{/* Subject */} {/* Subject */}
<div className="text-xs font-medium text-[var(--color-text)]"> <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} {task.subject}
</div> </div>
@ -106,10 +101,7 @@ export const TaskTooltip = ({
{/* Owner */} {/* Owner */}
{task.owner ? ( {task.owner ? (
<MemberBadge <MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
name={task.owner}
color={colorMap.get(task.owner)}
/>
) : ( ) : (
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span> <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 { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
AlertTriangle, AlertTriangle,
Bell, Bell,
@ -114,12 +115,13 @@ interface TimeWindow {
function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] { function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] {
if (query.startsWith('#')) { if (query.startsWith('#')) {
const id = query.slice(1); 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(); const lower = query.toLowerCase();
return tasks.filter( return tasks.filter(
(t) => (t) =>
t.id.toLowerCase().includes(lower) || t.id.toLowerCase().includes(lower) ||
(t.displayId?.toLowerCase().includes(lower) ?? false) ||
t.subject.toLowerCase().includes(lower) || t.subject.toLowerCase().includes(lower) ||
(t.owner?.toLowerCase().includes(lower) ?? false) (t.owner?.toLowerCase().includes(lower) ?? false)
); );
@ -770,7 +772,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
void (async () => { void (async () => {
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Delete task', title: 'Delete task',
message: `Move task #${taskId} to trash?`, message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
confirmLabel: 'Delete', confirmLabel: 'Delete',
cancelLabel: 'Cancel', cancelLabel: 'Cancel',
variant: 'danger', variant: 'danger',
@ -1307,7 +1309,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
if (result.notifiedOwner && task?.owner) { if (result.notifiedOwner && task?.owner) {
await api.teams.processSend( await api.teams.processSend(
teamName, 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) { } else if (!result.notifiedOwner) {
const desc = task?.description?.trim() const desc = task?.description?.trim()
@ -1315,7 +1317,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
: ''; : '';
await api.teams.processSend( await api.teams.processSend(
teamName, 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 { } catch {
@ -1347,8 +1349,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
try { try {
await api.teams.sendMessage(teamName, { await api.teams.sendMessage(teamName, {
member: task.owner, member: task.owner,
text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`,
summary: `Task #${taskId} cancelled`, summary: `Task ${formatTaskDisplayLabel(task)} cancelled`,
}); });
} catch { } catch {
// best-effort // best-effort
@ -1363,7 +1365,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
: ''; : '';
await api.teams.processSend( await api.teams.processSend(
teamName, 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 { } catch {
// best-effort // best-effort
@ -1582,7 +1584,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onMessageVisible={handleMessageVisible} onMessageVisible={handleMessageVisible}
onRestartTeam={() => setLaunchDialogOpen(true)} onRestartTeam={() => setLaunchDialogOpen(true)}
onTaskIdClick={(taskId) => { onTaskIdClick={(taskId) => {
const task = taskMap.get(taskId); const task =
taskMap.get(taskId) ??
data.tasks.find((candidate) => candidate.displayId === taskId);
if (task) setSelectedTask(task); if (task) setSelectedTask(task);
}} }}
/> />

View file

@ -3,6 +3,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -100,7 +101,7 @@ export const ActiveTasksBlock = ({
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
title={task.subject} title={task.subject}
> >
#{task.id} {task.subject} {formatTaskDisplayLabel(task)} {task.subject}
</button> </button>
) : ( ) : (
<span <span
@ -108,7 +109,7 @@ export const ActiveTasksBlock = ({
style={{ border: `1px solid ${colors.border}40` }} style={{ border: `1px solid ${colors.border}40` }}
title={task.subject} title={task.subject}
> >
#{task.id} {task.subject} {formatTaskDisplayLabel(task)} {task.subject}
</span> </span>
))} ))}
</div> </div>

View file

@ -23,6 +23,7 @@ import {
} from '@renderer/utils/agentMessageFormatting'; } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react'; import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react';
@ -94,7 +95,9 @@ function getNoiseLabel(parsed: StructuredMessage): string | null {
const rawTaskId = parsed.taskId; const rawTaskId = parsed.taskId;
const taskId = const taskId =
typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null; 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; return null;
@ -132,8 +135,8 @@ const NoiseRow = ({
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [ const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
{ pattern: /^New task assigned to you:/, label: 'Task assignment' }, { pattern: /^New task assigned to you:/, label: 'Task assignment' },
{ pattern: /^Task #\d+\s+approved/, label: 'Task approved' }, { pattern: /^Task #[A-Za-z0-9-]+\s+approved/, label: 'Task approved' },
{ pattern: /^Task #\d+\s+needs fixes/, label: 'Review changes requested' }, { pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' },
]; ];
function getSystemMessageLabel(text: string): string | null { function getSystemMessageLabel(text: string): string | null {
@ -184,9 +187,9 @@ const AUTH_ERROR_PATTERNS = [
// Full message card — left colored border, name badge, collapsible content // 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 { 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)})`; 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[] { function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#\d+\b)/g).map((part, i) => { return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => {
const match = /^#(\d+)$/.exec(part); const match = /^#([A-Za-z0-9-]+)$/.exec(part);
if (!match) return <span key={i}>{part}</span>; if (!match) return <span key={i}>{part}</span>;
const taskId = match[1]; const taskId = match[1];
return ( return (

View file

@ -28,6 +28,7 @@ import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertTriangle, Search } from 'lucide-react'; import { AlertTriangle, Search } from 'lucide-react';
@ -373,7 +374,8 @@ export const CreateTaskDialog = ({
(t) => (t) =>
!blockedBySearch || !blockedBySearch ||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) || t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
t.id.includes(blockedBySearch) t.id.includes(blockedBySearch) ||
t.displayId?.includes(blockedBySearch)
) )
.map((t) => { .map((t) => {
const isSelected = blockedBy.includes(t.id); const isSelected = blockedBy.includes(t.id);
@ -401,7 +403,7 @@ export const CreateTaskDialog = ({
variant="secondary" variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal" className="shrink-0 px-1 py-0 text-[10px] font-normal"
> >
#{t.id} {formatTaskDisplayLabel(t)}
</Badge> </Badge>
<span className="truncate">{t.subject}</span> <span className="truncate">{t.subject}</span>
</button> </button>
@ -411,7 +413,8 @@ export const CreateTaskDialog = ({
</div> </div>
{blockedBy.length > 0 ? ( {blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300"> <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> </p>
) : null} ) : null}
</div> </div>
@ -442,7 +445,8 @@ export const CreateTaskDialog = ({
(t) => (t) =>
!relatedSearch || !relatedSearch ||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) || t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
t.id.includes(relatedSearch) t.id.includes(relatedSearch) ||
t.displayId?.includes(relatedSearch)
) )
.map((t) => { .map((t) => {
const isSelected = related.includes(t.id); const isSelected = related.includes(t.id);
@ -470,7 +474,7 @@ export const CreateTaskDialog = ({
variant="secondary" variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal" className="shrink-0 px-1 py-0 text-[10px] font-normal"
> >
#{t.id} {formatTaskDisplayLabel(t)}
</Badge> </Badge>
<span className="truncate">{t.subject}</span> <span className="truncate">{t.subject}</span>
</button> </button>
@ -480,7 +484,7 @@ export const CreateTaskDialog = ({
</div> </div>
{related.length > 0 ? ( {related.length > 0 ? (
<p className="text-[11px] text-purple-300"> <p className="text-[11px] text-purple-300">
Related: {related.map((id) => `#${id}`).join(', ')} Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</p> </p>
) : null} ) : null}
</div> </div>

View file

@ -15,6 +15,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';
@ -76,7 +77,7 @@ export const ReviewDialog = ({
<DialogContent className="sm:max-w-[420px]"> <DialogContent className="sm:max-w-[420px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Request Changes</DialogTitle> <DialogTitle>Request Changes</DialogTitle>
<DialogDescription>Task #{taskId}</DialogDescription> <DialogDescription>Task #{taskId ? deriveTaskDisplayId(taskId) : ''}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-2 py-2"> <div className="grid gap-2 py-2">

View file

@ -55,9 +55,9 @@ interface TaskCommentsSectionProps {
containerClassName?: string; 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 { 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. */ /** 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { markAsRead } from '@renderer/services/commentReadStorage'; import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { import {
buildMemberColorMap, buildMemberColorMap,
@ -755,10 +756,14 @@ export const TaskDetailDialog = ({
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25' : 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} cursor-pointer`} } cursor-pointer`}
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`} title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)} onClick={() => handleDependencyClick(id)}
> >
#{id} {depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button> </button>
); );
})} })}
@ -783,10 +788,14 @@ export const TaskDetailDialog = ({
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25' : 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
} cursor-pointer`} } cursor-pointer`}
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`} title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)} onClick={() => handleDependencyClick(id)}
> >
#{id} {depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button> </button>
); );
})} })}
@ -811,10 +820,16 @@ export const TaskDetailDialog = ({
key={`related:${currentTask.id}:${id}`} key={`related:${currentTask.id}:${id}`}
type="button" 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" 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)} onClick={() => handleDependencyClick(id)}
> >
#{id} {depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button> </button>
); );
})} })}
@ -831,10 +846,16 @@ export const TaskDetailDialog = ({
key={`related-by:${currentTask.id}:${id}`} key={`related-by:${currentTask.id}:${id}`}
type="button" 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" 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)} onClick={() => handleDependencyClick(id)}
> >
#{id} {depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button> </button>
); );
})} })}

View file

@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
ArrowLeftFromLine, ArrowLeftFromLine,
ArrowRightFromLine, ArrowRightFromLine,
@ -65,13 +66,17 @@ const DependencyBadge = ({
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25' : 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} ${onScrollToTask ? 'cursor-pointer' : ''}`} } ${onScrollToTask ? 'cursor-pointer' : ''}`}
title={depTask ? `#${taskId}: ${depTask.subject}` : `#${taskId}`} title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(taskId)}`
}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onScrollToTask?.(taskId); onScrollToTask?.(taskId);
}} }}
> >
#{taskId} {depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(taskId)}`}
</button> </button>
); );
}; };
@ -264,7 +269,7 @@ export const KanbanTaskCard = ({
}} }}
> >
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]"> <span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
#{task.id} {formatTaskDisplayLabel(task)}
</span> </span>
<div className="mb-2 pt-2"> <div className="mb-2 pt-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View file

@ -12,6 +12,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { RotateCcw, Trash2 } from 'lucide-react'; import { RotateCcw, Trash2 } from 'lucide-react';
@ -63,7 +64,9 @@ export const TrashDialog = ({
key={task.id} key={task.id}
className="border-b border-[var(--color-border-subtle)] last:border-0" 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)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]"> <td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? 'Unassigned'} {task.owner ?? 'Unassigned'}

View file

@ -6,6 +6,7 @@ import {
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TASK_STATUS_STYLES, TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -58,7 +59,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
onClick={() => onTaskClick?.(task)} onClick={() => onTaskClick?.(task)}
> >
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal"> <Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
#{task.id} {formatTaskDisplayLabel(task)}
</Badge> </Badge>
<span className="min-w-0 flex-1 truncate text-sm text-[var(--color-text)]"> <span className="min-w-0 flex-1 truncate text-sm text-[var(--color-text)]">
{task.subject} {task.subject}

View file

@ -1,4 +1,5 @@
import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers'; import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -12,7 +13,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
return ( return (
<tr className="border-t border-[var(--color-border)]"> <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-sm text-[var(--color-text)]">{task.subject}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]"> <td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.owner ?? 'Unassigned'} {task.owner ?? 'Unassigned'}
@ -24,7 +27,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
</td> </td>
<td className="px-3 py-2 text-xs"> <td className="px-3 py-2 text-xs">
{blockedByIds.length > 0 ? ( {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> <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"> <td className="px-3 py-2 text-xs">
{blocksIds.length > 0 ? ( {blocksIds.length > 0 ? (
<span className="text-blue-600 dark:text-blue-400"> <span className="text-blue-600 dark:text-blue-400">
{blocksIds.map((id) => `#${id}`).join(', ')} {blocksIds.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</span> </span>
) : ( ) : (
<span className="text-[var(--color-text-muted)]">{'\u2014'}</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 { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
@ -118,7 +119,8 @@ function detectClarificationNotifications(
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void { function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
// Delegate to main process for native OS notification (cross-platform, no permission needed) // 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 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 void api.teams
?.showMessageNotification({ ?.showMessageNotification({
@ -126,7 +128,7 @@ function fireClarificationNotification(task: GlobalTask, suppressToast: boolean)
teamDisplayName: task.teamDisplayName, teamDisplayName: task.teamDisplayName,
from: latestComment?.author || 'team-lead', from: latestComment?.author || 'team-lead',
to: 'user', to: 'user',
summary: `Clarification needed — Task #${task.id}`, summary: `Clarification needed — Task ${formatTaskDisplayLabel(task)}`,
body, body,
teamEventType: 'task_clarification', teamEventType: 'task_clarification',
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`, dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
@ -204,7 +206,7 @@ function fireStatusChangeNotification(
teamDisplayName: task.teamDisplayName, teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system', from: task.owner ?? 'system',
to: 'user', to: 'user',
summary: `Task #${task.id}: ${from}${to}`, summary: `Task ${formatTaskDisplayLabel(task)}: ${from}${to}`,
body: task.subject, body: task.subject,
teamEventType: 'task_status_change', teamEventType: 'task_status_change',
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`, 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. // Adding a field here without mapping it there will cause a compile error.
export interface TeamTask { export interface TeamTask {
id: string; id: string;
/** Human-friendly short task label shown in UI. Canonical identity remains `id`. */
displayId?: string;
subject: string; subject: string;
description?: string; description?: string;
activeForm?: 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 () => { it('rejects invalid task id', async () => {
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; 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; success: boolean;
}; };
expect(result.success).toBe(false); expect(result.success).toBe(false);

View file

@ -146,7 +146,7 @@ describe('TeamDataService', () => {
}); });
it('includes projectPath from config when creating a task', async () => { 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( const service = new TeamDataService(
{ {
@ -176,20 +176,28 @@ describe('TeamDataService', () => {
{ {
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined), 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' }); const result = await service.createTask('my-team', { subject: 'Test' });
expect(result.projectPath).toBe('/Users/dev/my-project'); expect(result.projectPath).toBe('/Users/dev/my-project');
expect(createTaskMock).toHaveBeenCalledWith( expect(createTaskMock).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({ projectPath: '/Users/dev/my-project' }) expect.objectContaining({ projectPath: '/Users/dev/my-project' })
); );
}); });
it('creates task with status pending when startImmediately is false', async () => { 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( const service = new TeamDataService(
{ {
listTeams: vi.fn(), listTeams: vi.fn(),
@ -214,7 +222,16 @@ describe('TeamDataService', () => {
{ {
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined), 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', { const result = await service.createTask('my-team', {
@ -225,13 +242,12 @@ describe('TeamDataService', () => {
expect(result.status).toBe('pending'); expect(result.status).toBe('pending');
expect(createTaskMock).toHaveBeenCalledWith( expect(createTaskMock).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' }) expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' })
); );
}); });
it('persists explicit related task links when creating a task', async () => { 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( const service = new TeamDataService(
{ {
listTeams: vi.fn(), listTeams: vi.fn(),
@ -256,7 +272,16 @@ describe('TeamDataService', () => {
{ {
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined), 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', { const result = await service.createTask('my-team', {
@ -265,9 +290,6 @@ describe('TeamDataService', () => {
}); });
expect(result.related).toEqual(['1', '2']); expect(result.related).toEqual(['1', '2']);
expect(createTaskMock).toHaveBeenCalledWith( expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] }));
'my-team',
expect.objectContaining({ related: ['1', '2'] })
);
}); });
}); });

View file

@ -116,8 +116,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).not.toContain('.claude/tools'); expect(prompt).not.toContain('.claude/tools');
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toContain('--strict-mcp-config');
expect(launchArgs).toContain('--mcp-config'); expect(launchArgs).toContain('--mcp-config');
expect(launchArgs).not.toContain('--strict-mcp-config');
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });
@ -176,8 +176,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).not.toContain('.claude/tools'); expect(prompt).not.toContain('.claude/tools');
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toContain('--strict-mcp-config');
expect(launchArgs).toContain('--mcp-config'); expect(launchArgs).toContain('--mcp-config');
expect(launchArgs).not.toContain('--strict-mcp-config');
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });