feat: enhance task management with improved notification and briefing features
- Refactored task creation logic to support explicit immediate start and maintain default pending status. - Implemented notification system for task owners upon assignment, including detailed task instructions. - Enhanced task briefing output to provide a compact view of assigned tasks, differentiating between in-progress, pending, and completed tasks. - Added utility functions for better task management and streamlined task reference handling. - Updated tests to cover new task assignment notifications and briefing functionalities, ensuring accurate task status representation.
This commit is contained in:
parent
b369b779cc
commit
9c63959a08
11 changed files with 430 additions and 114 deletions
|
|
@ -86,7 +86,7 @@ function getPaths(flags, teamName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferLeadName(paths) {
|
function inferLeadName(paths) {
|
||||||
const config = readJson(path.join(paths.teamDir, 'config.json'), null);
|
const config = readTeamConfig(paths);
|
||||||
if (!config || !Array.isArray(config.members)) {
|
if (!config || !Array.isArray(config.members)) {
|
||||||
return 'team-lead';
|
return 'team-lead';
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +99,17 @@ function inferLeadName(paths) {
|
||||||
return config.members[0] ? String(config.members[0].name) : 'team-lead';
|
return config.members[0] ? String(config.members[0].name) : 'team-lead';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readTeamConfig(paths) {
|
||||||
|
return readJson(path.join(paths.teamDir, 'config.json'), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLeadSessionId(paths) {
|
||||||
|
const config = readTeamConfig(paths);
|
||||||
|
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
|
||||||
|
? config.leadSessionId.trim()
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function isProcessAlive(pid) {
|
function isProcessAlive(pid) {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
|
|
@ -291,5 +302,7 @@ module.exports = {
|
||||||
getPaths,
|
getPaths,
|
||||||
inferLeadName,
|
inferLeadName,
|
||||||
isProcessAlive,
|
isProcessAlive,
|
||||||
|
readTeamConfig,
|
||||||
|
resolveLeadSessionId,
|
||||||
saveTaskAttachmentFile,
|
saveTaskAttachmentFile,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ function listTasks(paths, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTaskRef(paths, taskRef, options = {}) {
|
function resolveTaskRef(paths, taskRef, options = {}) {
|
||||||
const normalizedRef = String(taskRef || '').trim();
|
const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
|
||||||
if (!normalizedRef) {
|
if (!normalizedRef) {
|
||||||
throw new Error('Missing taskId');
|
throw new Error('Missing taskId');
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +168,8 @@ function computeInitialStatus(paths, input, owner, blockedByIds) {
|
||||||
const explicit = normalizeStatus(input.status);
|
const explicit = normalizeStatus(input.status);
|
||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
if (blockedByIds.length > 0) return 'pending';
|
if (blockedByIds.length > 0) return 'pending';
|
||||||
return owner ? 'in_progress' : 'pending';
|
if (owner && input.startImmediately === true) return 'in_progress';
|
||||||
|
return 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickTaskId(input) {
|
function pickTaskId(input) {
|
||||||
|
|
@ -577,6 +578,45 @@ function buildTaskReference(task) {
|
||||||
return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`;
|
return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareTasksForBriefing(a, b) {
|
||||||
|
const order = {
|
||||||
|
in_progress: 0,
|
||||||
|
pending: 1,
|
||||||
|
completed: 2,
|
||||||
|
deleted: 3,
|
||||||
|
};
|
||||||
|
const byStatus = (order[a.status] ?? 99) - (order[b.status] ?? 99);
|
||||||
|
if (byStatus !== 0) return byStatus;
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveReviewState(kanbanEntry, task) {
|
||||||
|
if (normalizeTaskReviewState(task.reviewState) !== 'none') {
|
||||||
|
return normalizeTaskReviewState(task.reviewState);
|
||||||
|
}
|
||||||
|
return kanbanEntry && kanbanEntry.column ? String(kanbanEntry.column) : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBriefTaskLine(task, reviewState) {
|
||||||
|
const reviewSuffix = reviewState !== 'none' ? `, review=${reviewState}` : '';
|
||||||
|
return `- #${task.displayId || deriveDisplayId(task.id)} [status=${task.status}${reviewSuffix}] ${task.subject}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommentLine(comment) {
|
||||||
|
const author =
|
||||||
|
typeof comment.author === 'string' && comment.author.trim() ? comment.author.trim() : 'unknown';
|
||||||
|
const text = typeof comment.text === 'string' ? comment.text.trim() : '';
|
||||||
|
return ` - ${author}: ${text || '(empty comment)'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTaskBriefing(paths, teamName, memberName) {
|
function formatTaskBriefing(paths, teamName, memberName) {
|
||||||
const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), {
|
const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), {
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -585,57 +625,51 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
||||||
});
|
});
|
||||||
const activeTasks = listTasks(paths)
|
const activeTasks = listTasks(paths)
|
||||||
.filter((task) => task.owner === memberName && task.status !== 'deleted')
|
.filter((task) => task.owner === memberName && task.status !== 'deleted')
|
||||||
.sort((a, b) => String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
.sort(compareTasksForBriefing);
|
||||||
numeric: true,
|
|
||||||
sensitivity: 'base',
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (activeTasks.length === 0) {
|
if (activeTasks.length === 0) {
|
||||||
return `No pending tasks for ${memberName}.`;
|
return `No assigned tasks for ${memberName}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = [];
|
const groups = {
|
||||||
for (const task of activeTasks) {
|
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
||||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
pending: activeTasks.filter((task) => task.status === 'pending'),
|
||||||
const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : '';
|
completed: activeTasks.filter((task) => task.status === 'completed'),
|
||||||
const effectiveReviewState =
|
};
|
||||||
normalizeTaskReviewState(task.reviewState) !== 'none'
|
|
||||||
? normalizeTaskReviewState(task.reviewState)
|
const lines = [`Task briefing for ${memberName}:`];
|
||||||
: reviewState
|
|
||||||
? String(kanbanEntry.column)
|
if (groups.in_progress.length > 0) {
|
||||||
: 'none';
|
lines.push('', 'In progress:');
|
||||||
lines.push(
|
for (const task of groups.in_progress) {
|
||||||
`${buildTaskReference(task)} [status=${task.status}${effectiveReviewState !== 'none' ? `, review=${effectiveReviewState}` : ''}] ${task.subject}`
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||||
);
|
const reviewState = getEffectiveReviewState(kanbanEntry, task);
|
||||||
if (task.description) lines.push(` Description: ${task.description}`);
|
lines.push(formatBriefTaskLine(task, reviewState));
|
||||||
if (task.blockedBy && task.blockedBy.length > 0) {
|
if (task.description) {
|
||||||
const blockedLabels = task.blockedBy
|
lines.push(` Description: ${task.description}`);
|
||||||
.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}`);
|
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(task.comments) && task.comments.length > 0) {
|
||||||
|
lines.push(' Comments:');
|
||||||
|
for (const comment of task.comments) {
|
||||||
|
lines.push(formatCommentLine(comment));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.pending.length > 0) {
|
||||||
|
lines.push('', 'Pending:');
|
||||||
|
for (const task of groups.pending) {
|
||||||
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||||
|
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.completed.length > 0) {
|
||||||
|
lines.push('', 'Completed:');
|
||||||
|
for (const task of groups.completed) {
|
||||||
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||||
|
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,84 @@
|
||||||
const taskStore = require('./taskStore.js');
|
const taskStore = require('./taskStore.js');
|
||||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||||
|
const messages = require('./messages.js');
|
||||||
|
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||||
|
|
||||||
|
function normalizeActorName(value) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameMember(left, right) {
|
||||||
|
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssignmentMessage(context, task, options = {}) {
|
||||||
|
const description =
|
||||||
|
typeof options.description === 'string' && options.description.trim()
|
||||||
|
? options.description.trim()
|
||||||
|
: typeof task.description === 'string' && task.description.trim()
|
||||||
|
? task.description.trim()
|
||||||
|
: '';
|
||||||
|
const prompt =
|
||||||
|
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
||||||
|
const taskLabel = `#${task.displayId || task.id}`;
|
||||||
|
const lines = [`New task assigned to you: ${taskLabel} "${task.subject}".`];
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
lines.push(``, `Description:`, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt) {
|
||||||
|
lines.push(``, `Instructions:`, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
``,
|
||||||
|
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||||
|
1. Check the latest full context before starting:
|
||||||
|
task_get { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||||
|
2. When you actually begin work, mark it started:
|
||||||
|
task_start { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||||
|
3. When the work is done, mark it completed:
|
||||||
|
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||||
|
const owner = normalizeActorName(task.owner);
|
||||||
|
if (!owner || task.status === 'deleted') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||||
|
const sender = normalizeActorName(options.from) || leadName;
|
||||||
|
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||||
|
if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||||
|
messages.sendMessage(context, {
|
||||||
|
member: owner,
|
||||||
|
from: sender,
|
||||||
|
text: buildAssignmentMessage(context, task, options),
|
||||||
|
summary,
|
||||||
|
source: 'system_notification',
|
||||||
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createTask(context, input) {
|
function createTask(context, input) {
|
||||||
return taskStore.createTask(context.paths, input);
|
const task = taskStore.createTask(context.paths, input);
|
||||||
|
if (input && input.notifyOwner !== false) {
|
||||||
|
maybeNotifyAssignedOwner(context, task, {
|
||||||
|
description: input.description,
|
||||||
|
prompt: input.prompt,
|
||||||
|
from: input.from,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTask(context, taskId) {
|
function getTask(context, taskId) {
|
||||||
|
|
@ -44,7 +120,20 @@ function restoreTask(context, taskId, actor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTaskOwner(context, taskId, owner) {
|
function setTaskOwner(context, taskId, owner) {
|
||||||
return taskStore.setTaskOwner(context.paths, taskId, owner);
|
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
|
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||||
|
|
||||||
|
if (
|
||||||
|
owner != null &&
|
||||||
|
normalizeActorName(updatedTask.owner) &&
|
||||||
|
!isSameMember(previousTask.owner, updatedTask.owner)
|
||||||
|
) {
|
||||||
|
maybeNotifyAssignedOwner(context, updatedTask, {
|
||||||
|
summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTaskFields(context, taskId, fields) {
|
function updateTaskFields(context, taskId, fields) {
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,60 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(rows[1].id).toBe(registered.id);
|
expect(rows[1].id).toBe(registered.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and keeps briefing compact', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
const pendingTask = controller.tasks.createTask({
|
||||||
|
subject: 'Queued task',
|
||||||
|
description: 'Do this later',
|
||||||
|
owner: 'bob',
|
||||||
|
prompt: 'Check the migration plan first.',
|
||||||
|
});
|
||||||
|
const activeTask = controller.tasks.createTask({
|
||||||
|
subject: 'Active task',
|
||||||
|
description: 'Resume immediately',
|
||||||
|
owner: 'bob',
|
||||||
|
startImmediately: true,
|
||||||
|
});
|
||||||
|
const completedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Already done',
|
||||||
|
description: 'Completed task description should stay out of compact rows',
|
||||||
|
owner: 'bob',
|
||||||
|
});
|
||||||
|
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||||
|
controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' });
|
||||||
|
|
||||||
|
const reassignedTask = controller.tasks.createTask({ subject: 'Reassigned later' });
|
||||||
|
controller.tasks.setTaskOwner(reassignedTask.id, 'bob');
|
||||||
|
|
||||||
|
expect(pendingTask.status).toBe('pending');
|
||||||
|
expect(activeTask.status).toBe('in_progress');
|
||||||
|
|
||||||
|
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||||
|
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||||
|
expect(ownerInbox).toHaveLength(4);
|
||||||
|
expect(ownerInbox[0].summary).toContain(`#${pendingTask.displayId}`);
|
||||||
|
expect(ownerInbox[0].text).toContain('task_get');
|
||||||
|
expect(ownerInbox[0].text).toContain('task_start');
|
||||||
|
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
||||||
|
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
||||||
|
|
||||||
|
const briefing = await controller.tasks.taskBriefing('bob');
|
||||||
|
expect(briefing).toContain('In progress:');
|
||||||
|
expect(briefing).toContain(`#${activeTask.displayId}`);
|
||||||
|
expect(briefing).toContain('Description: Resume immediately');
|
||||||
|
expect(briefing).toContain('Resumed work with latest context.');
|
||||||
|
expect(briefing).toContain('Pending:');
|
||||||
|
expect(briefing).toContain(`#${pendingTask.displayId}`);
|
||||||
|
expect(briefing).not.toContain('Description: Do this later');
|
||||||
|
expect(briefing).toContain('Completed:');
|
||||||
|
expect(briefing).toContain(`#${completedTask.displayId}`);
|
||||||
|
expect(briefing).not.toContain(
|
||||||
|
'Completed task description should stay out of compact rows'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
|
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
|
||||||
...(related?.length ? { related: related.join(',') } : {}),
|
...(related?.length ? { related: related.join(',') } : {}),
|
||||||
...(prompt ? { prompt } : {}),
|
...(prompt ? { prompt } : {}),
|
||||||
...(startImmediately === false && owner ? { status: 'pending' } : {}),
|
...(startImmediately !== undefined ? { startImmediately } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
owner: 'alice',
|
owner: 'alice',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
expect(createdTask.status).toBe('pending');
|
||||||
|
|
||||||
const listedTasks = parseJsonToolResult(
|
const listedTasks = parseJsonToolResult(
|
||||||
await getTool('task_list').execute({
|
await getTool('task_list').execute({
|
||||||
|
|
@ -276,6 +277,108 @@ describe('agent-teams-mcp tools', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const teamName = 'gamma';
|
||||||
|
|
||||||
|
const queuedTask = parseJsonToolResult(
|
||||||
|
await getTool('task_create').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
subject: 'Queued work',
|
||||||
|
description: 'Pending description should stay out of briefing details',
|
||||||
|
owner: 'alice',
|
||||||
|
prompt: 'Read the plan before starting.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(queuedTask.status).toBe('pending');
|
||||||
|
|
||||||
|
const activeTask = parseJsonToolResult(
|
||||||
|
await getTool('task_create').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
subject: 'Active work',
|
||||||
|
description: 'This one is already in progress',
|
||||||
|
owner: 'alice',
|
||||||
|
startImmediately: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(activeTask.status).toBe('in_progress');
|
||||||
|
|
||||||
|
await getTool('task_add_comment').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: activeTask.id,
|
||||||
|
text: 'Investigating the active task now.',
|
||||||
|
from: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedTask = parseJsonToolResult(
|
||||||
|
await getTool('task_create').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
subject: 'Done work',
|
||||||
|
description: 'Completed description should also stay compact',
|
||||||
|
owner: 'alice',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await getTool('task_complete').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: completedTask.id,
|
||||||
|
actor: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
const unassignedTask = parseJsonToolResult(
|
||||||
|
await getTool('task_create').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
subject: 'Assign later',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await getTool('task_set_owner').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: unassignedTask.id,
|
||||||
|
owner: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedByHashRef = parseJsonToolResult(
|
||||||
|
await getTool('task_get').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: `#${queuedTask.displayId}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(queuedByHashRef.id).toBe(queuedTask.id);
|
||||||
|
|
||||||
|
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||||
|
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||||
|
expect(ownerInbox).toHaveLength(4);
|
||||||
|
expect(ownerInbox[0].summary).toContain(`#${queuedTask.displayId}`);
|
||||||
|
expect(ownerInbox[0].text).toContain('task_get');
|
||||||
|
expect(ownerInbox[0].text).toContain('task_start');
|
||||||
|
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
||||||
|
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
||||||
|
|
||||||
|
const briefing = (await getTool('task_briefing').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
memberName: 'alice',
|
||||||
|
})) as { content: Array<{ text: string }> };
|
||||||
|
const briefingText = briefing.content[0]?.text ?? '';
|
||||||
|
expect(briefingText).toContain('In progress:');
|
||||||
|
expect(briefingText).toContain(`#${activeTask.displayId}`);
|
||||||
|
expect(briefingText).toContain('Description: This one is already in progress');
|
||||||
|
expect(briefingText).toContain('Investigating the active task now.');
|
||||||
|
expect(briefingText).toContain('Pending:');
|
||||||
|
expect(briefingText).toContain(`#${queuedTask.displayId}`);
|
||||||
|
expect(briefingText).not.toContain('Pending description should stay out of briefing details');
|
||||||
|
expect(briefingText).toContain('Completed:');
|
||||||
|
expect(briefingText).toContain(`#${completedTask.displayId}`);
|
||||||
|
expect(briefingText).not.toContain('Completed description should also stay compact');
|
||||||
|
});
|
||||||
|
|
||||||
it('covers review_request_changes and full process lifecycle tools', async () => {
|
it('covers review_request_changes and full process lifecycle tools', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const teamName = 'beta';
|
const teamName = 'beta';
|
||||||
|
|
|
||||||
|
|
@ -742,7 +742,7 @@ export class TeamDataService {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldStart = request.owner && request.startImmediately !== false;
|
const shouldStart = request.owner && request.startImmediately === true;
|
||||||
const task = controller.tasks.createTask({
|
const task = controller.tasks.createTask({
|
||||||
subject: request.subject,
|
subject: request.subject,
|
||||||
...(request.description?.trim() ? { description: request.description.trim() } : {}),
|
...(request.description?.trim() ? { description: request.description.trim() } : {}),
|
||||||
|
|
@ -751,48 +751,10 @@ export class TeamDataService {
|
||||||
...(related.length > 0 ? { related } : {}),
|
...(related.length > 0 ? { related } : {}),
|
||||||
...(projectPath ? { projectPath } : {}),
|
...(projectPath ? { projectPath } : {}),
|
||||||
createdBy: 'user',
|
createdBy: 'user',
|
||||||
...(shouldStart ? { status: 'in_progress' } : { status: 'pending' }),
|
...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}),
|
||||||
|
...(shouldStart ? { startImmediately: true } : {}),
|
||||||
}) as TeamTask;
|
}) as TeamTask;
|
||||||
|
|
||||||
if (shouldStart && request.owner) {
|
|
||||||
try {
|
|
||||||
const leadName = await this.resolveLeadName(teamName);
|
|
||||||
|
|
||||||
// Skip inbox notification when lead assigns a task to themselves (solo teams)
|
|
||||||
if (!this.isLeadOwner(request.owner, leadName)) {
|
|
||||||
// Build notification with full context — inbox is the primary delivery
|
|
||||||
// channel to agents (Claude Code monitors inbox via fs.watch)
|
|
||||||
const parts = [`New task assigned to you: ${this.getTaskLabel(task)} "${task.subject}".`];
|
|
||||||
|
|
||||||
if (request.description?.trim()) {
|
|
||||||
parts.push(`\nDescription:\n${request.description.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.prompt?.trim()) {
|
|
||||||
parts.push(`\nInstructions:\n${request.prompt.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
`\n${AGENT_BLOCK_OPEN}`,
|
|
||||||
`Update task status using the board MCP tools:`,
|
|
||||||
`task_start { teamName: "${teamName}", taskId: "${task.id}" }`,
|
|
||||||
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
|
|
||||||
AGENT_BLOCK_CLOSE
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.sendMessage(teamName, {
|
|
||||||
member: request.owner,
|
|
||||||
from: leadName,
|
|
||||||
text: parts.join('\n'),
|
|
||||||
summary: `New task ${this.getTaskLabel(task)} assigned`,
|
|
||||||
source: 'system_notification',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort notification — don't fail task creation if message fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,7 @@ function buildMemberSpawnPrompt(
|
||||||
${getAgentLanguageInstruction()}
|
${getAgentLanguageInstruction()}
|
||||||
Introduce yourself briefly (name and role) and confirm you are ready.
|
Introduce yourself briefly (name and role) and confirm you are ready.
|
||||||
Then wait for task assignments.
|
Then wait for task assignments.
|
||||||
|
When you later receive work or reconnect after a restart, use task_briefing as your compact queue view, then call task_get for the specific task you are about to resume or start.
|
||||||
${buildTeammateAgentBlockReminder()}
|
${buildTeammateAgentBlockReminder()}
|
||||||
Include the following agent-only instructions verbatim in the prompt:
|
Include the following agent-only instructions verbatim in the prompt:
|
||||||
|
|
||||||
|
|
@ -363,8 +364,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:
|
0. IMPORTANT ID RULE:
|
||||||
- In MCP tool calls, ALWAYS use the exact canonical taskId value shown in the board/task snapshot.
|
- If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
|
||||||
- Human-facing summaries may use the short display label like #abcd1234 for readability.
|
- task_briefing may show short display labels like #abcd1234; MCP task tools also accept that short task ref.
|
||||||
|
- Human-facing summaries should 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.
|
||||||
|
|
@ -404,6 +406,11 @@ function buildTaskStatusProtocol(teamName: string): string {
|
||||||
12. DEPENDENCY AWARENESS:
|
12. DEPENDENCY AWARENESS:
|
||||||
When your task has blockedBy dependencies, check if they are completed before starting.
|
When your task has blockedBy dependencies, check if they are completed before starting.
|
||||||
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
|
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
|
||||||
|
13. TASK QUEUE DISCIPLINE:
|
||||||
|
- Use task_briefing as a compact queue view of your assigned tasks.
|
||||||
|
- task_briefing may include full description/comments only for in_progress tasks; pending/completed entries may be minimal on purpose.
|
||||||
|
- Before resuming any in_progress task, call task_get for that task to refresh full context.
|
||||||
|
- When you become free, call task_briefing again, inspect pending tasks, then call task_get for the specific pending task you are about to start before task_start.
|
||||||
Failure to follow this protocol means the task board will show incorrect status.`);
|
Failure to follow this protocol means the task board will show incorrect status.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -627,7 +634,7 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string
|
||||||
: '';
|
: '';
|
||||||
return ` - ${formatTaskDisplayLabel(t)} (taskId: ${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 active tasks from last session (resume in_progress first, then pending):\n${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a full task board snapshot for the lead. */
|
/** Build a full task board snapshot for the lead. */
|
||||||
|
|
@ -649,7 +656,7 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
||||||
: '';
|
: '';
|
||||||
return ` - ${formatTaskDisplayLabel(t)} (taskId: ${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 (in_progress/pending):\n${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
|
|
@ -803,13 +810,15 @@ function buildLaunchPrompt(
|
||||||
|
|
||||||
${languageInstruction}
|
${languageInstruction}
|
||||||
The team has been reconnected after a restart.
|
The team has been reconnected after a restart.
|
||||||
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
|
${hasTasks ? `You may have in_progress or pending tasks from the previous session.` : 'You have no assigned tasks currently.'}
|
||||||
${buildTeammateAgentBlockReminder()}
|
${buildTeammateAgentBlockReminder()}
|
||||||
|
|
||||||
Your FIRST action: call MCP tool task_briefing with:
|
Your FIRST action: call MCP tool task_briefing with:
|
||||||
{ teamName: "${request.teamName}", memberName: "${m.name}" }
|
{ teamName: "${request.teamName}", memberName: "${m.name}" }
|
||||||
Then resume in_progress tasks first, then pending tasks.
|
Then:
|
||||||
If you have no tasks, wait for new assignments.`;
|
- If task_briefing shows any in_progress task, call task_get for each in_progress task first and resume/finish those before touching pending tasks.
|
||||||
|
- Only after there are no active in_progress tasks, inspect pending tasks from task_briefing, call task_get for the specific pending task you choose, and only then run task_start when you truly begin.
|
||||||
|
- If you have no tasks, wait for new assignments.`;
|
||||||
})
|
})
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,17 +179,6 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
}, [sending, sendError, draft]);
|
}, [sending, sendError, draft]);
|
||||||
|
|
||||||
const handleKeyDownCapture = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleSend]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { addFiles: draftAddFiles } = draft;
|
const { addFiles: draftAddFiles } = draft;
|
||||||
const handleFileInputChange = useCallback(
|
const handleFileInputChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -280,7 +269,6 @@ export const MessageComposer = ({
|
||||||
<div
|
<div
|
||||||
className="relative mb-3 p-3"
|
className="relative mb-3 p-3"
|
||||||
role="group"
|
role="group"
|
||||||
onKeyDownCapture={handleKeyDownCapture}
|
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ describe('TeamDataService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
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((task) => task);
|
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
|
|
@ -274,8 +274,66 @@ describe('TeamDataService', () => {
|
||||||
|
|
||||||
expect(result.status).toBe('pending');
|
expect(result.status).toBe('pending');
|
||||||
expect(createTaskMock).toHaveBeenCalledWith(
|
expect(createTaskMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' })
|
expect.objectContaining({ owner: 'alice', createdBy: 'user' })
|
||||||
);
|
);
|
||||||
|
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ startImmediately: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates task with explicit immediate start only when startImmediately is true', async () => {
|
||||||
|
const createTaskMock = vi.fn((task) => ({ ...task, status: 'in_progress' }));
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{
|
||||||
|
listTeams: vi.fn(),
|
||||||
|
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getNextTaskId: vi.fn(async () => '2'),
|
||||||
|
getTasks: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
listInboxNames: vi.fn(async () => []),
|
||||||
|
getMessages: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
createTask: createTaskMock,
|
||||||
|
addBlocksEntry: vi.fn(async () => undefined),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
resolveMembers: vi.fn(() => []),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||||
|
garbageCollect: vi.fn(async () => undefined),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
(_teamName: string) =>
|
||||||
|
({
|
||||||
|
tasks: {
|
||||||
|
createTask: createTaskMock,
|
||||||
|
},
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.createTask('my-team', {
|
||||||
|
subject: 'Start now',
|
||||||
|
owner: 'alice',
|
||||||
|
startImmediately: true,
|
||||||
|
prompt: 'Begin immediately.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('in_progress');
|
||||||
|
expect(createTaskMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
owner: 'alice',
|
||||||
|
createdBy: 'user',
|
||||||
|
startImmediately: true,
|
||||||
|
prompt: 'Begin immediately.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'in_progress' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists explicit related task links when creating a task', async () => {
|
it('persists explicit related task links when creating a task', async () => {
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
|
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
|
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
|
||||||
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
|
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
|
||||||
|
expect(prompt).toContain('use task_briefing as your compact queue view');
|
||||||
|
expect(prompt).toContain('then call task_get for the specific task you are about to resume or start');
|
||||||
|
expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
|
||||||
|
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
@ -273,6 +276,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
|
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
|
||||||
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
|
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
|
||||||
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
|
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
|
||||||
|
expect(prompt).toContain('Your FIRST action: call MCP tool task_briefing');
|
||||||
|
expect(prompt).toContain('call task_get for each in_progress task first');
|
||||||
|
expect(prompt).toContain('call task_get for the specific pending task you choose');
|
||||||
|
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue