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:
parent
6a95eceb4f
commit
4cf330e8cc
41 changed files with 1575 additions and 406 deletions
0
agent-teams-controller/src/cli.js
Normal file → Executable file
0
agent-teams-controller/src/cli.js
Normal file → Executable file
|
|
@ -1,16 +1,19 @@
|
|||
const legacy = require('../legacy/teamctl.cli.js');
|
||||
const kanbanStore = require('./kanbanStore.js');
|
||||
const tasks = require('./tasks.js');
|
||||
|
||||
function getKanbanState(context) {
|
||||
return legacy.readKanbanState(context.paths, context.teamName);
|
||||
return kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
}
|
||||
|
||||
function setKanbanColumn(context, taskId, column) {
|
||||
legacy.setKanbanColumn(context.paths, context.teamName, String(taskId), String(column));
|
||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
|
||||
return getKanbanState(context);
|
||||
}
|
||||
|
||||
function clearKanban(context, taskId) {
|
||||
legacy.clearKanban(context.paths, context.teamName, String(taskId));
|
||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId);
|
||||
return getKanbanState(context);
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +25,7 @@ function addReviewer(context, reviewer) {
|
|||
const state = getKanbanState(context);
|
||||
const next = new Set(state.reviewers);
|
||||
next.add(String(reviewer));
|
||||
legacy.writeKanbanState(context.paths, {
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: [...next],
|
||||
});
|
||||
|
|
@ -32,13 +35,18 @@ function addReviewer(context, reviewer) {
|
|||
function removeReviewer(context, reviewer) {
|
||||
const state = getKanbanState(context);
|
||||
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
||||
legacy.writeKanbanState(context.paths, {
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: next,
|
||||
});
|
||||
return listReviewers(context);
|
||||
}
|
||||
|
||||
function updateColumnOrder(context, columnId, orderedTaskIds) {
|
||||
const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId));
|
||||
return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getKanbanState,
|
||||
setKanbanColumn,
|
||||
|
|
@ -46,4 +54,5 @@ module.exports = {
|
|||
listReviewers,
|
||||
addReviewer,
|
||||
removeReviewer,
|
||||
updateColumnOrder,
|
||||
};
|
||||
|
|
|
|||
112
agent-teams-controller/src/internal/kanbanStore.js
Normal file
112
agent-teams-controller/src/internal/kanbanStore.js
Normal 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,
|
||||
};
|
||||
159
agent-teams-controller/src/internal/processStore.js
Normal file
159
agent-teams-controller/src/internal/processStore.js
Normal 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,
|
||||
};
|
||||
|
|
@ -1,27 +1,25 @@
|
|||
const legacy = require('../legacy/teamctl.cli.js');
|
||||
const { captureStreamOutput } = require('./capture.js');
|
||||
const processStore = require('./processStore.js');
|
||||
|
||||
function registerProcess(context, flags) {
|
||||
captureStreamOutput(process.stdout, () => legacy.processRegister(context.paths, flags));
|
||||
return listProcesses(context).find((entry) => entry.pid === Number(flags.pid)) || null;
|
||||
return processStore.registerProcess(context.paths, flags);
|
||||
}
|
||||
|
||||
function unregisterProcess(context, flags) {
|
||||
captureStreamOutput(process.stdout, () => legacy.processUnregister(context.paths, flags));
|
||||
processStore.unregisterProcess(context.paths, flags);
|
||||
return listProcesses(context);
|
||||
}
|
||||
|
||||
function listProcesses(context) {
|
||||
return legacy.readProcessesSafe(context.paths.processesPath).map((entry) => ({
|
||||
...entry,
|
||||
alive: Number.isFinite(Number(entry && entry.pid))
|
||||
? legacy.isProcessAlive(Number(entry.pid))
|
||||
: false,
|
||||
}));
|
||||
return processStore.listProcesses(context.paths);
|
||||
}
|
||||
|
||||
function stopProcess(context, flags) {
|
||||
return processStore.stopProcess(context.paths, flags);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerProcess,
|
||||
stopProcess,
|
||||
unregisterProcess,
|
||||
listProcesses,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,67 @@
|
|||
const legacy = require('../legacy/teamctl.cli.js');
|
||||
const kanban = require('./kanban.js');
|
||||
const messages = require('./messages.js');
|
||||
const tasks = require('./tasks.js');
|
||||
|
||||
function approveReview(context, taskId, flags = {}) {
|
||||
legacy.reviewApprove(context.paths, context.teamName, String(taskId), flags);
|
||||
return tasks.getTask(context, taskId);
|
||||
const task = tasks.getTask(context, taskId);
|
||||
const from =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
|
||||
|
||||
kanban.setKanbanColumn(context, task.id, 'approved');
|
||||
tasks.addTaskComment(context, task.id, {
|
||||
text: note,
|
||||
from,
|
||||
type: 'review_approved',
|
||||
});
|
||||
|
||||
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
|
||||
messages.sendMessage(context, {
|
||||
to: task.owner,
|
||||
from,
|
||||
text:
|
||||
note && note !== 'Approved'
|
||||
? `Task ${task.displayId || task.id} approved.\n\n${note}`
|
||||
: `Task ${task.displayId || task.id} approved.`,
|
||||
summary: `Approved ${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
||||
return tasks.getTask(context, task.id);
|
||||
}
|
||||
|
||||
function requestChanges(context, taskId, flags = {}) {
|
||||
legacy.reviewRequestChanges(context.paths, context.teamName, String(taskId), flags);
|
||||
return tasks.getTask(context, taskId);
|
||||
const task = tasks.getTask(context, taskId);
|
||||
if (!task.owner) {
|
||||
throw new Error(`No owner found for task ${String(taskId)}`);
|
||||
}
|
||||
|
||||
const from =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const comment =
|
||||
typeof flags.comment === 'string' && flags.comment.trim()
|
||||
? flags.comment.trim()
|
||||
: 'Reviewer requested changes.';
|
||||
|
||||
kanban.clearKanban(context, task.id);
|
||||
tasks.setTaskStatus(context, task.id, 'in_progress', from);
|
||||
tasks.addTaskComment(context, task.id, {
|
||||
text: comment,
|
||||
from,
|
||||
type: 'review_request',
|
||||
});
|
||||
messages.sendMessage(context, {
|
||||
to: task.owner,
|
||||
from,
|
||||
text:
|
||||
`Task ${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` +
|
||||
'Please fix and mark it as completed when ready.',
|
||||
summary: `Fix request for ${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
|
||||
return tasks.getTask(context, task.id);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
|
|||
651
agent-teams-controller/src/internal/taskStore.js
Normal file
651
agent-teams-controller/src/internal/taskStore.js
Normal 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,
|
||||
};
|
||||
|
|
@ -1,21 +1,30 @@
|
|||
const legacy = require('../legacy/teamctl.cli.js');
|
||||
const { captureStreamOutput } = require('./capture.js');
|
||||
const taskStore = require('./taskStore.js');
|
||||
|
||||
function createTask(context, flags) {
|
||||
return legacy.createTask(context.paths, flags);
|
||||
function createTask(context, input) {
|
||||
return taskStore.createTask(context.paths, input);
|
||||
}
|
||||
|
||||
function getTask(context, taskId) {
|
||||
return legacy.readTask(context.paths, String(taskId)).task;
|
||||
return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function listTasks(context) {
|
||||
return legacy.listTaskIds(context.paths.tasksDir).map((taskId) => getTask(context, taskId));
|
||||
return taskStore.listTasks(context.paths);
|
||||
}
|
||||
|
||||
function listDeletedTasks(context) {
|
||||
return taskStore.listTasks(context.paths, { includeDeleted: true }).filter(
|
||||
(task) => task.status === 'deleted'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTaskId(context, taskRef) {
|
||||
return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function setTaskStatus(context, taskId, status, actor) {
|
||||
legacy.setTaskStatus(context.paths, String(taskId), String(status), actor);
|
||||
return getTask(context, taskId);
|
||||
return taskStore.setTaskStatus(context.paths, taskId, status, actor);
|
||||
}
|
||||
|
||||
function startTask(context, taskId, actor) {
|
||||
|
|
@ -26,70 +35,108 @@ function completeTask(context, taskId, actor) {
|
|||
return setTaskStatus(context, taskId, 'completed', actor);
|
||||
}
|
||||
|
||||
function softDeleteTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'deleted', actor);
|
||||
}
|
||||
|
||||
function restoreTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'pending', actor || 'user');
|
||||
}
|
||||
|
||||
function setTaskOwner(context, taskId, owner) {
|
||||
return legacy.setTaskOwner(
|
||||
context.paths,
|
||||
String(taskId),
|
||||
owner == null || owner === 'clear' || owner === 'none' ? null : String(owner)
|
||||
);
|
||||
return taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||
}
|
||||
|
||||
function updateTaskFields(context, taskId, fields) {
|
||||
return taskStore.updateTaskFields(context.paths, taskId, fields);
|
||||
}
|
||||
|
||||
function addTaskComment(context, taskId, flags) {
|
||||
const result = legacy.addTaskComment(context.paths, String(taskId), flags);
|
||||
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
||||
author:
|
||||
typeof flags.from === 'string' && flags.from.trim()
|
||||
? flags.from.trim()
|
||||
: legacy.inferLeadName(context.paths),
|
||||
...(flags.id ? { id: flags.id } : {}),
|
||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||
...(flags.type ? { type: flags.type } : {}),
|
||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
task: getTask(context, taskId),
|
||||
commentId: result.comment.id,
|
||||
taskId: result.task.id,
|
||||
subject: result.task.subject,
|
||||
owner: result.task.owner,
|
||||
task: result.task,
|
||||
comment: result.comment,
|
||||
};
|
||||
}
|
||||
|
||||
function attachTaskFile(context, taskId, flags) {
|
||||
const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags);
|
||||
legacy.addAttachmentToTask(context.paths, String(taskId), saved.meta);
|
||||
return saved.meta;
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = legacy.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
function attachCommentFile(context, taskId, commentId, flags) {
|
||||
const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags);
|
||||
legacy.addAttachmentToComment(context.paths, String(taskId), String(commentId), saved.meta);
|
||||
return saved.meta;
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = legacy.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
function addTaskAttachmentMeta(context, taskId, meta) {
|
||||
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
|
||||
}
|
||||
|
||||
function removeTaskAttachment(context, taskId, attachmentId) {
|
||||
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
|
||||
}
|
||||
|
||||
function setNeedsClarification(context, taskId, value) {
|
||||
const normalized = value == null ? 'clear' : String(value);
|
||||
legacy.setNeedsClarification(context.paths, String(taskId), normalized);
|
||||
return getTask(context, taskId);
|
||||
return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
|
||||
}
|
||||
|
||||
function linkTask(context, taskId, targetId, linkType) {
|
||||
legacy.linkTasks(context.paths, String(taskId), String(targetId), String(linkType));
|
||||
return getTask(context, taskId);
|
||||
return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
|
||||
}
|
||||
|
||||
function unlinkTask(context, taskId, targetId, linkType) {
|
||||
legacy.unlinkTasks(context.paths, String(taskId), String(targetId), String(linkType));
|
||||
return getTask(context, taskId);
|
||||
return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
|
||||
}
|
||||
|
||||
async function taskBriefing(context, memberName) {
|
||||
const { output } = await captureStreamOutput(process.stdout, () =>
|
||||
legacy.taskBriefing(context.paths, context.teamName, { for: memberName })
|
||||
);
|
||||
return output;
|
||||
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTask,
|
||||
getTask,
|
||||
listTasks,
|
||||
setTaskStatus,
|
||||
startTask,
|
||||
completeTask,
|
||||
setTaskOwner,
|
||||
addTaskAttachmentMeta,
|
||||
addTaskComment,
|
||||
attachTaskFile,
|
||||
attachCommentFile,
|
||||
setNeedsClarification,
|
||||
completeTask,
|
||||
createTask,
|
||||
getTask,
|
||||
linkTask,
|
||||
unlinkTask,
|
||||
listDeletedTasks,
|
||||
listTasks,
|
||||
removeTaskAttachment,
|
||||
resolveTaskId,
|
||||
restoreTask,
|
||||
setNeedsClarification,
|
||||
setTaskOwner,
|
||||
setTaskStatus,
|
||||
softDeleteTask,
|
||||
startTask,
|
||||
taskBriefing,
|
||||
unlinkTask,
|
||||
updateTaskFields,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,27 +30,30 @@ describe('agent-teams-controller API', () => {
|
|||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
controller.tasks.createTask({ subject: 'Base task' });
|
||||
controller.tasks.createTask({ subject: 'Dependency task' });
|
||||
const base = controller.tasks.createTask({ subject: 'Base task' });
|
||||
const dependency = controller.tasks.createTask({ subject: 'Dependency task' });
|
||||
const created = controller.tasks.createTask({
|
||||
subject: 'Blocked task',
|
||||
owner: 'bob',
|
||||
'blocked-by': '1,2',
|
||||
related: '1',
|
||||
'blocked-by': `${base.displayId},${dependency.displayId}`,
|
||||
related: base.displayId,
|
||||
});
|
||||
|
||||
expect(created.id).toBe('3');
|
||||
expect(created.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
expect(created.displayId).toHaveLength(8);
|
||||
expect(created.status).toBe('pending');
|
||||
expect(controller.tasks.getTask('1').blocks).toEqual(['3']);
|
||||
expect(controller.tasks.getTask('3').blockedBy).toEqual(['1', '2']);
|
||||
expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
|
||||
expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
|
||||
|
||||
controller.kanban.addReviewer('alice');
|
||||
controller.kanban.setKanbanColumn('3', 'review');
|
||||
controller.review.approveReview('3', { 'notify-owner': true, from: 'alice' });
|
||||
controller.kanban.setKanbanColumn(created.id, 'review');
|
||||
controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
|
||||
|
||||
const kanbanState = controller.kanban.getKanbanState();
|
||||
expect(kanbanState.reviewers).toEqual(['alice']);
|
||||
expect(kanbanState.tasks['3'].column).toBe('approved');
|
||||
expect(kanbanState.tasks[created.id].column).toBe('approved');
|
||||
|
||||
const proc = controller.processes.registerProcess({
|
||||
pid: process.pid,
|
||||
|
|
@ -59,5 +62,7 @@ describe('agent-teams-controller API', () => {
|
|||
});
|
||||
expect(proc.port).toBe(3000);
|
||||
expect(controller.processes.listProcesses()).toHaveLength(1);
|
||||
const stopped = controller.processes.stopProcess({ pid: process.pid });
|
||||
expect(typeof stopped.stoppedAt).toBe('string');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const prodDeps = Object.keys(pkg.dependencies || {})
|
|||
|
||||
// node-pty is a native addon that cannot be bundled by Rollup.
|
||||
// It must remain external and be loaded at runtime via require().
|
||||
const bundledDeps = prodDeps.filter(d => d !== 'node-pty')
|
||||
const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-controller')
|
||||
|
||||
// Rollup plugin: stub out native .node addon imports with empty modules.
|
||||
// ssh2 and cpu-features use optional native bindings that can't be bundled,
|
||||
|
|
|
|||
9
mcp-server/src/agent-teams-controller.d.ts
vendored
9
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -8,13 +8,20 @@ declare module 'agent-teams-controller' {
|
|||
createTask(flags: Record<string, unknown>): unknown;
|
||||
getTask(taskId: string): unknown;
|
||||
listTasks(): unknown[];
|
||||
listDeletedTasks(): unknown[];
|
||||
resolveTaskId(taskRef: string): string;
|
||||
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
|
||||
startTask(taskId: string, actor?: string): unknown;
|
||||
completeTask(taskId: string, actor?: string): unknown;
|
||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||
restoreTask(taskId: string, actor?: string): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null): unknown;
|
||||
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachCommentFile(taskId: string, commentId: string, flags: Record<string, unknown>): unknown;
|
||||
addTaskAttachmentMeta(taskId: string, meta: Record<string, unknown>): unknown;
|
||||
removeTaskAttachment(taskId: string, attachmentId: string): unknown;
|
||||
setNeedsClarification(taskId: string, value: string | null): unknown;
|
||||
linkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
|
|
@ -28,6 +35,7 @@ declare module 'agent-teams-controller' {
|
|||
listReviewers(): string[];
|
||||
addReviewer(reviewer: string): string[];
|
||||
removeReviewer(reviewer: string): string[];
|
||||
updateColumnOrder(columnId: string, orderedTaskIds: string[]): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerReviewApi {
|
||||
|
|
@ -41,6 +49,7 @@ declare module 'agent-teams-controller' {
|
|||
|
||||
export interface ControllerProcessApi {
|
||||
registerProcess(flags: Record<string, unknown>): unknown;
|
||||
stopProcess(flags: Record<string, unknown>): unknown;
|
||||
unregisterProcess(flags: Record<string, unknown>): unknown;
|
||||
listProcesses(): unknown[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { createController } from 'agent-teams-controller';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
|
||||
const { createController } = agentTeamsControllerModule;
|
||||
|
||||
export function getController(teamName: string, claudeDir?: string) {
|
||||
return createController({
|
||||
|
|
|
|||
|
|
@ -67,4 +67,15 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid })),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'process_stop',
|
||||
description: 'Mark a registered process as stopped while preserving history',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid })),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,15 +100,16 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(loadedTask.comments[0].attachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('covers process register/list/unregister without legacy stdout leaking into results', async () => {
|
||||
it('covers process register/list/stop without legacy stdout leaking into results', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'beta';
|
||||
const pid = process.pid;
|
||||
|
||||
const registered = parseJsonToolResult(
|
||||
await getTool('process_register').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
pid: 43210,
|
||||
pid,
|
||||
label: 'vite',
|
||||
command: 'pnpm dev',
|
||||
from: 'lead',
|
||||
|
|
@ -116,7 +117,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
})
|
||||
);
|
||||
|
||||
expect(registered.pid).toBe(43210);
|
||||
expect(registered.pid).toBe(pid);
|
||||
expect(registered.label).toBe('vite');
|
||||
|
||||
const listed = parseJsonToolResult(
|
||||
|
|
@ -127,16 +128,17 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].pid).toBe(43210);
|
||||
expect(listed[0].pid).toBe(pid);
|
||||
|
||||
const afterUnregister = parseJsonToolResult(
|
||||
await getTool('process_unregister').execute({
|
||||
const stopped = parseJsonToolResult(
|
||||
await getTool('process_stop').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
pid: 43210,
|
||||
pid,
|
||||
})
|
||||
);
|
||||
|
||||
expect(afterUnregister).toEqual([]);
|
||||
expect(stopped.pid).toBe(pid);
|
||||
expect(typeof stopped.stoppedAt).toBe('string');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"agent-teams-controller": "workspace:*",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
|
|
|
|||
|
|
@ -143,6 +143,9 @@ importers:
|
|||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
agent-teams-controller:
|
||||
specifier: workspace:*
|
||||
version: link:agent-teams-controller
|
||||
chokidar:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|||
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^\d{1,10}$/;
|
||||
const TASK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/;
|
||||
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
|
||||
|
|
@ -130,13 +130,13 @@ export function validateTeamName(teamName: unknown): ValidationResult<string> {
|
|||
}
|
||||
|
||||
export function validateTaskId(taskId: unknown): ValidationResult<string> {
|
||||
const basic = validateString(taskId, 'taskId', 16);
|
||||
const basic = validateString(taskId, 'taskId', 64);
|
||||
if (!basic.valid) {
|
||||
return basic;
|
||||
}
|
||||
|
||||
if (!TASK_ID_PATTERN.test(basic.value!)) {
|
||||
return { valid: false, error: 'taskId must contain only digits' };
|
||||
return { valid: false, error: 'taskId contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true, value: basic.value };
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
|||
import {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
getClaudeBasePath,
|
||||
getProjectsBasePath,
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
|
|
@ -16,8 +17,10 @@ import {
|
|||
} from '@shared/constants/agentBlocks';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -61,6 +64,9 @@ import type {
|
|||
ToolCallMeta,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { AgentTeamsController } from 'agent-teams-controller';
|
||||
|
||||
const { createController } = agentTeamsControllerModule;
|
||||
|
||||
const logger = createLogger('Service:TeamDataService');
|
||||
|
||||
|
|
@ -86,9 +92,22 @@ export class TeamDataService {
|
|||
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
|
||||
_legacyToolsInstaller: unknown = null,
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
||||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
||||
private readonly controllerFactory: (teamName: string) => AgentTeamsController = (teamName) =>
|
||||
createController({
|
||||
teamName,
|
||||
claudeDir: getClaudeBasePath(),
|
||||
})
|
||||
) {}
|
||||
|
||||
private getController(teamName: string): AgentTeamsController {
|
||||
return this.controllerFactory(teamName);
|
||||
}
|
||||
|
||||
private getTaskLabel(task: Pick<TeamTask, 'id' | 'displayId'>): string {
|
||||
return formatTaskDisplayLabel(task);
|
||||
}
|
||||
|
||||
async listTeams(): Promise<TeamSummary[]> {
|
||||
return this.configReader.listTeams();
|
||||
}
|
||||
|
|
@ -508,42 +527,7 @@ export class TeamDataService {
|
|||
private async processHealthTick(): Promise<void> {
|
||||
for (const teamName of this.processHealthTeams) {
|
||||
try {
|
||||
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||
let raw: unknown[];
|
||||
try {
|
||||
const stat = await fs.promises.stat(processesPath);
|
||||
if (!stat.isFile() || stat.size > MAX_PROCESSES_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
const content = await readFileUtf8WithTimeout(processesPath, 5_000);
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const processes = raw.filter(
|
||||
(p): p is TeamProcess =>
|
||||
!!p &&
|
||||
typeof p === 'object' &&
|
||||
'pid' in p &&
|
||||
typeof (p as TeamProcess).pid === 'number' &&
|
||||
(p as TeamProcess).pid > 0
|
||||
);
|
||||
|
||||
let dirty = false;
|
||||
for (const proc of processes) {
|
||||
if (!proc.stoppedAt && !isProcessAlive(proc.pid)) {
|
||||
proc.stoppedAt = new Date().toISOString();
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2));
|
||||
// atomicWrite triggers FileWatcher → team-change 'process' → UI refresh
|
||||
// No need to emit manually — FileWatcher handles it.
|
||||
}
|
||||
this.getController(teamName).processes.listProcesses();
|
||||
} catch {
|
||||
// best-effort per team
|
||||
}
|
||||
|
|
@ -551,54 +535,13 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
private async readProcesses(teamName: string): Promise<TeamProcess[]> {
|
||||
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||
let raw: unknown[];
|
||||
try {
|
||||
const stat = await fs.promises.stat(processesPath);
|
||||
if (!stat.isFile() || stat.size > MAX_PROCESSES_FILE_BYTES) {
|
||||
return [];
|
||||
}
|
||||
const content = await readFileUtf8WithTimeout(processesPath, 5_000);
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processes = raw.filter(
|
||||
(p): p is TeamProcess =>
|
||||
!!p &&
|
||||
typeof p === 'object' &&
|
||||
'pid' in p &&
|
||||
typeof (p as TeamProcess).pid === 'number' &&
|
||||
(p as TeamProcess).pid > 0
|
||||
);
|
||||
|
||||
let dirty = false;
|
||||
for (const proc of processes) {
|
||||
if (!proc.stoppedAt && !isProcessAlive(proc.pid)) {
|
||||
proc.stoppedAt = new Date().toISOString();
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
try {
|
||||
await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2));
|
||||
} catch {
|
||||
// best-effort write-back
|
||||
}
|
||||
}
|
||||
|
||||
return processes;
|
||||
return this.getController(teamName).processes.listProcesses() as TeamProcess[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a registered CLI process by PID (SIGTERM) and mark it as stopped in processes.json.
|
||||
*/
|
||||
async killProcess(teamName: string, pid: number): Promise<void> {
|
||||
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||
|
||||
// Try to kill the process (cross-platform: SIGTERM on Unix, taskkill on Windows)
|
||||
try {
|
||||
killProcessByPid(pid);
|
||||
|
|
@ -613,32 +556,10 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
// Update processes.json to set stoppedAt
|
||||
let raw: unknown[];
|
||||
try {
|
||||
const content = await readFileUtf8WithTimeout(processesPath, 5_000);
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
|
||||
this.getController(teamName).processes.stopProcess({ pid });
|
||||
} catch {
|
||||
return; // No processes file — nothing to update
|
||||
}
|
||||
|
||||
let dirty = false;
|
||||
for (const entry of raw) {
|
||||
if (
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'pid' in entry &&
|
||||
(entry as TeamProcess).pid === pid &&
|
||||
!(entry as TeamProcess).stoppedAt
|
||||
) {
|
||||
(entry as TeamProcess).stoppedAt = new Date().toISOString();
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
await atomicWriteAsync(processesPath, JSON.stringify(raw, null, 2));
|
||||
// Ignore missing persisted registry rows after OS-level stop.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -840,20 +761,9 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> {
|
||||
const nextId = await this.taskReader.getNextTaskId(teamName);
|
||||
|
||||
const controller = this.getController(teamName);
|
||||
const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||
const related = request.related?.filter((id) => id.length > 0 && id !== nextId) ?? [];
|
||||
|
||||
let description = request.description
|
||||
? `${request.subject}\n\n${request.description}`
|
||||
: request.subject;
|
||||
|
||||
if (request.prompt?.trim()) {
|
||||
description = description
|
||||
? `${description}\n\n---\nPrompt: ${request.prompt.trim()}`
|
||||
: `Prompt: ${request.prompt.trim()}`;
|
||||
}
|
||||
const related = request.related?.filter((id) => id.length > 0) ?? [];
|
||||
|
||||
let projectPath: string | undefined;
|
||||
try {
|
||||
|
|
@ -864,26 +774,16 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
const shouldStart = request.owner && request.startImmediately !== false;
|
||||
|
||||
const task: TeamTask = {
|
||||
id: nextId,
|
||||
const task = controller.tasks.createTask({
|
||||
subject: request.subject,
|
||||
description,
|
||||
owner: request.owner,
|
||||
...(request.description?.trim() ? { description: request.description.trim() } : {}),
|
||||
...(request.owner ? { owner: request.owner } : {}),
|
||||
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
||||
...(related.length > 0 ? { related } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
createdBy: 'user',
|
||||
status: shouldStart ? 'in_progress' : 'pending',
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
related: related.length > 0 ? related : undefined,
|
||||
projectPath,
|
||||
};
|
||||
|
||||
await this.taskWriter.createTask(teamName, task);
|
||||
|
||||
// Update blocks[] on each referenced task so the reverse link exists
|
||||
for (const depId of blockedBy) {
|
||||
await this.taskWriter.addBlocksEntry(teamName, depId, nextId);
|
||||
}
|
||||
...(shouldStart ? { status: 'in_progress' } : { status: 'pending' }),
|
||||
}) as TeamTask;
|
||||
|
||||
if (shouldStart && request.owner) {
|
||||
try {
|
||||
|
|
@ -893,7 +793,7 @@ export class TeamDataService {
|
|||
if (!this.isLeadOwner(request.owner, leadName)) {
|
||||
// Build notification with full context — inbox is the primary delivery
|
||||
// channel to agents (Claude Code monitors inbox via fs.watch)
|
||||
const parts = [`New task assigned to you: #${task.id} "${task.subject}".`];
|
||||
const parts = [`New task assigned to you: ${this.getTaskLabel(task)} "${task.subject}".`];
|
||||
|
||||
if (request.description?.trim()) {
|
||||
parts.push(`\nDescription:\n${request.description.trim()}`);
|
||||
|
|
@ -915,7 +815,7 @@ export class TeamDataService {
|
|||
member: request.owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `New task #${task.id} assigned`,
|
||||
summary: `New task ${this.getTaskLabel(task)} assigned`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
|
@ -937,7 +837,7 @@ export class TeamDataService {
|
|||
throw new Error(`Task #${taskId} is not pending (current: ${task.status})`);
|
||||
}
|
||||
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'user');
|
||||
this.getController(teamName).tasks.startTask(taskId, 'user');
|
||||
|
||||
if (task.owner) {
|
||||
try {
|
||||
|
|
@ -945,7 +845,7 @@ export class TeamDataService {
|
|||
|
||||
// Skip inbox notification when lead starts their own task (solo teams)
|
||||
if (!this.isLeadOwner(task.owner, leadName)) {
|
||||
const parts = [`Task #${task.id} "${task.subject}" has been started.`];
|
||||
const parts = [`Task ${this.getTaskLabel(task)} "${task.subject}" has been started.`];
|
||||
if (task.description?.trim()) {
|
||||
parts.push(`\nDetails:\n${task.description.trim()}`);
|
||||
}
|
||||
|
|
@ -959,7 +859,7 @@ export class TeamDataService {
|
|||
member: task.owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `Task #${task.id} started`,
|
||||
summary: `Task ${this.getTaskLabel(task)} started`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
|
@ -977,7 +877,7 @@ export class TeamDataService {
|
|||
status: TeamTaskStatus,
|
||||
actor?: string
|
||||
): Promise<void> {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, status, actor);
|
||||
this.getController(teamName).tasks.setTaskStatus(taskId, status, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1014,8 +914,8 @@ export class TeamDataService {
|
|||
await this.sendMessage(teamName, {
|
||||
member: leadName,
|
||||
from: last.actor,
|
||||
text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`,
|
||||
summary: `Task #${task.id} started`,
|
||||
text: `Task ${this.getTaskLabel(task)} "${task.subject}" has been started by ${last.actor}.`,
|
||||
summary: `Task ${this.getTaskLabel(task)} started`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -1024,11 +924,11 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
||||
await this.taskWriter.softDelete(teamName, taskId, 'user');
|
||||
this.getController(teamName).tasks.softDeleteTask(taskId, 'user');
|
||||
}
|
||||
|
||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
||||
await this.taskWriter.restoreTask(teamName, taskId, 'user');
|
||||
this.getController(teamName).tasks.restoreTask(taskId, 'user');
|
||||
}
|
||||
|
||||
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||
|
|
@ -1036,7 +936,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||
await this.taskWriter.updateOwner(teamName, taskId, owner);
|
||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
|
||||
}
|
||||
|
||||
async updateTaskFields(
|
||||
|
|
@ -1044,7 +944,7 @@ export class TeamDataService {
|
|||
taskId: string,
|
||||
fields: { subject?: string; description?: string }
|
||||
): Promise<void> {
|
||||
await this.taskWriter.updateFields(teamName, taskId, fields);
|
||||
this.getController(teamName).tasks.updateTaskFields(taskId, fields);
|
||||
}
|
||||
|
||||
async addTaskAttachment(
|
||||
|
|
@ -1052,7 +952,10 @@ export class TeamDataService {
|
|||
taskId: string,
|
||||
meta: TaskAttachmentMeta
|
||||
): Promise<void> {
|
||||
await this.taskWriter.addAttachment(teamName, taskId, meta);
|
||||
this.getController(teamName).tasks.addTaskAttachmentMeta(
|
||||
taskId,
|
||||
meta as unknown as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
async removeTaskAttachment(
|
||||
|
|
@ -1060,7 +963,7 @@ export class TeamDataService {
|
|||
taskId: string,
|
||||
attachmentId: string
|
||||
): Promise<void> {
|
||||
await this.taskWriter.removeAttachment(teamName, taskId, attachmentId);
|
||||
this.getController(teamName).tasks.removeTaskAttachment(taskId, attachmentId);
|
||||
}
|
||||
|
||||
async setTaskNeedsClarification(
|
||||
|
|
@ -1068,7 +971,7 @@ export class TeamDataService {
|
|||
taskId: string,
|
||||
value: 'lead' | 'user' | null
|
||||
): Promise<void> {
|
||||
await this.taskWriter.setNeedsClarification(teamName, taskId, value);
|
||||
this.getController(teamName).tasks.setNeedsClarification(taskId, value);
|
||||
}
|
||||
|
||||
async addTaskRelationship(
|
||||
|
|
@ -1077,7 +980,11 @@ export class TeamDataService {
|
|||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> {
|
||||
await this.taskWriter.addRelationship(teamName, taskId, targetId, type);
|
||||
this.getController(teamName).tasks.linkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
type === 'blockedBy' ? 'blocked-by' : type
|
||||
);
|
||||
}
|
||||
|
||||
async removeTaskRelationship(
|
||||
|
|
@ -1086,7 +993,11 @@ export class TeamDataService {
|
|||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
): Promise<void> {
|
||||
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
|
||||
this.getController(teamName).tasks.unlinkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
type === 'blockedBy' ? 'blocked-by' : type
|
||||
);
|
||||
}
|
||||
|
||||
async addTaskComment(
|
||||
|
|
@ -1095,28 +1006,40 @@ export class TeamDataService {
|
|||
text: string,
|
||||
attachments?: TaskAttachmentMeta[]
|
||||
): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text, {
|
||||
const controller = this.getController(teamName);
|
||||
const addResult = controller.tasks.addTaskComment(taskId, {
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
}) as { task?: TeamTask; comment?: TaskComment };
|
||||
const comment =
|
||||
addResult.comment ??
|
||||
({
|
||||
id: randomUUID(),
|
||||
author: 'user',
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: 'regular',
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
} as TaskComment);
|
||||
|
||||
try {
|
||||
const [tasks, config] = await Promise.all([
|
||||
this.taskReader.getTasks(teamName),
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
]);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
const task = addResult.task ?? tasks.find((t) => t.id === taskId);
|
||||
const leadName = this.resolveLeadNameFromConfig(config);
|
||||
const owner = task?.owner?.trim() || null;
|
||||
// Auto-clear needsClarification: "user" on UI comment
|
||||
// UI comments always have author "user" (TeamTaskWriter default)
|
||||
if (task?.needsClarification === 'user') {
|
||||
await this.taskWriter.setNeedsClarification(teamName, taskId, null);
|
||||
controller.tasks.setNeedsClarification(taskId, null);
|
||||
}
|
||||
|
||||
if (task && owner && !this.isLeadOwner(owner, leadName)) {
|
||||
// Notify non-lead task owner via inbox (lead → member message)
|
||||
const parts = [
|
||||
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
|
||||
`Comment on task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`,
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Reply to this comment using MCP tool task_add_comment:`,
|
||||
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "<your-name>" }`,
|
||||
|
|
@ -1126,14 +1049,14 @@ export class TeamDataService {
|
|||
member: owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
summary: `Comment on ${this.getTaskLabel(task)}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} else if (task && owner && this.isLeadOwner(owner, leadName)) {
|
||||
// Notify lead about user's comment on their own task.
|
||||
// Write to lead's inbox — relay delivers to stdin when process is alive.
|
||||
const parts = [
|
||||
`New comment from user on your task #${taskId} "${task.subject}":\n\n${text}`,
|
||||
`New comment from user on your task ${this.getTaskLabel(task)} "${task.subject}":\n\n${text}`,
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Reply to this comment using MCP tool task_add_comment:`,
|
||||
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "${leadName}" }`,
|
||||
|
|
@ -1143,7 +1066,7 @@ export class TeamDataService {
|
|||
member: leadName,
|
||||
from: 'user',
|
||||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
summary: `Comment on ${this.getTaskLabel(task)}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
|
@ -1252,7 +1175,16 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||
await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' });
|
||||
const tasks = await this.taskReader.getTasks(teamName);
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
if (task.status !== 'completed') {
|
||||
throw new Error(`Task ${this.getTaskLabel(task)} must be completed before review`);
|
||||
}
|
||||
|
||||
this.getController(teamName).kanban.setKanbanColumn(taskId, 'review');
|
||||
|
||||
const state = await this.kanbanManager.getState(teamName);
|
||||
const reviewer = state.reviewers[0];
|
||||
|
|
@ -1266,20 +1198,18 @@ export class TeamDataService {
|
|||
member: reviewer,
|
||||
from: leadName,
|
||||
text:
|
||||
`Please review task #${taskId}.\n\n` +
|
||||
`Please review task ${this.getTaskLabel(task)}.\n\n` +
|
||||
`${AGENT_BLOCK_OPEN}\n` +
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`{ teamName: "${teamName}", taskId: "${taskId}", notifyOwner: true }\n\n` +
|
||||
`If changes are needed, use MCP tool review_request_changes:\n` +
|
||||
`{ teamName: "${teamName}", taskId: "${taskId}", comment: "..." }\n` +
|
||||
AGENT_BLOCK_CLOSE,
|
||||
summary: `Review request for #${taskId}`,
|
||||
summary: `Review request for ${this.getTaskLabel(task)}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (error) {
|
||||
await this.kanbanManager
|
||||
.updateTask(teamName, taskId, { op: 'remove' })
|
||||
.catch(() => undefined);
|
||||
this.getController(teamName).kanban.clearKanban(taskId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -1347,12 +1277,15 @@ export class TeamDataService {
|
|||
tasks: TeamTask[],
|
||||
messages: InboxMessage[]
|
||||
): Promise<boolean> {
|
||||
const TASK_ID_PATTERN = /#(\d+)/g;
|
||||
const TASK_ID_PATTERN = /#([A-Za-z0-9-]+)/g;
|
||||
let synced = false;
|
||||
|
||||
const tasksById = new Map<string, TeamTask>();
|
||||
for (const t of tasks) {
|
||||
tasksById.set(t.id, t);
|
||||
if (t.displayId) {
|
||||
tasksById.set(t.displayId, t);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup broadcasts: same sender + same text → process only once
|
||||
|
|
@ -1360,7 +1293,7 @@ export class TeamDataService {
|
|||
|
||||
function isAutomatedCommentNotification(msg: InboxMessage): boolean {
|
||||
const summary = typeof msg.summary === 'string' ? msg.summary : '';
|
||||
if (!/^Comment on #\d+/.test(summary)) return false;
|
||||
if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false;
|
||||
const text = typeof msg.text === 'string' ? msg.text : '';
|
||||
if (!text) return false;
|
||||
// These are system-generated inbox messages that already correspond to a real task comment.
|
||||
|
|
@ -1396,7 +1329,7 @@ export class TeamDataService {
|
|||
if (existing.some((c) => c.id === commentId)) continue;
|
||||
|
||||
try {
|
||||
await this.taskWriter.addComment(teamName, taskId, msg.text, {
|
||||
await this.taskWriter.addComment(teamName, task.id, msg.text, {
|
||||
id: commentId,
|
||||
author: msg.from,
|
||||
createdAt: msg.timestamp,
|
||||
|
|
@ -1569,74 +1502,32 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
|
||||
if (patch.op !== 'request_changes') {
|
||||
// Keep kanban + task.status consistent:
|
||||
// - moving a task into kanban review/approved implies the work is complete
|
||||
// - request_changes already moves it back to in_progress and clears kanban entry
|
||||
if (patch.op !== 'set_column') {
|
||||
await this.kanbanManager.updateTask(teamName, taskId, patch);
|
||||
return;
|
||||
}
|
||||
const controller = this.getController(teamName);
|
||||
|
||||
const previousState = await this.kanbanManager.getState(teamName);
|
||||
const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId];
|
||||
if (patch.op === 'remove') {
|
||||
controller.kanban.clearKanban(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.kanbanManager.updateTask(teamName, taskId, patch);
|
||||
|
||||
try {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user');
|
||||
} catch (error) {
|
||||
// Best-effort rollback of kanban move if task status update failed.
|
||||
if (previousKanbanEntry) {
|
||||
await this.kanbanManager
|
||||
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
await this.kanbanManager
|
||||
.updateTask(teamName, taskId, { op: 'remove' })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
if (patch.op === 'set_column') {
|
||||
if (patch.column === 'review') {
|
||||
controller.kanban.setKanbanColumn(taskId, 'review');
|
||||
} else {
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
controller.review.approveReview(taskId, {
|
||||
from: leadName,
|
||||
note: 'Approved from kanban',
|
||||
'notify-owner': true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = await this.taskReader.getTasks(teamName);
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task?.owner) {
|
||||
throw new Error(`No owner found for task ${taskId}`);
|
||||
}
|
||||
|
||||
const previousStatus: TeamTaskStatus = task.status;
|
||||
const previousState = await this.kanbanManager.getState(teamName);
|
||||
const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId];
|
||||
|
||||
await this.kanbanManager.updateTask(teamName, taskId, { op: 'remove' });
|
||||
|
||||
try {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'reviewer');
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
from: leadName,
|
||||
text:
|
||||
`Task #${taskId} needs fixes.\n\n` +
|
||||
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +
|
||||
`Please fix and mark it as completed when ready.`,
|
||||
summary: `Fix request for #${taskId}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (error) {
|
||||
await this.taskWriter
|
||||
.updateStatus(teamName, taskId, previousStatus, 'system')
|
||||
.catch(() => undefined);
|
||||
if (previousKanbanEntry) {
|
||||
await this.kanbanManager
|
||||
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
controller.review.requestChanges(taskId, {
|
||||
from: leadName,
|
||||
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
||||
});
|
||||
}
|
||||
|
||||
async updateKanbanColumnOrder(
|
||||
|
|
@ -1644,6 +1535,6 @@ export class TeamDataService {
|
|||
columnId: KanbanColumnId,
|
||||
orderedTaskIds: string[]
|
||||
): Promise<void> {
|
||||
await this.kanbanManager.updateColumnOrder(teamName, columnId, orderedTaskIds);
|
||||
this.getController(teamName).kanban.updateColumnOrder(columnId, orderedTaskIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
|
|||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||
import { spawn } from 'child_process';
|
||||
|
|
@ -452,6 +453,9 @@ ${processRegistration}`;
|
|||
|
||||
function buildTaskStatusProtocol(teamName: string): string {
|
||||
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
0. IMPORTANT ID RULE:
|
||||
- In MCP tool calls, ALWAYS use the exact canonical taskId value shown in the board/task snapshot.
|
||||
- Human-facing summaries may use the short display label like #abcd1234 for readability.
|
||||
1. Use MCP tool task_start to mark task started:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>" }
|
||||
- Start the task ONLY when you are actually beginning work on it.
|
||||
|
|
@ -469,7 +473,7 @@ function buildTaskStatusProtocol(teamName: string): string {
|
|||
7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
|
||||
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
|
||||
8. When sending a message about a specific task, include #<taskId> in your SendMessage summary field for traceability.
|
||||
8. When sending a message about a specific task, include its short display label like #<displayId> in your SendMessage summary field for traceability.
|
||||
9. Review workflow clarity (IMPORTANT):
|
||||
- The work task (e.g. #1) is the thing that must end up APPROVED after review.
|
||||
- If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task).
|
||||
|
|
@ -501,7 +505,7 @@ function buildProcessRegistrationProtocol(teamName: string): string {
|
|||
{ teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" }
|
||||
3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list:
|
||||
{ teamName: "${teamName}" }
|
||||
4. When stopping a process, use MCP tool process_unregister:
|
||||
4. When stopping a process, use MCP tool process_stop:
|
||||
{ teamName: "${teamName}", pid: <PID> }
|
||||
If verification in step 3 fails or the process is missing from the list, re-register it.`);
|
||||
}
|
||||
|
|
@ -703,9 +707,13 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string
|
|||
const lines = activeTasks.map((t) => {
|
||||
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
||||
const deps = t.blockedBy?.length
|
||||
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
|
||||
? ` [blocked by: ${t.blockedBy
|
||||
.map((id) => tasks.find((candidate) => candidate.id === id))
|
||||
.filter((task): task is TeamTask => Boolean(task))
|
||||
.map((task) => formatTaskDisplayLabel(task))
|
||||
.join(', ')}]`
|
||||
: '';
|
||||
return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`;
|
||||
return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}] ${t.subject}${deps}${desc}`;
|
||||
});
|
||||
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
|
@ -721,9 +729,13 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
|||
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
|
||||
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
||||
const deps = t.blockedBy?.length
|
||||
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
|
||||
? ` [blocked by: ${t.blockedBy
|
||||
.map((id) => tasks.find((candidate) => candidate.id === id))
|
||||
.filter((task): task is TeamTask => Boolean(task))
|
||||
.map((task) => formatTaskDisplayLabel(task))
|
||||
.join(', ')}]`
|
||||
: '';
|
||||
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`;
|
||||
return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}]${owner} ${t.subject}${deps}${desc}`;
|
||||
});
|
||||
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
|
@ -1822,7 +1834,6 @@ export class TeamProvisioningService {
|
|||
'--verbose',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--strict-mcp-config',
|
||||
'--mcp-config',
|
||||
mcpConfigPath,
|
||||
'--disallowedTools',
|
||||
|
|
@ -2154,7 +2165,6 @@ export class TeamProvisioningService {
|
|||
'--verbose',
|
||||
'--setting-sources',
|
||||
'user,project,local',
|
||||
'--strict-mcp-config',
|
||||
'--mcp-config',
|
||||
mcpConfigPath,
|
||||
'--disallowedTools',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { yieldToEventLoop } from '@main/utils/asyncYield';
|
|||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
|
|
@ -165,6 +166,14 @@ export class TeamTaskReader {
|
|||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
displayId:
|
||||
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
|
||||
? parsed.displayId.trim()
|
||||
: deriveTaskDisplayId(
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number'
|
||||
? String(parsed.id)
|
||||
: ''
|
||||
),
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||
|
|
@ -280,14 +289,20 @@ export class TeamTaskReader {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11).
|
||||
// Fall back to stable lexicographic ordering for unexpected non-numeric IDs.
|
||||
// Sort by display ID first for stable human-facing ordering, then canonical id.
|
||||
tasks.sort((a, b) => {
|
||||
const aIsNumeric = /^\d+$/.test(a.id);
|
||||
const bIsNumeric = /^\d+$/.test(b.id);
|
||||
if (aIsNumeric && bIsNumeric) return Number(a.id) - Number(b.id);
|
||||
const aLabel = a.displayId ?? a.id;
|
||||
const bLabel = b.displayId ?? b.id;
|
||||
const aIsNumeric = /^\d+$/.test(aLabel);
|
||||
const bIsNumeric = /^\d+$/.test(bLabel);
|
||||
if (aIsNumeric && bIsNumeric) return Number(aLabel) - Number(bLabel);
|
||||
if (aIsNumeric) return -1;
|
||||
if (bIsNumeric) return 1;
|
||||
const byDisplay = aLabel.localeCompare(bLabel, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
|
|
@ -347,6 +362,14 @@ export class TeamTaskReader {
|
|||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
displayId:
|
||||
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
|
||||
? parsed.displayId.trim()
|
||||
: deriveTaskDisplayId(
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number'
|
||||
? String(parsed.id)
|
||||
: ''
|
||||
),
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ type WorkerResponse =
|
|||
| { id: string; ok: true; result: unknown; diag?: unknown }
|
||||
| { id: string; ok: false; error: string };
|
||||
|
||||
const UUID_TASK_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function deriveTaskDisplayId(taskId: string): string {
|
||||
const normalized = taskId.trim();
|
||||
if (!normalized) return normalized;
|
||||
return UUID_TASK_ID_PATTERN.test(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagnostic types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -93,6 +102,7 @@ interface RawMember {
|
|||
|
||||
interface ParsedTask {
|
||||
id?: unknown;
|
||||
displayId?: unknown;
|
||||
subject?: unknown;
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
|
|
@ -593,6 +603,14 @@ async function readTasksDirForTeam(
|
|||
|
||||
tasks.push({
|
||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
displayId:
|
||||
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
|
||||
? parsed.displayId.trim()
|
||||
: deriveTaskDisplayId(
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number'
|
||||
? String(parsed.id)
|
||||
: ''
|
||||
),
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { cn } from '@renderer/lib/utils';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
getNonEmptyTaskCategories,
|
||||
groupTasksByDate,
|
||||
|
|
@ -222,7 +223,7 @@ export const GlobalTaskList = ({
|
|||
const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete task',
|
||||
message: `Move task #${taskId} to trash?`,
|
||||
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ function getStatusLabel(column: string): string {
|
|||
}
|
||||
|
||||
interface TaskTooltipProps {
|
||||
/** The task ID (number string, e.g. "10"). */
|
||||
/** Canonical task id or short display id reference. */
|
||||
taskId: string;
|
||||
/** Rendered trigger element. */
|
||||
children: React.ReactElement;
|
||||
|
|
@ -65,10 +66,7 @@ export const TaskTooltip = ({
|
|||
const tasks = useStore((s) => s.selectedTeamData?.tasks);
|
||||
const members = useStore((s) => s.selectedTeamData?.members);
|
||||
|
||||
const task = useMemo(
|
||||
() => tasks?.find((t) => t.id === taskId),
|
||||
[tasks, taskId]
|
||||
);
|
||||
const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]);
|
||||
|
||||
const colorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
|
|
@ -85,13 +83,10 @@ export const TaskTooltip = ({
|
|||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
className="max-w-xs space-y-1.5 p-2.5"
|
||||
>
|
||||
<TooltipContent side={side} className="max-w-xs space-y-1.5 p-2.5">
|
||||
{/* Subject */}
|
||||
<div className="text-xs font-medium text-[var(--color-text)]">
|
||||
<span className="text-[var(--color-text-muted)]">#{taskId}</span>{' '}
|
||||
<span className="text-[var(--color-text-muted)]">{formatTaskDisplayLabel(task)}</span>{' '}
|
||||
{task.subject}
|
||||
</div>
|
||||
|
||||
|
|
@ -106,10 +101,7 @@ export const TaskTooltip = ({
|
|||
|
||||
{/* Owner */}
|
||||
{task.owner ? (
|
||||
<MemberBadge
|
||||
name={task.owner}
|
||||
color={colorMap.get(task.owner)}
|
||||
/>
|
||||
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
|||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
|
|
@ -114,12 +115,13 @@ interface TimeWindow {
|
|||
function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] {
|
||||
if (query.startsWith('#')) {
|
||||
const id = query.slice(1);
|
||||
return tasks.filter((t) => t.id === id);
|
||||
return tasks.filter((t) => t.id === id || t.displayId === id);
|
||||
}
|
||||
const lower = query.toLowerCase();
|
||||
return tasks.filter(
|
||||
(t) =>
|
||||
t.id.toLowerCase().includes(lower) ||
|
||||
(t.displayId?.toLowerCase().includes(lower) ?? false) ||
|
||||
t.subject.toLowerCase().includes(lower) ||
|
||||
(t.owner?.toLowerCase().includes(lower) ?? false)
|
||||
);
|
||||
|
|
@ -770,7 +772,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
void (async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete task',
|
||||
message: `Move task #${taskId} to trash?`,
|
||||
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
|
|
@ -1307,7 +1309,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
if (result.notifiedOwner && task?.owner) {
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task #${taskId} "${task.subject}" has started. Please begin working on it.`
|
||||
`Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.`
|
||||
);
|
||||
} else if (!result.notifiedOwner) {
|
||||
const desc = task?.description?.trim()
|
||||
|
|
@ -1315,7 +1317,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
: '';
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.`
|
||||
`Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1347,8 +1349,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
try {
|
||||
await api.teams.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`,
|
||||
summary: `Task #${taskId} cancelled`,
|
||||
text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`,
|
||||
summary: `Task ${formatTaskDisplayLabel(task)} cancelled`,
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
@ -1363,7 +1365,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
: '';
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}`
|
||||
`Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}`
|
||||
);
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
@ -1582,7 +1584,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onMessageVisible={handleMessageVisible}
|
||||
onRestartTeam={() => setLaunchDialogOpen(true)}
|
||||
onTaskIdClick={(taskId) => {
|
||||
const task = taskMap.get(taskId);
|
||||
const task =
|
||||
taskMap.get(taskId) ??
|
||||
data.tasks.find((candidate) => candidate.displayId === taskId);
|
||||
if (task) setSelectedTask(task);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -100,7 +101,7 @@ export const ActiveTasksBlock = ({
|
|||
onClick={() => onTaskClick(task)}
|
||||
title={task.subject}
|
||||
>
|
||||
#{task.id} {task.subject}
|
||||
{formatTaskDisplayLabel(task)} {task.subject}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
|
|
@ -108,7 +109,7 @@ export const ActiveTasksBlock = ({
|
|||
style={{ border: `1px solid ${colors.border}40` }}
|
||||
title={task.subject}
|
||||
>
|
||||
#{task.id} {task.subject}
|
||||
{formatTaskDisplayLabel(task)} {task.subject}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react';
|
||||
|
|
@ -94,7 +95,9 @@ function getNoiseLabel(parsed: StructuredMessage): string | null {
|
|||
const rawTaskId = parsed.taskId;
|
||||
const taskId =
|
||||
typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null;
|
||||
return taskId !== null ? `Completed task #${taskId}` : 'Completed a task';
|
||||
return taskId !== null
|
||||
? `Completed task ${formatTaskDisplayLabel({ id: String(taskId) })}`
|
||||
: 'Completed a task';
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -132,8 +135,8 @@ const NoiseRow = ({
|
|||
|
||||
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
|
||||
{ pattern: /^New task assigned to you:/, label: 'Task assignment' },
|
||||
{ pattern: /^Task #\d+\s+approved/, label: 'Task approved' },
|
||||
{ pattern: /^Task #\d+\s+needs fixes/, label: 'Review changes requested' },
|
||||
{ pattern: /^Task #[A-Za-z0-9-]+\s+approved/, label: 'Task approved' },
|
||||
{ pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' },
|
||||
];
|
||||
|
||||
function getSystemMessageLabel(text: string): string | null {
|
||||
|
|
@ -184,9 +187,9 @@ const AUTH_ERROR_PATTERNS = [
|
|||
// Full message card — left colored border, name badge, collapsible content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||
/** Convert `#<task-display-id>` in plain text to markdown links with task:// protocol. */
|
||||
export function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
|
||||
return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -211,10 +214,10 @@ export function linkifyMentionsInMarkdown(
|
|||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
}
|
||||
/** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
|
||||
/** Render `#<task-display-id>` in plain text as clickable inline elements with TaskTooltip. */
|
||||
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
||||
return text.split(/(#\d+\b)/g).map((part, i) => {
|
||||
const match = /^#(\d+)$/.exec(part);
|
||||
return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => {
|
||||
const match = /^#([A-Za-z0-9-]+)$/.exec(part);
|
||||
if (!match) return <span key={i}>{part}</span>;
|
||||
const taskId = match[1];
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { useStore } from '@renderer/store';
|
|||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { AlertTriangle, Search } from 'lucide-react';
|
||||
|
||||
|
|
@ -373,7 +374,8 @@ export const CreateTaskDialog = ({
|
|||
(t) =>
|
||||
!blockedBySearch ||
|
||||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
|
||||
t.id.includes(blockedBySearch)
|
||||
t.id.includes(blockedBySearch) ||
|
||||
t.displayId?.includes(blockedBySearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = blockedBy.includes(t.id);
|
||||
|
|
@ -401,7 +403,7 @@ export const CreateTaskDialog = ({
|
|||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
#{t.id}
|
||||
{formatTaskDisplayLabel(t)}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
|
|
@ -411,7 +413,8 @@ export const CreateTaskDialog = ({
|
|||
</div>
|
||||
{blockedBy.length > 0 ? (
|
||||
<p className="text-[11px] text-yellow-300">
|
||||
Task will be blocked by: {blockedBy.map((id) => `#${id}`).join(', ')}
|
||||
Task will be blocked by:{' '}
|
||||
{blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -442,7 +445,8 @@ export const CreateTaskDialog = ({
|
|||
(t) =>
|
||||
!relatedSearch ||
|
||||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
|
||||
t.id.includes(relatedSearch)
|
||||
t.id.includes(relatedSearch) ||
|
||||
t.displayId?.includes(relatedSearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = related.includes(t.id);
|
||||
|
|
@ -470,7 +474,7 @@ export const CreateTaskDialog = ({
|
|||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
#{t.id}
|
||||
{formatTaskDisplayLabel(t)}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
|
|
@ -480,7 +484,7 @@ export const CreateTaskDialog = ({
|
|||
</div>
|
||||
{related.length > 0 ? (
|
||||
<p className="text-[11px] text-purple-300">
|
||||
Related: {related.map((id) => `#${id}`).join(', ')}
|
||||
Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
|
@ -76,7 +77,7 @@ export const ReviewDialog = ({
|
|||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Request Changes</DialogTitle>
|
||||
<DialogDescription>Task #{taskId}</DialogDescription>
|
||||
<DialogDescription>Task #{taskId ? deriveTaskDisplayId(taskId) : ''}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-2 py-2">
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@ interface TaskCommentsSectionProps {
|
|||
containerClassName?: string;
|
||||
}
|
||||
|
||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||
/** Convert `#<task-display-id>` in plain text to markdown links with task:// protocol. */
|
||||
function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
|
||||
return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { Textarea } from '@renderer/components/ui/textarea';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||
import {
|
||||
buildMemberColorMap,
|
||||
|
|
@ -755,10 +756,14 @@ export const TaskDetailDialog = ({
|
|||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
|
||||
} cursor-pointer`}
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
title={
|
||||
depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(id)}`
|
||||
}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -783,10 +788,14 @@ export const TaskDetailDialog = ({
|
|||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
||||
} cursor-pointer`}
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
title={
|
||||
depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(id)}`
|
||||
}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -811,10 +820,16 @@ export const TaskDetailDialog = ({
|
|||
key={`related:${currentTask.id}:${id}`}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
title={
|
||||
depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(id)}`
|
||||
}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
{depTask
|
||||
? formatTaskDisplayLabel(depTask)
|
||||
: `#${deriveTaskDisplayId(id)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -831,10 +846,16 @@ export const TaskDetailDialog = ({
|
|||
key={`related-by:${currentTask.id}:${id}`}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
title={
|
||||
depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(id)}`
|
||||
}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
{depTask
|
||||
? formatTaskDisplayLabel(depTask)
|
||||
: `#${deriveTaskDisplayId(id)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
ArrowLeftFromLine,
|
||||
ArrowRightFromLine,
|
||||
|
|
@ -65,13 +66,17 @@ const DependencyBadge = ({
|
|||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
|
||||
} ${onScrollToTask ? 'cursor-pointer' : ''}`}
|
||||
title={depTask ? `#${taskId}: ${depTask.subject}` : `#${taskId}`}
|
||||
title={
|
||||
depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(taskId)}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onScrollToTask?.(taskId);
|
||||
}}
|
||||
>
|
||||
#{taskId}
|
||||
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(taskId)}`}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -264,7 +269,7 @@ export const KanbanTaskCard = ({
|
|||
}}
|
||||
>
|
||||
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
|
||||
#{task.id}
|
||||
{formatTaskDisplayLabel(task)}
|
||||
</span>
|
||||
<div className="mb-2 pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { RotateCcw, Trash2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -63,7 +64,9 @@ export const TrashDialog = ({
|
|||
key={task.id}
|
||||
className="border-b border-[var(--color-border-subtle)] last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
|
||||
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
|
||||
{formatTaskDisplayLabel(task)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
|
||||
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
|
||||
{task.owner ?? 'Unassigned'}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
|||
onClick={() => onTaskClick?.(task)}
|
||||
>
|
||||
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
{formatTaskDisplayLabel(task)}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -12,7 +13,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
|||
|
||||
return (
|
||||
<tr className="border-t border-[var(--color-border)]">
|
||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
|
||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{formatTaskDisplayLabel(task)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
|
||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{task.owner ?? 'Unassigned'}
|
||||
|
|
@ -24,7 +27,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
|||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{blockedByIds.length > 0 ? (
|
||||
<span className="text-yellow-300">{blockedByIds.map((id) => `#${id}`).join(', ')}</span>
|
||||
<span className="text-yellow-300">
|
||||
{blockedByIds.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">{'\u2014'}</span>
|
||||
)}
|
||||
|
|
@ -32,7 +37,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
|||
<td className="px-3 py-2 text-xs">
|
||||
{blocksIds.length > 0 ? (
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{blocksIds.map((id) => `#${id}`).join(', ')}
|
||||
{blocksIds.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">{'\u2014'}</span>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { api } from '@renderer/api';
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
||||
|
|
@ -118,7 +119,8 @@ function detectClarificationNotifications(
|
|||
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
|
||||
// Delegate to main process for native OS notification (cross-platform, no permission needed)
|
||||
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
|
||||
const body = latestComment?.text || task.description || `Task #${task.id}: ${task.subject}`;
|
||||
const body =
|
||||
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
|
||||
|
||||
void api.teams
|
||||
?.showMessageNotification({
|
||||
|
|
@ -126,7 +128,7 @@ function fireClarificationNotification(task: GlobalTask, suppressToast: boolean)
|
|||
teamDisplayName: task.teamDisplayName,
|
||||
from: latestComment?.author || 'team-lead',
|
||||
to: 'user',
|
||||
summary: `Clarification needed — Task #${task.id}`,
|
||||
summary: `Clarification needed — Task ${formatTaskDisplayLabel(task)}`,
|
||||
body,
|
||||
teamEventType: 'task_clarification',
|
||||
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
|
||||
|
|
@ -204,7 +206,7 @@ function fireStatusChangeNotification(
|
|||
teamDisplayName: task.teamDisplayName,
|
||||
from: task.owner ?? 'system',
|
||||
to: 'user',
|
||||
summary: `Task #${task.id}: ${from} → ${to}`,
|
||||
summary: `Task ${formatTaskDisplayLabel(task)}: ${from} → ${to}`,
|
||||
body: task.subject,
|
||||
teamEventType: 'task_status_change',
|
||||
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ export interface TaskComment {
|
|||
// Adding a field here without mapping it there will cause a compile error.
|
||||
export interface TeamTask {
|
||||
id: string;
|
||||
/** Human-friendly short task label shown in UI. Canonical identity remains `id`. */
|
||||
displayId?: string;
|
||||
subject: string;
|
||||
description?: string;
|
||||
activeForm?: string;
|
||||
|
|
|
|||
32
src/shared/utils/taskIdentity.ts
Normal file
32
src/shared/utils/taskIdentity.ts
Normal 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
66
src/types/agent-teams-controller.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -566,7 +566,7 @@ describe('ipc teams handlers', () => {
|
|||
|
||||
it('rejects invalid task id', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
||||
const result = (await handler({} as never, 'my-team', 'abc', '2', 'blockedBy')) as {
|
||||
const result = (await handler({} as never, 'my-team', 'bad/id', '2', 'blockedBy')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(result.success).toBe(false);
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ describe('TeamDataService', () => {
|
|||
});
|
||||
|
||||
it('includes projectPath from config when creating a task', async () => {
|
||||
const createTaskMock = vi.fn(async () => undefined);
|
||||
const createTaskMock = vi.fn((task) => task);
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
|
|
@ -176,20 +176,28 @@ describe('TeamDataService', () => {
|
|||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
garbageCollect: vi.fn(async () => undefined),
|
||||
} as never
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
(teamName: string) =>
|
||||
({
|
||||
tasks: {
|
||||
createTask: createTaskMock,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const result = await service.createTask('my-team', { subject: 'Test' });
|
||||
|
||||
expect(result.projectPath).toBe('/Users/dev/my-project');
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.objectContaining({ projectPath: '/Users/dev/my-project' })
|
||||
);
|
||||
});
|
||||
|
||||
it('creates task with status pending when startImmediately is false', async () => {
|
||||
const createTaskMock = vi.fn(async () => undefined);
|
||||
const createTaskMock = vi.fn((task) => task);
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
|
|
@ -214,7 +222,16 @@ describe('TeamDataService', () => {
|
|||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
garbageCollect: vi.fn(async () => undefined),
|
||||
} as never
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
(teamName: string) =>
|
||||
({
|
||||
tasks: {
|
||||
createTask: createTaskMock,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const result = await service.createTask('my-team', {
|
||||
|
|
@ -225,13 +242,12 @@ describe('TeamDataService', () => {
|
|||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' })
|
||||
);
|
||||
});
|
||||
|
||||
it('persists explicit related task links when creating a task', async () => {
|
||||
const createTaskMock = vi.fn(async () => undefined);
|
||||
const createTaskMock = vi.fn((task) => task);
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
|
|
@ -256,7 +272,16 @@ describe('TeamDataService', () => {
|
|||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
garbageCollect: vi.fn(async () => undefined),
|
||||
} as never
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
(teamName: string) =>
|
||||
({
|
||||
tasks: {
|
||||
createTask: createTaskMock,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const result = await service.createTask('my-team', {
|
||||
|
|
@ -265,9 +290,6 @@ describe('TeamDataService', () => {
|
|||
});
|
||||
|
||||
expect(result.related).toEqual(['1', '2']);
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.objectContaining({ related: ['1', '2'] })
|
||||
);
|
||||
expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
expect(prompt).not.toContain('.claude/tools');
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toContain('--strict-mcp-config');
|
||||
expect(launchArgs).toContain('--mcp-config');
|
||||
expect(launchArgs).not.toContain('--strict-mcp-config');
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
|
@ -176,8 +176,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
expect(prompt).not.toContain('.claude/tools');
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toContain('--strict-mcp-config');
|
||||
expect(launchArgs).toContain('--mcp-config');
|
||||
expect(launchArgs).not.toContain('--strict-mcp-config');
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue