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