feat(agent-teams): add derived task queue agenda
This commit is contained in:
parent
7b486b7fea
commit
f40ea4f738
23 changed files with 3530 additions and 348 deletions
814
agent-teams-controller/src/internal/agenda.js
Normal file
814
agent-teams-controller/src/internal/agenda.js
Normal file
|
|
@ -0,0 +1,814 @@
|
||||||
|
const kanbanStore = require('./kanbanStore.js');
|
||||||
|
const taskStore = require('./taskStore.js');
|
||||||
|
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||||
|
const { withTeamBoardLock } = require('./boardLock.js');
|
||||||
|
|
||||||
|
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
|
||||||
|
const REVIEW_COLUMNS = new Set(['review', 'approved']);
|
||||||
|
const INVENTORY_KANBAN_COLUMNS = new Set(['review', 'approved']);
|
||||||
|
|
||||||
|
function normalizeName(value) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(value) {
|
||||||
|
return normalizeName(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReviewState(value) {
|
||||||
|
const normalized = normalizeName(value);
|
||||||
|
return REVIEW_STATES.has(normalized) ? normalized : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskLabel(task) {
|
||||||
|
return `#${task.displayId || task.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLeadCandidate(member) {
|
||||||
|
if (!member || typeof member !== 'object') return false;
|
||||||
|
if (typeof member.agentType === 'string' && member.agentType.trim() === 'team-lead') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalizeKey(member.name) === 'team-lead';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueueRoster(paths) {
|
||||||
|
const resolved = runtimeHelpers.resolveTeamMembers(paths);
|
||||||
|
const membersByKey = new Map();
|
||||||
|
|
||||||
|
for (const member of resolved.members || []) {
|
||||||
|
const key = normalizeKey(member && member.name);
|
||||||
|
if (!key) continue;
|
||||||
|
membersByKey.set(key, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadCandidates = (resolved.members || []).filter(isLeadCandidate);
|
||||||
|
const uniqueLeadName = leadCandidates.length === 1 ? normalizeName(leadCandidates[0].name) : '';
|
||||||
|
const inferredLeadName = normalizeName(runtimeHelpers.inferLeadName(paths));
|
||||||
|
const canonicalLeadName =
|
||||||
|
uniqueLeadName ||
|
||||||
|
(membersByKey.get(normalizeKey(inferredLeadName)) &&
|
||||||
|
normalizeName(membersByKey.get(normalizeKey(inferredLeadName)).name)) ||
|
||||||
|
'';
|
||||||
|
const leadAliases = new Set(['team-lead']);
|
||||||
|
if (canonicalLeadName) {
|
||||||
|
leadAliases.add(normalizeKey(canonicalLeadName));
|
||||||
|
leadAliases.add('lead');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
membersByKey,
|
||||||
|
removedNames: resolved.removedNames || new Set(),
|
||||||
|
leadAliases,
|
||||||
|
leadCandidates: leadCandidates.map((member) => normalizeName(member.name)).filter(Boolean),
|
||||||
|
canonicalLeadName,
|
||||||
|
leadHeaderName: uniqueLeadName || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQueueActor(value, roster) {
|
||||||
|
const normalized = normalizeName(value);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const key = normalizeKey(normalized);
|
||||||
|
if (roster.removedNames.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roster.leadAliases.has(key) && roster.canonicalLeadName) {
|
||||||
|
return { kind: 'lead', memberName: roster.canonicalLeadName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = roster.membersByKey.get(key);
|
||||||
|
if (!member) return null;
|
||||||
|
|
||||||
|
if (roster.canonicalLeadName && normalizeKey(member.name) === normalizeKey(roster.canonicalLeadName)) {
|
||||||
|
return { kind: 'lead', memberName: roster.canonicalLeadName };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'member', memberName: normalizeName(member.name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function areSameActors(left, right) {
|
||||||
|
if (!left || !right || left.kind !== right.kind) return false;
|
||||||
|
if (left.kind === 'lead') return true;
|
||||||
|
return normalizeKey(left.memberName) === normalizeKey(right.memberName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReviewStateFromHistory(task) {
|
||||||
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const event = events[i];
|
||||||
|
if (
|
||||||
|
event.type === 'review_requested' ||
|
||||||
|
event.type === 'review_changes_requested' ||
|
||||||
|
event.type === 'review_approved' ||
|
||||||
|
event.type === 'review_started'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: normalizeReviewState(event.to),
|
||||||
|
source: `history_${event.type}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
||||||
|
return {
|
||||||
|
state: 'none',
|
||||||
|
source: 'history_status_reset',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveReviewState(task, kanbanEntry) {
|
||||||
|
const historyState = resolveReviewStateFromHistory(task);
|
||||||
|
if (historyState) {
|
||||||
|
return historyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = normalizeReviewState(task.reviewState);
|
||||||
|
if (persisted !== 'none') {
|
||||||
|
return {
|
||||||
|
state: persisted,
|
||||||
|
source: 'task_review_state',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||||
|
return {
|
||||||
|
state: normalizeReviewState(kanbanEntry.column),
|
||||||
|
source: 'kanban_column',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: 'none',
|
||||||
|
source: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyKanbanReviewer(task, roster, options = {}) {
|
||||||
|
const reviewState = normalizeName(options.reviewState);
|
||||||
|
const kanbanEntry = options.kanbanEntry;
|
||||||
|
if (reviewState !== 'review' || !kanbanEntry || kanbanEntry.column !== 'review') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyReviewer = normalizeName(kanbanEntry.reviewer);
|
||||||
|
if (!legacyReviewer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = resolveQueueActor(legacyReviewer, roster);
|
||||||
|
if (actor) {
|
||||||
|
return { actor, source: 'legacy_kanban_reviewer', invalidValue: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actor: null,
|
||||||
|
source: 'legacy_kanban_reviewer_invalid',
|
||||||
|
invalidValue: legacyReviewer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCurrentCycleReviewer(task, roster, options = {}) {
|
||||||
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
|
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const event = events[i];
|
||||||
|
|
||||||
|
if (event.type === 'review_started') {
|
||||||
|
const actor = resolveQueueActor(event.actor, roster);
|
||||||
|
if (actor) {
|
||||||
|
return { actor, source: 'history_review_started_actor', invalidValue: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actor: null,
|
||||||
|
source: 'history_review_started_invalid',
|
||||||
|
invalidValue: normalizeName(event.actor) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'review_requested') {
|
||||||
|
const reviewer = resolveQueueActor(event.reviewer, roster);
|
||||||
|
if (reviewer) {
|
||||||
|
return { actor: reviewer, source: 'history_review_requested_reviewer', invalidValue: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actor: null,
|
||||||
|
source: 'history_review_requested_invalid',
|
||||||
|
invalidValue: normalizeName(event.reviewer) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'task_created') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyFallback = resolveLegacyKanbanReviewer(task, roster, options);
|
||||||
|
if (legacyFallback) {
|
||||||
|
return legacyFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { actor: null, source: 'none', invalidValue: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTasksByFreshness(left, right) {
|
||||||
|
const leftTs = Date.parse(normalizeName(left.updatedAt) || normalizeName(left.createdAt) || '') || 0;
|
||||||
|
const rightTs = Date.parse(normalizeName(right.updatedAt) || normalizeName(right.createdAt) || '') || 0;
|
||||||
|
if (leftTs !== rightTs) return rightTs - leftTs;
|
||||||
|
|
||||||
|
const byDisplay = String(left.displayId || left.id).localeCompare(String(right.displayId || right.id), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
if (byDisplay !== 0) return byDisplay;
|
||||||
|
|
||||||
|
return String(left.id).localeCompare(String(right.id), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBoardState(paths, teamName) {
|
||||||
|
const taskRows = taskStore.listTaskRows(paths);
|
||||||
|
const kanbanState = kanbanStore.readKanbanState(paths, teamName);
|
||||||
|
const roster = buildQueueRoster(paths);
|
||||||
|
const tasksById = new Map(taskRows.tasks.map((task) => [task.id, task]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks: [...taskRows.tasks].sort(compareTasksByFreshness),
|
||||||
|
tasksById,
|
||||||
|
kanbanState,
|
||||||
|
roster,
|
||||||
|
anomalies: taskRows.anomalies.map((anomaly) => ({
|
||||||
|
code: anomaly.code,
|
||||||
|
detail: anomaly.detail,
|
||||||
|
...(anomaly.taskId ? { taskId: anomaly.taskId } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWatchers(ownerActor, reviewerActor, actionOwner) {
|
||||||
|
const watchers = new Set();
|
||||||
|
|
||||||
|
if (ownerActor && ownerActor.kind === 'member') {
|
||||||
|
watchers.add(ownerActor.memberName);
|
||||||
|
}
|
||||||
|
if (reviewerActor && reviewerActor.kind === 'member') {
|
||||||
|
watchers.add(reviewerActor.memberName);
|
||||||
|
}
|
||||||
|
if (actionOwner && actionOwner.kind === 'member') {
|
||||||
|
watchers.delete(actionOwner.memberName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...watchers];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastMeaningfulEventAt(task) {
|
||||||
|
const timestamps = [];
|
||||||
|
if (normalizeName(task.updatedAt)) timestamps.push(task.updatedAt);
|
||||||
|
if (normalizeName(task.createdAt)) timestamps.push(task.createdAt);
|
||||||
|
|
||||||
|
const comments = Array.isArray(task.comments) ? task.comments : [];
|
||||||
|
for (const comment of comments) {
|
||||||
|
if (normalizeName(comment && comment.createdAt)) {
|
||||||
|
timestamps.push(comment.createdAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamps.sort((left, right) => {
|
||||||
|
const leftTs = Date.parse(left) || 0;
|
||||||
|
const rightTs = Date.parse(right) || 0;
|
||||||
|
return rightTs - leftTs;
|
||||||
|
});
|
||||||
|
|
||||||
|
return timestamps[0] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgendaItem(task, boardState) {
|
||||||
|
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
|
||||||
|
const reviewStateResult = resolveEffectiveReviewState(task, kanbanEntry);
|
||||||
|
const reviewerResult = resolveCurrentCycleReviewer(task, boardState.roster, {
|
||||||
|
reviewState: reviewStateResult.state,
|
||||||
|
kanbanEntry,
|
||||||
|
});
|
||||||
|
const ownerActor = resolveQueueActor(task.owner, boardState.roster);
|
||||||
|
const hasOwnerField = normalizeName(task.owner).length > 0;
|
||||||
|
const hasMissingOwner = !hasOwnerField;
|
||||||
|
const hasInvalidOwner = hasOwnerField && !ownerActor;
|
||||||
|
const reviewActor = reviewerResult.actor;
|
||||||
|
const reviewActorIsInvalid = reviewStateResult.state === 'review' && !reviewActor;
|
||||||
|
const hasSelfReview = Boolean(ownerActor && reviewActor && areSameActors(ownerActor, reviewActor));
|
||||||
|
|
||||||
|
const brokenDependencyIds = [];
|
||||||
|
const waitingDependencyIds = [];
|
||||||
|
const blockedByIds = Array.isArray(task.blockedBy) ? task.blockedBy.map(String) : [];
|
||||||
|
for (const dependencyId of blockedByIds) {
|
||||||
|
const dependency = boardState.tasksById.get(dependencyId);
|
||||||
|
if (!dependency || dependency.status === 'deleted') {
|
||||||
|
brokenDependencyIds.push(dependencyId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dependency.status !== 'completed') {
|
||||||
|
waitingDependencyIds.push(dependencyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionOwner = { kind: 'none' };
|
||||||
|
let nextAction = 'none';
|
||||||
|
let queueCategory = 'done';
|
||||||
|
let reasonCode = 'completed_no_followup';
|
||||||
|
const derivedFrom = [reviewStateResult.source, reviewerResult.source].filter(
|
||||||
|
(value) => value && value !== 'none'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (task.status === 'deleted') {
|
||||||
|
reasonCode = 'terminal_deleted';
|
||||||
|
} else if (reviewStateResult.state === 'approved') {
|
||||||
|
reasonCode = 'terminal_approved';
|
||||||
|
} else if (task.needsClarification === 'user') {
|
||||||
|
actionOwner = { kind: 'user' };
|
||||||
|
nextAction = 'clarify_with_user';
|
||||||
|
queueCategory = 'waiting';
|
||||||
|
reasonCode = 'waiting_user_clarification';
|
||||||
|
derivedFrom.push('clarification_flag');
|
||||||
|
} else if (task.needsClarification === 'lead') {
|
||||||
|
actionOwner = { kind: 'lead' };
|
||||||
|
nextAction = 'clarify_with_lead';
|
||||||
|
queueCategory = 'oversight';
|
||||||
|
reasonCode = 'waiting_lead_clarification';
|
||||||
|
derivedFrom.push('clarification_flag');
|
||||||
|
} else if (hasMissingOwner) {
|
||||||
|
actionOwner = { kind: 'lead' };
|
||||||
|
nextAction = 'assign_owner';
|
||||||
|
queueCategory = 'oversight';
|
||||||
|
reasonCode = 'owner_missing';
|
||||||
|
derivedFrom.push('owner_status');
|
||||||
|
} else if (hasInvalidOwner) {
|
||||||
|
actionOwner = { kind: 'lead' };
|
||||||
|
nextAction = 'assign_owner';
|
||||||
|
queueCategory = 'oversight';
|
||||||
|
reasonCode = 'owner_invalid';
|
||||||
|
derivedFrom.push('owner_status');
|
||||||
|
} else if (reviewStateResult.state === 'review') {
|
||||||
|
if (hasSelfReview) {
|
||||||
|
actionOwner = { kind: 'lead' };
|
||||||
|
nextAction = 'assign_reviewer';
|
||||||
|
queueCategory = 'oversight';
|
||||||
|
reasonCode = 'self_review_invalid';
|
||||||
|
derivedFrom.push('self_review_invalid');
|
||||||
|
} else if (reviewActorIsInvalid) {
|
||||||
|
actionOwner = { kind: 'lead' };
|
||||||
|
nextAction = 'assign_reviewer';
|
||||||
|
queueCategory = 'oversight';
|
||||||
|
reasonCode = 'review_reviewer_missing';
|
||||||
|
derivedFrom.push('history_reviewer_invalid');
|
||||||
|
} else if (reviewActor) {
|
||||||
|
actionOwner = reviewActor.kind === 'lead'
|
||||||
|
? { kind: 'lead' }
|
||||||
|
: { kind: 'member', memberName: reviewActor.memberName };
|
||||||
|
nextAction = 'review';
|
||||||
|
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
|
||||||
|
reasonCode =
|
||||||
|
reviewerResult.source === 'history_review_started_actor'
|
||||||
|
? 'review_in_progress'
|
||||||
|
: 'review_requested_waiting_pickup';
|
||||||
|
}
|
||||||
|
} else if (brokenDependencyIds.length > 0) {
|
||||||
|
actionOwner = { kind: 'lead' };
|
||||||
|
nextAction = 'repair_dependencies';
|
||||||
|
queueCategory = 'oversight';
|
||||||
|
reasonCode = 'dependency_broken';
|
||||||
|
derivedFrom.push('dependency_graph');
|
||||||
|
} else if (waitingDependencyIds.length > 0) {
|
||||||
|
actionOwner = { kind: 'none' };
|
||||||
|
nextAction = 'wait_dependency';
|
||||||
|
queueCategory = 'waiting';
|
||||||
|
reasonCode = 'dependency_waiting';
|
||||||
|
derivedFrom.push('dependency_graph');
|
||||||
|
} else if (reviewStateResult.state === 'needsFix') {
|
||||||
|
actionOwner =
|
||||||
|
ownerActor.kind === 'lead'
|
||||||
|
? { kind: 'lead' }
|
||||||
|
: { kind: 'member', memberName: ownerActor.memberName };
|
||||||
|
nextAction = 'apply_changes';
|
||||||
|
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
|
||||||
|
reasonCode = 'needs_fix';
|
||||||
|
} else if (task.status === 'in_progress') {
|
||||||
|
actionOwner =
|
||||||
|
ownerActor.kind === 'lead'
|
||||||
|
? { kind: 'lead' }
|
||||||
|
: { kind: 'member', memberName: ownerActor.memberName };
|
||||||
|
nextAction = 'execute';
|
||||||
|
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
|
||||||
|
reasonCode = 'owner_executing';
|
||||||
|
} else if (task.status === 'pending') {
|
||||||
|
actionOwner =
|
||||||
|
ownerActor.kind === 'lead'
|
||||||
|
? { kind: 'lead' }
|
||||||
|
: { kind: 'member', memberName: ownerActor.memberName };
|
||||||
|
nextAction = 'execute';
|
||||||
|
queueCategory = actionOwner.kind === 'lead' ? 'oversight' : 'actionable';
|
||||||
|
reasonCode = 'owner_ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchers = buildWatchers(ownerActor, reviewActor, actionOwner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId: task.id,
|
||||||
|
displayId: task.displayId,
|
||||||
|
subject: task.subject,
|
||||||
|
status: task.status,
|
||||||
|
reviewState: reviewStateResult.state,
|
||||||
|
actionOwner,
|
||||||
|
nextAction,
|
||||||
|
queueCategory,
|
||||||
|
reasonCode,
|
||||||
|
...(normalizeName(task.owner) ? { owner: task.owner } : {}),
|
||||||
|
reviewer:
|
||||||
|
reviewActor && reviewActor.kind === 'member'
|
||||||
|
? reviewActor.memberName
|
||||||
|
: reviewActor && reviewActor.kind === 'lead'
|
||||||
|
? reviewActor.memberName
|
||||||
|
: null,
|
||||||
|
...(blockedByIds.length > 0 ? { blockedBy: blockedByIds } : {}),
|
||||||
|
...(watchers.length > 0 ? { watchers } : {}),
|
||||||
|
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
|
||||||
|
...(getLastMeaningfulEventAt(task) ? { lastMeaningfulEventAt: getLastMeaningfulEventAt(task) } : {}),
|
||||||
|
derivedFrom,
|
||||||
|
_fullTask: task,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareAgendaItems(left, right) {
|
||||||
|
const leftTs = Date.parse(normalizeName(left.lastMeaningfulEventAt)) || 0;
|
||||||
|
const rightTs = Date.parse(normalizeName(right.lastMeaningfulEventAt)) || 0;
|
||||||
|
if (leftTs !== rightTs) return rightTs - leftTs;
|
||||||
|
|
||||||
|
const byDisplay = String(left.displayId || left.taskId).localeCompare(String(right.displayId || right.taskId), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
if (byDisplay !== 0) return byDisplay;
|
||||||
|
|
||||||
|
return String(left.taskId).localeCompare(String(right.taskId), undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgendaSnapshot(paths, teamName, actor) {
|
||||||
|
return withTeamBoardLock(paths, () => {
|
||||||
|
const boardState = buildBoardState(paths, teamName);
|
||||||
|
const items = boardState.tasks.map((task) => buildAgendaItem(task, boardState));
|
||||||
|
const actionable = [];
|
||||||
|
const awareness = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (actor.kind === 'member') {
|
||||||
|
const memberKey = normalizeKey(actor.memberName);
|
||||||
|
const isActionable =
|
||||||
|
item.actionOwner.kind === 'member' &&
|
||||||
|
normalizeKey(item.actionOwner.memberName) === memberKey;
|
||||||
|
const isRelevant =
|
||||||
|
isActionable ||
|
||||||
|
normalizeKey(item.owner) === memberKey ||
|
||||||
|
normalizeKey(item.reviewer) === memberKey ||
|
||||||
|
(Array.isArray(item.watchers) && item.watchers.some((entry) => normalizeKey(entry) === memberKey));
|
||||||
|
|
||||||
|
if (isActionable) {
|
||||||
|
actionable.push(item);
|
||||||
|
} else if (isRelevant) {
|
||||||
|
awareness.push(item);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.actionOwner.kind === 'lead') {
|
||||||
|
actionable.push(item);
|
||||||
|
} else if (
|
||||||
|
item.actionOwner.kind === 'user' ||
|
||||||
|
item.reasonCode === 'dependency_waiting' ||
|
||||||
|
item.reasonCode === 'review_in_progress' ||
|
||||||
|
item.reasonCode === 'review_requested_waiting_pickup'
|
||||||
|
) {
|
||||||
|
awareness.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actionable.sort(compareAgendaItems);
|
||||||
|
awareness.sort(compareAgendaItems);
|
||||||
|
|
||||||
|
return {
|
||||||
|
actor,
|
||||||
|
actionable,
|
||||||
|
awareness,
|
||||||
|
anomalies: boardState.anomalies,
|
||||||
|
counters: {
|
||||||
|
actionable: actionable.length,
|
||||||
|
awareness: awareness.length,
|
||||||
|
blocked: items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.reasonCode === 'dependency_waiting' || item.reasonCode === 'dependency_broken'
|
||||||
|
).length,
|
||||||
|
waitingOnUser: items.filter((item) => item.reasonCode === 'waiting_user_clarification').length,
|
||||||
|
waitingOnLead: items.filter((item) => item.reasonCode === 'waiting_lead_clarification').length,
|
||||||
|
reviewNeeded: items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.reasonCode === 'review_reviewer_missing' ||
|
||||||
|
item.reasonCode === 'review_requested_waiting_pickup' ||
|
||||||
|
item.reasonCode === 'review_in_progress' ||
|
||||||
|
item.reasonCode === 'self_review_invalid'
|
||||||
|
).length,
|
||||||
|
anomalies: boardState.anomalies.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInventoryRow(task, reviewState) {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
displayId: task.displayId,
|
||||||
|
subject: task.subject,
|
||||||
|
status: task.status,
|
||||||
|
...(normalizeName(task.owner) ? { owner: task.owner } : {}),
|
||||||
|
reviewState,
|
||||||
|
...(task.needsClarification ? { needsClarification: task.needsClarification } : {}),
|
||||||
|
...(Array.isArray(task.blockedBy) && task.blockedBy.length > 0 ? { blockedBy: task.blockedBy } : {}),
|
||||||
|
...(Array.isArray(task.blocks) && task.blocks.length > 0 ? { blocks: task.blocks } : {}),
|
||||||
|
...(Array.isArray(task.related) && task.related.length > 0 ? { related: task.related } : {}),
|
||||||
|
commentCount: Array.isArray(task.comments) ? task.comments.length : 0,
|
||||||
|
...(normalizeName(task.createdAt) ? { createdAt: task.createdAt } : {}),
|
||||||
|
...(normalizeName(task.updatedAt) ? { updatedAt: task.updatedAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesInventoryFilters(row, filters) {
|
||||||
|
if (normalizeName(filters.owner) && normalizeKey(row.owner) !== normalizeKey(filters.owner)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeName(filters.status) && row.status !== filters.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeName(filters.reviewState) && row.reviewState !== filters.reviewState) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeName(filters.kanbanColumn)) {
|
||||||
|
const kanbanColumn = filters.kanbanColumn;
|
||||||
|
if (!INVENTORY_KANBAN_COLUMNS.has(kanbanColumn) || row.reviewState !== kanbanColumn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizeName(filters.relatedTo)) {
|
||||||
|
const related = Array.isArray(row.related) ? row.related : [];
|
||||||
|
if (!related.includes(filters.relatedTo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizeName(filters.blockedBy)) {
|
||||||
|
const blockedBy = Array.isArray(row.blockedBy) ? row.blockedBy : [];
|
||||||
|
if (!blockedBy.includes(filters.blockedBy)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTaskInventory(paths, teamName, filters = {}) {
|
||||||
|
return withTeamBoardLock(paths, () => {
|
||||||
|
const boardState = buildBoardState(paths, teamName);
|
||||||
|
const resolvedRelatedTo = normalizeName(filters.relatedTo)
|
||||||
|
? taskStore.resolveTaskRef(paths, filters.relatedTo)
|
||||||
|
: '';
|
||||||
|
const resolvedBlockedBy = normalizeName(filters.blockedBy)
|
||||||
|
? taskStore.resolveTaskRef(paths, filters.blockedBy)
|
||||||
|
: '';
|
||||||
|
const limit =
|
||||||
|
typeof filters.limit === 'number' && Number.isFinite(filters.limit)
|
||||||
|
? Math.max(1, Math.floor(filters.limit))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rows = boardState.tasks
|
||||||
|
.map((task) => {
|
||||||
|
const kanbanEntry = boardState.kanbanState.tasks ? boardState.kanbanState.tasks[task.id] : undefined;
|
||||||
|
const reviewState = resolveEffectiveReviewState(task, kanbanEntry).state;
|
||||||
|
return buildInventoryRow(task, reviewState);
|
||||||
|
})
|
||||||
|
.filter((row) =>
|
||||||
|
matchesInventoryFilters(row, {
|
||||||
|
...filters,
|
||||||
|
...(resolvedRelatedTo ? { relatedTo: resolvedRelatedTo } : {}),
|
||||||
|
...(resolvedBlockedBy ? { blockedBy: resolvedBlockedBy } : {}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return limit == null ? rows : rows.slice(0, limit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActionOwner(actionOwner) {
|
||||||
|
if (actionOwner.kind === 'member') return `@${actionOwner.memberName}`;
|
||||||
|
if (actionOwner.kind === 'lead') return 'lead';
|
||||||
|
if (actionOwner.kind === 'user') return 'user';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAgendaLine(item) {
|
||||||
|
const reviewSuffix = item.reviewState !== 'none' ? `, review=${item.reviewState}` : '';
|
||||||
|
const meta = [
|
||||||
|
`next=${item.nextAction}`,
|
||||||
|
`owner=${normalizeName(item.owner) || 'none'}`,
|
||||||
|
`actionOwner=${formatActionOwner(item.actionOwner)}`,
|
||||||
|
`reason=${item.reasonCode}`,
|
||||||
|
];
|
||||||
|
if (normalizeName(item.reviewer)) {
|
||||||
|
meta.push(`reviewer=${item.reviewer}`);
|
||||||
|
}
|
||||||
|
if (item.needsClarification) {
|
||||||
|
meta.push(`clarification=${item.needsClarification}`);
|
||||||
|
}
|
||||||
|
return `- ${formatTaskLabel(item)} [status=${item.status}${reviewSuffix}] ${item.subject} (${meta.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendExpandedTaskContext(lines, item) {
|
||||||
|
const task = item._fullTask;
|
||||||
|
if (!task || typeof task !== 'object') return;
|
||||||
|
|
||||||
|
if (normalizeName(task.description)) {
|
||||||
|
lines.push(` Description: ${task.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = Array.isArray(task.comments) ? task.comments : [];
|
||||||
|
if (comments.length === 0) return;
|
||||||
|
|
||||||
|
lines.push(' Comments:');
|
||||||
|
for (const comment of comments.slice(-5)) {
|
||||||
|
const author = normalizeName(comment && comment.author) || 'unknown';
|
||||||
|
const text = normalizeName(comment && comment.text) || '(empty comment)';
|
||||||
|
lines.push(` - ${author}: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAnomalyLine(anomaly) {
|
||||||
|
const ref = normalizeName(anomaly.taskId) ? ` (${anomaly.taskId})` : '';
|
||||||
|
return `- ${anomaly.code}${ref}: ${anomaly.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskBriefing(paths, teamName, memberName) {
|
||||||
|
const snapshot = buildAgendaSnapshot(paths, teamName, {
|
||||||
|
kind: 'member',
|
||||||
|
memberName: normalizeName(memberName),
|
||||||
|
});
|
||||||
|
const lines = [
|
||||||
|
`Task briefing for ${memberName}:`,
|
||||||
|
`Primary queue for ${memberName}. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.`,
|
||||||
|
`Use task_list only to search/browse inventory rows, not as your working queue.`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (snapshot.anomalies.length > 0) {
|
||||||
|
lines.push('', 'Board warnings:');
|
||||||
|
for (const anomaly of snapshot.anomalies) {
|
||||||
|
lines.push(formatAnomalyLine(anomaly));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.actionable.length === 0 && snapshot.awareness.length === 0) {
|
||||||
|
lines.push('', `No actionable or awareness tasks for ${memberName}.`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.actionable.length > 0) {
|
||||||
|
lines.push('', 'Actionable:');
|
||||||
|
for (const item of snapshot.actionable) {
|
||||||
|
lines.push(formatAgendaLine(item));
|
||||||
|
if (item.status === 'in_progress' || item.reasonCode === 'needs_fix') {
|
||||||
|
appendExpandedTaskContext(lines, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.awareness.length > 0) {
|
||||||
|
lines.push('', 'Awareness:');
|
||||||
|
for (const item of snapshot.awareness) {
|
||||||
|
lines.push(formatAgendaLine(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
`Counters: actionable=${snapshot.counters.actionable}, awareness=${snapshot.counters.awareness}, blocked=${snapshot.counters.blocked}, waitingOnUser=${snapshot.counters.waitingOnUser}, waitingOnLead=${snapshot.counters.waitingOnLead}, reviewNeeded=${snapshot.counters.reviewNeeded}, anomalies=${snapshot.counters.anomalies}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketLeadItems(items) {
|
||||||
|
const buckets = {
|
||||||
|
assign_owner: [],
|
||||||
|
assign_reviewer: [],
|
||||||
|
clarify_with_lead: [],
|
||||||
|
repair_dependencies: [],
|
||||||
|
lead_owned: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.nextAction === 'assign_owner') {
|
||||||
|
buckets.assign_owner.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.nextAction === 'assign_reviewer') {
|
||||||
|
buckets.assign_reviewer.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.nextAction === 'clarify_with_lead') {
|
||||||
|
buckets.clarify_with_lead.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.nextAction === 'repair_dependencies') {
|
||||||
|
buckets.repair_dependencies.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buckets.lead_owned.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLeadBriefing(paths, teamName) {
|
||||||
|
const roster = buildQueueRoster(paths);
|
||||||
|
const leadHeaderName = roster.leadHeaderName ? ` for ${roster.leadHeaderName}` : '';
|
||||||
|
const snapshot = buildAgendaSnapshot(paths, teamName, { kind: 'lead' });
|
||||||
|
const buckets = bucketLeadItems(snapshot.actionable);
|
||||||
|
const lines = [
|
||||||
|
`Lead queue${leadHeaderName} on team "${teamName}":`,
|
||||||
|
`Primary lead queue. Sections below already represent lead-owned actions or watch-only context.`,
|
||||||
|
`Use task_list only for search, filtering, and drill-down inventory lookups.`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (snapshot.anomalies.length > 0) {
|
||||||
|
lines.push('', 'Board anomalies:');
|
||||||
|
for (const anomaly of snapshot.anomalies) {
|
||||||
|
lines.push(formatAnomalyLine(anomaly));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
['Needs owner assignment:', buckets.assign_owner],
|
||||||
|
['Needs reviewer assignment:', buckets.assign_reviewer],
|
||||||
|
['Needs clarification from lead:', buckets.clarify_with_lead],
|
||||||
|
['Dependency repair:', buckets.repair_dependencies],
|
||||||
|
['Lead-owned follow-up:', buckets.lead_owned],
|
||||||
|
[
|
||||||
|
'Waiting on user:',
|
||||||
|
snapshot.awareness.filter((item) => item.reasonCode === 'waiting_user_clarification'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Watching:',
|
||||||
|
snapshot.awareness.filter((item) => item.reasonCode !== 'waiting_user_clarification'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
let renderedAnySection = false;
|
||||||
|
for (const [title, items] of sections) {
|
||||||
|
if (!items || items.length === 0) continue;
|
||||||
|
renderedAnySection = true;
|
||||||
|
lines.push('', title);
|
||||||
|
for (const item of items) {
|
||||||
|
lines.push(formatAgendaLine(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderedAnySection && snapshot.anomalies.length === 0) {
|
||||||
|
lines.push('', 'No lead action items.');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
`Counters: actionable=${snapshot.counters.actionable}, awareness=${snapshot.counters.awareness}, blocked=${snapshot.counters.blocked}, waitingOnUser=${snapshot.counters.waitingOnUser}, waitingOnLead=${snapshot.counters.waitingOnLead}, reviewNeeded=${snapshot.counters.reviewNeeded}, anomalies=${snapshot.counters.anomalies}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildAgendaSnapshot,
|
||||||
|
formatLeadBriefing,
|
||||||
|
formatTaskBriefing,
|
||||||
|
listTaskInventory,
|
||||||
|
resolveCurrentCycleReviewer,
|
||||||
|
resolveEffectiveReviewState,
|
||||||
|
};
|
||||||
42
agent-teams-controller/src/internal/boardLock.js
Normal file
42
agent-teams-controller/src/internal/boardLock.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { withFileLockSync } = require('./fileLock.js');
|
||||||
|
|
||||||
|
const reentrantLockDepthByScope = new Map();
|
||||||
|
|
||||||
|
function getTeamBoardLockScope(paths) {
|
||||||
|
return path.join(paths.teamDir, 'board-state');
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTeamBoardLock(paths, fn) {
|
||||||
|
const scope = getTeamBoardLockScope(paths);
|
||||||
|
const currentDepth = reentrantLockDepthByScope.get(scope) || 0;
|
||||||
|
|
||||||
|
if (currentDepth > 0) {
|
||||||
|
reentrantLockDepthByScope.set(scope, currentDepth + 1);
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
const nextDepth = (reentrantLockDepthByScope.get(scope) || 1) - 1;
|
||||||
|
if (nextDepth <= 0) {
|
||||||
|
reentrantLockDepthByScope.delete(scope);
|
||||||
|
} else {
|
||||||
|
reentrantLockDepthByScope.set(scope, nextDepth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return withFileLockSync(scope, () => {
|
||||||
|
reentrantLockDepthByScope.set(scope, 1);
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
reentrantLockDepthByScope.delete(scope);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getTeamBoardLockScope,
|
||||||
|
withTeamBoardLock,
|
||||||
|
};
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
const kanbanStore = require('./kanbanStore.js');
|
const kanbanStore = require('./kanbanStore.js');
|
||||||
const tasks = require('./tasks.js');
|
const tasks = require('./tasks.js');
|
||||||
|
const { withTeamBoardLock } = require('./boardLock.js');
|
||||||
|
|
||||||
function getKanbanState(context) {
|
function getKanbanState(context) {
|
||||||
return kanbanStore.readKanbanState(context.paths, context.teamName);
|
return kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setKanbanColumn(context, taskId, column) {
|
function setKanbanColumn(context, taskId, column) {
|
||||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
return withTeamBoardLock(context.paths, () => {
|
||||||
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
|
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||||
return getKanbanState(context);
|
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
|
||||||
|
return getKanbanState(context);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearKanban(context, taskId, options) {
|
function clearKanban(context, taskId, options) {
|
||||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
return withTeamBoardLock(context.paths, () => {
|
||||||
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
|
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||||
return getKanbanState(context);
|
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
|
||||||
|
return getKanbanState(context);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function listReviewers(context) {
|
function listReviewers(context) {
|
||||||
|
|
@ -22,29 +27,35 @@ function listReviewers(context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function addReviewer(context, reviewer) {
|
function addReviewer(context, reviewer) {
|
||||||
const state = getKanbanState(context);
|
return withTeamBoardLock(context.paths, () => {
|
||||||
const next = new Set(state.reviewers);
|
const state = getKanbanState(context);
|
||||||
next.add(String(reviewer));
|
const next = new Set(state.reviewers);
|
||||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
next.add(String(reviewer));
|
||||||
...state,
|
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||||
reviewers: [...next],
|
...state,
|
||||||
|
reviewers: [...next],
|
||||||
|
});
|
||||||
|
return listReviewers(context);
|
||||||
});
|
});
|
||||||
return listReviewers(context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeReviewer(context, reviewer) {
|
function removeReviewer(context, reviewer) {
|
||||||
const state = getKanbanState(context);
|
return withTeamBoardLock(context.paths, () => {
|
||||||
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
const state = getKanbanState(context);
|
||||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
||||||
...state,
|
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||||
reviewers: next,
|
...state,
|
||||||
|
reviewers: next,
|
||||||
|
});
|
||||||
|
return listReviewers(context);
|
||||||
});
|
});
|
||||||
return listReviewers(context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateColumnOrder(context, columnId, orderedTaskIds) {
|
function updateColumnOrder(context, columnId, orderedTaskIds) {
|
||||||
const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId));
|
return withTeamBoardLock(context.paths, () => {
|
||||||
return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds);
|
const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId));
|
||||||
|
return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,16 @@ const kanban = require('./kanban.js');
|
||||||
const messages = require('./messages.js');
|
const messages = require('./messages.js');
|
||||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||||
const tasks = require('./tasks.js');
|
const tasks = require('./tasks.js');
|
||||||
|
const { withTeamBoardLock } = require('./boardLock.js');
|
||||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||||
|
|
||||||
|
function warnNonCritical(message, error) {
|
||||||
|
if (typeof console === 'undefined' || typeof console.warn !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
function getReviewer(context, flags) {
|
function getReviewer(context, flags) {
|
||||||
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
|
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
|
||||||
return flags.reviewer.trim();
|
return flags.reviewer.trim();
|
||||||
|
|
@ -32,76 +40,115 @@ function getCurrentReviewState(task) {
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function startReview(context, taskId, flags = {}) {
|
function getLatestReviewLifecycleEvent(task) {
|
||||||
const task = tasks.getTask(context, taskId);
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
if (task.status === 'deleted') {
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
const e = events[i];
|
||||||
}
|
if (
|
||||||
|
e.type === 'review_requested' ||
|
||||||
const from =
|
e.type === 'review_changes_requested' ||
|
||||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer';
|
e.type === 'review_approved' ||
|
||||||
const prevReviewState = getCurrentReviewState(task);
|
e.type === 'review_started'
|
||||||
|
) {
|
||||||
// Idempotent: already in review → return ok without duplicate history event
|
return e;
|
||||||
if (prevReviewState === 'review') {
|
}
|
||||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||||
}
|
return e;
|
||||||
|
}
|
||||||
try {
|
if (e.type === 'task_created') {
|
||||||
kanban.setKanbanColumn(context, task.id, 'review');
|
return e;
|
||||||
tasks.updateTask(context, task.id, (t) => {
|
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
|
||||||
type: 'review_started',
|
|
||||||
from: prevReviewState,
|
|
||||||
to: 'review',
|
|
||||||
actor: from,
|
|
||||||
});
|
|
||||||
t.reviewState = 'review';
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
|
||||||
} catch (error) {
|
|
||||||
try {
|
|
||||||
kanban.clearKanban(context, task.id);
|
|
||||||
} catch {
|
|
||||||
// Best-effort rollback
|
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReview(context, taskId, flags = {}) {
|
||||||
|
return withTeamBoardLock(context.paths, () => {
|
||||||
|
const task = tasks.getTask(context, taskId);
|
||||||
|
if (task.status === 'deleted') {
|
||||||
|
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from =
|
||||||
|
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer';
|
||||||
|
const latestReviewEvent = getLatestReviewLifecycleEvent(task);
|
||||||
|
const prevReviewState = getCurrentReviewState(task);
|
||||||
|
|
||||||
|
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
|
||||||
|
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
kanban.setKanbanColumn(context, task.id, 'review');
|
||||||
|
tasks.updateTask(context, task.id, (t) => {
|
||||||
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
|
type: 'review_started',
|
||||||
|
from: prevReviewState,
|
||||||
|
to: 'review',
|
||||||
|
actor: from,
|
||||||
|
});
|
||||||
|
t.reviewState = 'review';
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
kanban.clearKanban(context, task.id);
|
||||||
|
} catch (rollbackError) {
|
||||||
|
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestReview(context, taskId, flags = {}) {
|
function requestReview(context, taskId, flags = {}) {
|
||||||
const task = tasks.getTask(context, taskId);
|
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
|
||||||
if (task.status !== 'completed') {
|
const currentTask = tasks.getTask(context, taskId);
|
||||||
throw new Error(`Task #${task.displayId || task.id} must be completed before review`);
|
if (currentTask.status !== 'completed') {
|
||||||
}
|
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`);
|
||||||
|
|
||||||
const from =
|
|
||||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
|
||||||
const reviewer = getReviewer(context, flags);
|
|
||||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
|
||||||
const prevReviewState = getCurrentReviewState(task);
|
|
||||||
|
|
||||||
try {
|
|
||||||
kanban.setKanbanColumn(context, task.id, 'review');
|
|
||||||
|
|
||||||
// Append review_requested event
|
|
||||||
tasks.updateTask(context, task.id, (t) => {
|
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
|
||||||
type: 'review_requested',
|
|
||||||
from: prevReviewState,
|
|
||||||
to: 'review',
|
|
||||||
...(reviewer ? { reviewer } : {}),
|
|
||||||
actor: from,
|
|
||||||
});
|
|
||||||
t.reviewState = 'review';
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!reviewer) {
|
|
||||||
return tasks.getTask(context, task.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextFrom =
|
||||||
|
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||||
|
const nextReviewer = getReviewer(context, flags);
|
||||||
|
const prevReviewState = getCurrentReviewState(currentTask);
|
||||||
|
|
||||||
|
try {
|
||||||
|
kanban.setKanbanColumn(context, currentTask.id, 'review');
|
||||||
|
tasks.updateTask(context, currentTask.id, (t) => {
|
||||||
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
|
type: 'review_requested',
|
||||||
|
from: prevReviewState,
|
||||||
|
to: 'review',
|
||||||
|
...(nextReviewer ? { reviewer: nextReviewer } : {}),
|
||||||
|
actor: nextFrom,
|
||||||
|
});
|
||||||
|
t.reviewState = 'review';
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
kanban.clearKanban(context, currentTask.id);
|
||||||
|
} catch (rollbackError) {
|
||||||
|
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: tasks.getTask(context, currentTask.id),
|
||||||
|
reviewer: nextReviewer,
|
||||||
|
from: nextFrom,
|
||||||
|
leadSessionId: resolveLeadSessionId(context, flags),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reviewer) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
messages.sendMessage(context, {
|
messages.sendMessage(context, {
|
||||||
to: reviewer,
|
to: reviewer,
|
||||||
from,
|
from,
|
||||||
|
|
@ -119,122 +166,158 @@ function requestReview(context, taskId, flags = {}) {
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
return tasks.getTask(context, task.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
warnNonCritical(`[review] reviewer notification failed for task ${task.id}`, error);
|
||||||
kanban.clearKanban(context, task.id);
|
|
||||||
} catch {
|
|
||||||
// Best-effort rollback: keep the original error.
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
function approveReview(context, taskId, flags = {}) {
|
function approveReview(context, taskId, flags = {}) {
|
||||||
const task = tasks.getTask(context, taskId);
|
const result = withTeamBoardLock(context.paths, () => {
|
||||||
const from =
|
const currentTask = tasks.getTask(context, taskId);
|
||||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
const nextFrom =
|
||||||
const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
|
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||||
const suppressTaskComment = flags.suppressTaskComment === true;
|
const nextNote =
|
||||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
|
||||||
const prevReviewState = getCurrentReviewState(task);
|
const suppressTaskComment = flags.suppressTaskComment === true;
|
||||||
|
const prevReviewState = getCurrentReviewState(currentTask);
|
||||||
|
|
||||||
// Idempotent: already approved → skip duplicate comment/event, only add note if new
|
if (prevReviewState === 'approved') {
|
||||||
if (prevReviewState === 'approved') {
|
return {
|
||||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'approved', alreadyApproved: true };
|
alreadyApproved: true,
|
||||||
}
|
payload: {
|
||||||
|
ok: true,
|
||||||
|
taskId: currentTask.id,
|
||||||
|
displayId: currentTask.displayId,
|
||||||
|
column: 'approved',
|
||||||
|
alreadyApproved: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
kanban.setKanbanColumn(context, task.id, 'approved');
|
kanban.setKanbanColumn(context, currentTask.id, 'approved');
|
||||||
|
tasks.updateTask(context, currentTask.id, (t) => {
|
||||||
// Append review_approved event
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
tasks.updateTask(context, task.id, (t) => {
|
type: 'review_approved',
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
from: prevReviewState,
|
||||||
type: 'review_approved',
|
to: 'approved',
|
||||||
from: prevReviewState,
|
...(nextNote ? { note: nextNote } : {}),
|
||||||
to: 'approved',
|
actor: nextFrom,
|
||||||
...(note ? { note } : {}),
|
});
|
||||||
actor: from,
|
t.reviewState = 'approved';
|
||||||
|
return t;
|
||||||
});
|
});
|
||||||
t.reviewState = 'approved';
|
|
||||||
return t;
|
if (!suppressTaskComment) {
|
||||||
|
tasks.addTaskComment(context, currentTask.id, {
|
||||||
|
text: nextNote,
|
||||||
|
from: nextFrom,
|
||||||
|
type: 'review_approved',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alreadyApproved: false,
|
||||||
|
payload: tasks.getTask(context, currentTask.id),
|
||||||
|
from: nextFrom,
|
||||||
|
note: nextNote,
|
||||||
|
leadSessionId: resolveLeadSessionId(context, flags),
|
||||||
|
shouldNotifyOwner:
|
||||||
|
(flags.notify === true || flags['notify-owner'] === true) && Boolean(currentTask.owner),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!suppressTaskComment) {
|
if (result.alreadyApproved) {
|
||||||
tasks.addTaskComment(context, task.id, {
|
return result.payload;
|
||||||
text: note,
|
|
||||||
from,
|
|
||||||
type: 'review_approved',
|
|
||||||
notifyOwner: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
|
const { payload: task, from, note, leadSessionId, shouldNotifyOwner } = result;
|
||||||
|
|
||||||
|
if (shouldNotifyOwner && task.owner) {
|
||||||
|
try {
|
||||||
|
messages.sendMessage(context, {
|
||||||
|
to: task.owner,
|
||||||
|
from,
|
||||||
|
text:
|
||||||
|
note && note !== 'Approved'
|
||||||
|
? `@${from} **approved** task #${task.displayId || task.id}\n\n${note}`
|
||||||
|
: `@${from} **approved** task #${task.displayId || task.id}`,
|
||||||
|
summary: `Approved #${task.displayId || task.id}`,
|
||||||
|
source: 'system_notification',
|
||||||
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
warnNonCritical(`[review] owner approval notification failed for task ${task.id}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestChanges(context, taskId, flags = {}) {
|
||||||
|
const { task, from, comment, leadSessionId } = withTeamBoardLock(context.paths, () => {
|
||||||
|
const currentTask = tasks.getTask(context, taskId);
|
||||||
|
if (!currentTask.owner) {
|
||||||
|
throw new Error(`No owner found for task ${String(taskId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFrom =
|
||||||
|
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||||
|
const nextComment =
|
||||||
|
typeof flags.comment === 'string' && flags.comment.trim()
|
||||||
|
? flags.comment.trim()
|
||||||
|
: 'Reviewer requested changes.';
|
||||||
|
const prevReviewState = getCurrentReviewState(currentTask);
|
||||||
|
|
||||||
|
tasks.updateTask(context, currentTask.id, (t) => {
|
||||||
|
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||||
|
type: 'review_changes_requested',
|
||||||
|
from: prevReviewState,
|
||||||
|
to: 'needsFix',
|
||||||
|
...(nextComment ? { note: nextComment } : {}),
|
||||||
|
actor: nextFrom,
|
||||||
|
});
|
||||||
|
t.reviewState = 'needsFix';
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
kanban.clearKanban(context, currentTask.id, { nextReviewState: 'needsFix' });
|
||||||
|
tasks.setTaskStatus(context, currentTask.id, 'pending', nextFrom);
|
||||||
|
tasks.addTaskComment(context, currentTask.id, {
|
||||||
|
text: nextComment,
|
||||||
|
from: nextFrom,
|
||||||
|
type: 'review_request',
|
||||||
|
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: tasks.getTask(context, currentTask.id),
|
||||||
|
from: nextFrom,
|
||||||
|
comment: nextComment,
|
||||||
|
leadSessionId: resolveLeadSessionId(context, flags),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
messages.sendMessage(context, {
|
messages.sendMessage(context, {
|
||||||
to: task.owner,
|
to: task.owner,
|
||||||
from,
|
from,
|
||||||
text:
|
text:
|
||||||
note && note !== 'Approved'
|
`@${from} **requested changes** for task #${task.displayId || task.id}\n\n${comment}\n\n` +
|
||||||
? `@${from} **approved** task #${task.displayId || task.id}\n\n${note}`
|
'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.',
|
||||||
: `@${from} **approved** task #${task.displayId || task.id}`,
|
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||||
summary: `Approved #${task.displayId || task.id}`,
|
summary: `Fix request for #${task.displayId || task.id}`,
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
warnNonCritical(`[review] owner fix-request notification failed for task ${task.id}`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks.getTask(context, task.id);
|
return task;
|
||||||
}
|
|
||||||
|
|
||||||
function requestChanges(context, taskId, flags = {}) {
|
|
||||||
const task = tasks.getTask(context, taskId);
|
|
||||||
if (!task.owner) {
|
|
||||||
throw new Error(`No owner found for task ${String(taskId)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const from =
|
|
||||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
|
||||||
const comment =
|
|
||||||
typeof flags.comment === 'string' && flags.comment.trim()
|
|
||||||
? flags.comment.trim()
|
|
||||||
: 'Reviewer requested changes.';
|
|
||||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
|
||||||
const prevReviewState = getCurrentReviewState(task);
|
|
||||||
|
|
||||||
// Append review_changes_requested event before status change
|
|
||||||
tasks.updateTask(context, task.id, (t) => {
|
|
||||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
|
||||||
type: 'review_changes_requested',
|
|
||||||
from: prevReviewState,
|
|
||||||
to: 'needsFix',
|
|
||||||
...(comment ? { note: comment } : {}),
|
|
||||||
actor: from,
|
|
||||||
});
|
|
||||||
t.reviewState = 'needsFix';
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
|
|
||||||
kanban.clearKanban(context, task.id, { nextReviewState: 'needsFix' });
|
|
||||||
tasks.setTaskStatus(context, task.id, 'pending', from);
|
|
||||||
tasks.addTaskComment(context, task.id, {
|
|
||||||
text: comment,
|
|
||||||
from,
|
|
||||||
type: 'review_request',
|
|
||||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
|
||||||
notifyOwner: false,
|
|
||||||
});
|
|
||||||
messages.sendMessage(context, {
|
|
||||||
to: task.owner,
|
|
||||||
from,
|
|
||||||
text:
|
|
||||||
`@${from} **requested changes** for task #${task.displayId || task.id}\n\n${comment}\n\n` +
|
|
||||||
'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.',
|
|
||||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
|
||||||
summary: `Fix request for #${task.displayId || task.id}`,
|
|
||||||
source: 'system_notification',
|
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return tasks.getTask(context, task.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,12 @@ function normalizeTaskReviewState(value) {
|
||||||
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function listRawTasks(paths) {
|
function listTaskRows(paths, options = {}) {
|
||||||
ensureDir(paths.tasksDir);
|
ensureDir(paths.tasksDir);
|
||||||
const entries = fs.readdirSync(paths.tasksDir);
|
const entries = fs.readdirSync(paths.tasksDir);
|
||||||
const out = [];
|
const includeDeleted = options.includeDeleted === true;
|
||||||
|
const tasks = [];
|
||||||
|
const anomalies = [];
|
||||||
|
|
||||||
for (const fileName of entries) {
|
for (const fileName of entries) {
|
||||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||||
|
|
@ -84,13 +86,25 @@ function listRawTasks(paths) {
|
||||||
if (!rawTask) continue;
|
if (!rawTask) continue;
|
||||||
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
||||||
try {
|
try {
|
||||||
out.push(normalizeTask(rawTask, filePath));
|
const task = normalizeTask(rawTask, filePath);
|
||||||
} catch {
|
if (includeDeleted || task.status !== 'deleted') {
|
||||||
// Skip unreadable task rows.
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const taskId =
|
||||||
|
typeof rawTask?.id === 'string' || typeof rawTask?.id === 'number'
|
||||||
|
? String(rawTask.id)
|
||||||
|
: path.basename(fileName, '.json');
|
||||||
|
anomalies.push({
|
||||||
|
code: 'unreadable_task',
|
||||||
|
taskId,
|
||||||
|
filePath,
|
||||||
|
detail: error instanceof Error ? error.message : 'Unreadable task row',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out.sort((a, b) => {
|
tasks.sort((a, b) => {
|
||||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
sensitivity: 'base',
|
sensitivity: 'base',
|
||||||
|
|
@ -102,12 +116,15 @@ function listRawTasks(paths) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return out;
|
return { tasks, anomalies };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRawTasks(paths) {
|
||||||
|
return listTaskRows(paths, { includeDeleted: true }).tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTasks(paths, options = {}) {
|
function listTasks(paths, options = {}) {
|
||||||
const includeDeleted = options.includeDeleted === true;
|
return listTaskRows(paths, options).tasks;
|
||||||
return listRawTasks(paths).filter((task) => includeDeleted || task.status !== 'deleted');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTaskRef(paths, taskRef, options = {}) {
|
function resolveTaskRef(paths, taskRef, options = {}) {
|
||||||
|
|
@ -479,30 +496,18 @@ function addTaskComment(paths, taskRef, text, options = {}) {
|
||||||
};
|
};
|
||||||
|
|
||||||
let inserted = false;
|
let inserted = false;
|
||||||
let clarificationCleared = false;
|
|
||||||
const task = updateTask(paths, taskRef, (currentTask) => {
|
const task = updateTask(paths, taskRef, (currentTask) => {
|
||||||
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
|
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
|
||||||
if (comments.some((entry) => entry.id === comment.id)) {
|
if (comments.some((entry) => entry.id === comment.id)) {
|
||||||
return currentTask;
|
return currentTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorName = normalizeMemberName(comment.author);
|
|
||||||
const ownerName = normalizeMemberName(currentTask.owner);
|
|
||||||
if (currentTask.needsClarification === 'lead' && authorName && authorName !== ownerName) {
|
|
||||||
delete currentTask.needsClarification;
|
|
||||||
clarificationCleared = true;
|
|
||||||
}
|
|
||||||
if (currentTask.needsClarification === 'user' && authorName === 'user') {
|
|
||||||
delete currentTask.needsClarification;
|
|
||||||
clarificationCleared = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTask.comments = comments.concat([comment]);
|
currentTask.comments = comments.concat([comment]);
|
||||||
inserted = true;
|
inserted = true;
|
||||||
return currentTask;
|
return currentTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { comment, task, inserted, clarificationCleared };
|
return { comment, task, inserted, clarificationCleared: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNeedsClarification(paths, taskRef, value) {
|
function setNeedsClarification(paths, taskRef, value) {
|
||||||
|
|
@ -808,6 +813,7 @@ module.exports = {
|
||||||
deriveDisplayId,
|
deriveDisplayId,
|
||||||
formatTaskBriefing,
|
formatTaskBriefing,
|
||||||
linkTask,
|
linkTask,
|
||||||
|
listTaskRows,
|
||||||
listTasks,
|
listTasks,
|
||||||
readTask,
|
readTask,
|
||||||
removeTaskAttachment,
|
removeTaskAttachment,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ const taskStore = require('./taskStore.js');
|
||||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||||
const messages = require('./messages.js');
|
const messages = require('./messages.js');
|
||||||
const processStore = require('./processStore.js');
|
const processStore = require('./processStore.js');
|
||||||
|
const kanbanStore = require('./kanbanStore.js');
|
||||||
|
const agenda = require('./agenda.js');
|
||||||
|
const { withTeamBoardLock } = require('./boardLock.js');
|
||||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||||
|
|
||||||
function normalizeActorName(value) {
|
function normalizeActorName(value) {
|
||||||
|
|
@ -42,6 +45,13 @@ function quoteMarkdown(text) {
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function warnNonCritical(message, error) {
|
||||||
|
if (typeof console === 'undefined' || typeof console.warn !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAssignmentMessage(context, task, options = {}) {
|
function buildAssignmentMessage(context, task, options = {}) {
|
||||||
const description =
|
const description =
|
||||||
typeof options.description === 'string' && options.description.trim() ?
|
typeof options.description === 'string' && options.description.trim() ?
|
||||||
|
|
@ -112,15 +122,19 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||||
messages.sendMessage(context, {
|
try {
|
||||||
member: owner,
|
messages.sendMessage(context, {
|
||||||
from: sender,
|
member: owner,
|
||||||
text: buildAssignmentMessage(context, task, options),
|
from: sender,
|
||||||
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
text: buildAssignmentMessage(context, task, options),
|
||||||
summary,
|
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
||||||
source: 'system_notification',
|
summary,
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
source: 'system_notification',
|
||||||
});
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
warnNonCritical(`[tasks] assignment notification failed for task ${task.id}`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
||||||
|
|
@ -157,7 +171,7 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTask(context, input) {
|
function createTask(context, input) {
|
||||||
const task = taskStore.createTask(context.paths, input);
|
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
|
||||||
if (input && input.notifyOwner !== false) {
|
if (input && input.notifyOwner !== false) {
|
||||||
maybeNotifyAssignedOwner(context, task, {
|
maybeNotifyAssignedOwner(context, task, {
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
|
@ -219,23 +233,21 @@ function resolveTaskId(context, taskRef) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTaskStatus(context, taskId, status, actor) {
|
function setTaskStatus(context, taskId, status, actor) {
|
||||||
return taskStore.setTaskStatus(context.paths, taskId, status, actor);
|
return withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.setTaskStatus(context.paths, taskId, status, actor)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTask(context, taskId, actor) {
|
function startTask(context, taskId, actor) {
|
||||||
const task = setTaskStatus(context, taskId, 'in_progress', actor);
|
return withTeamBoardLock(context.paths, () => {
|
||||||
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
|
const task = taskStore.setTaskStatus(context.paths, taskId, 'in_progress', actor);
|
||||||
try {
|
|
||||||
const kanbanStore = require('./kanbanStore.js');
|
|
||||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||||
if (state.tasks[task.id]) {
|
if (state.tasks[task.id]) {
|
||||||
delete state.tasks[task.id];
|
delete state.tasks[task.id];
|
||||||
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
||||||
}
|
}
|
||||||
} catch {
|
return task;
|
||||||
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
|
});
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyUnblockedOwners(context, completedTask) {
|
function notifyUnblockedOwners(context, completedTask) {
|
||||||
|
|
@ -303,8 +315,8 @@ function completeTask(context, taskId, actor) {
|
||||||
const task = setTaskStatus(context, taskId, 'completed', actor);
|
const task = setTaskStatus(context, taskId, 'completed', actor);
|
||||||
try {
|
try {
|
||||||
notifyUnblockedOwners(context, task);
|
notifyUnblockedOwners(context, task);
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Best-effort: task completion succeeded, notification failure is non-fatal
|
warnNonCritical(`[tasks] dependency-resolution follow-up failed for task ${task.id}`, error);
|
||||||
}
|
}
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
@ -318,8 +330,14 @@ function restoreTask(context, taskId, actor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTaskOwner(context, taskId, owner) {
|
function setTaskOwner(context, taskId, owner) {
|
||||||
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
|
||||||
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
|
const after = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||||
|
return {
|
||||||
|
previousTask: before,
|
||||||
|
updatedTask: after,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
owner != null &&
|
owner != null &&
|
||||||
|
|
@ -335,19 +353,23 @@ function setTaskOwner(context, taskId, owner) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTaskFields(context, taskId, fields) {
|
function updateTaskFields(context, taskId, fields) {
|
||||||
return taskStore.updateTaskFields(context.paths, taskId, fields);
|
return withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.updateTaskFields(context.paths, taskId, fields)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTaskComment(context, taskId, flags) {
|
function addTaskComment(context, taskId, flags) {
|
||||||
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
const result = withTeamBoardLock(context.paths, () =>
|
||||||
author: typeof flags.from === 'string' && flags.from.trim() ?
|
taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
||||||
flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
|
author: typeof flags.from === 'string' && flags.from.trim() ?
|
||||||
...(flags.id ? { id: flags.id } : {}),
|
flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
|
||||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
...(flags.id ? { id: flags.id } : {}),
|
||||||
...(flags.type ? { type: flags.type } : {}),
|
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
...(flags.type ? { type: flags.type } : {}),
|
||||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||||
});
|
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||||
|
|
@ -355,12 +377,7 @@ function addTaskComment(context, taskId, flags) {
|
||||||
notifyOwner: flags.notifyOwner,
|
notifyOwner: flags.notifyOwner,
|
||||||
});
|
});
|
||||||
} catch (notifyError) {
|
} catch (notifyError) {
|
||||||
// Best-effort: comment is already persisted, notification failure must not fail the call
|
warnNonCritical(`[tasks] owner notification failed for task ${taskId}`, notifyError);
|
||||||
if (typeof console !== 'undefined' && console.warn) {
|
|
||||||
console.warn(
|
|
||||||
`[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -376,7 +393,9 @@ function addTaskComment(context, taskId, flags) {
|
||||||
function attachTaskFile(context, taskId, flags) {
|
function attachTaskFile(context, taskId, flags) {
|
||||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||||
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
|
const task = withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta)
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...saved.meta,
|
...saved.meta,
|
||||||
task,
|
task,
|
||||||
|
|
@ -386,7 +405,9 @@ function attachTaskFile(context, taskId, flags) {
|
||||||
function attachCommentFile(context, taskId, commentId, flags) {
|
function attachCommentFile(context, taskId, commentId, flags) {
|
||||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||||
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
|
const task = withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta)
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...saved.meta,
|
...saved.meta,
|
||||||
task,
|
task,
|
||||||
|
|
@ -394,27 +415,45 @@ function attachCommentFile(context, taskId, commentId, flags) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTaskAttachmentMeta(context, taskId, meta) {
|
function addTaskAttachmentMeta(context, taskId, meta) {
|
||||||
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
|
return withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.addTaskAttachmentMeta(context.paths, taskId, meta)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTaskAttachment(context, taskId, attachmentId) {
|
function removeTaskAttachment(context, taskId, attachmentId) {
|
||||||
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
|
return withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.removeTaskAttachment(context.paths, taskId, attachmentId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNeedsClarification(context, taskId, value) {
|
function setNeedsClarification(context, taskId, value) {
|
||||||
return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
|
return withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkTask(context, taskId, targetId, linkType) {
|
function linkTask(context, taskId, targetId, linkType) {
|
||||||
return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
|
return withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.linkTask(context.paths, taskId, targetId, String(linkType))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unlinkTask(context, taskId, targetId, linkType) {
|
function unlinkTask(context, taskId, targetId, linkType) {
|
||||||
return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
|
return withTeamBoardLock(context.paths, () =>
|
||||||
|
taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function taskBriefing(context, memberName) {
|
async function taskBriefing(context, memberName) {
|
||||||
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
|
return agenda.formatTaskBriefing(context.paths, context.teamName, String(memberName));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leadBriefing(context) {
|
||||||
|
return agenda.formatLeadBriefing(context.paths, context.teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTaskInventory(context, filters = {}) {
|
||||||
|
return agenda.listTaskInventory(context.paths, context.teamName, filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSystemLocale() {
|
function getSystemLocale() {
|
||||||
|
|
@ -505,10 +544,11 @@ function buildMemberTaskProtocol(teamName) {
|
||||||
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
|
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
|
||||||
- After task_complete, send a notification to your team lead via SendMessage. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results.
|
- After task_complete, send a notification to your team lead via SendMessage. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results.
|
||||||
Example: "#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678 next."
|
Example: "#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678 next."
|
||||||
- After task_complete, if the task needs review AND the team has a member whose role includes reviewing (e.g. "reviewer", "tech-lead", "qa"), IMMEDIATELY call review_request to move it to the review column and notify the reviewer:
|
- After task_complete, call review_request ONLY when review is explicitly expected for THIS task and a concrete reviewer is already known.
|
||||||
|
Example:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
|
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
|
||||||
Do NOT leave a completed task without sending it to review when review is expected and a reviewer exists.
|
Do NOT infer mandatory review just from free-form teammate roles like "reviewer", "qa", or "tech-lead".
|
||||||
If no team member has a reviewer role, skip review_request — the task stays completed.
|
If review is not explicitly requested yet or the reviewer is still undecided, leave the task completed and wait.
|
||||||
3b. When you BEGIN reviewing a task, FIRST call review_start to ensure it appears in the REVIEW column:
|
3b. When you BEGIN reviewing a task, FIRST call review_start to ensure it appears in the REVIEW column:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>" }
|
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>" }
|
||||||
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
|
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
|
||||||
|
|
@ -543,16 +583,19 @@ function buildMemberTaskProtocol(teamName) {
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
|
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
|
||||||
c) STEP 3 — THEN, send a message to your team lead via SendMessage so they notice it promptly.
|
c) STEP 3 — THEN, send a message to your team lead via SendMessage so they notice it promptly.
|
||||||
IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board.
|
IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board.
|
||||||
d) The flag is auto-cleared when the lead adds a task comment on your task.
|
d) The clarification flag is durable until it is cleared explicitly.
|
||||||
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
|
When the blocker is truly resolved, clear the flag yourself with:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
|
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
|
||||||
e) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
|
e) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
|
||||||
13. DEPENDENCY AWARENESS:
|
13. 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, blocked task owners are notified automatically via a task comment.
|
When you complete a task that blocks others, blocked task owners are notified automatically via a task comment.
|
||||||
14. TASK QUEUE DISCIPLINE:
|
14. TASK QUEUE DISCIPLINE:
|
||||||
- Use task_briefing as a compact queue view of your assigned tasks.
|
- task_briefing is your primary working queue for assigned tasks.
|
||||||
|
- Use task_list only to search/browse inventory rows. Do NOT use task_list as your working queue.
|
||||||
- task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose.
|
- task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose.
|
||||||
|
- Act only on Actionable items from task_briefing.
|
||||||
|
- Awareness items are watch-only context. Do NOT start work from Awareness unless the lead reroutes the task or you become the actionOwner first.
|
||||||
- Finish existing in_progress tasks first.
|
- Finish existing in_progress tasks first.
|
||||||
- A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are still busy on another task, blocked, or still need more context, immediately add a short task comment on that waiting task with the reason and your best ETA or what you are waiting on.
|
- A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are still busy on another task, blocked, or still need more context, immediately add a short task comment on that waiting task with the reason and your best ETA or what you are waiting on.
|
||||||
- Keep any task you have not actually started in pending/TODO (use task_set_status pending if it was moved too early).
|
- Keep any task you have not actually started in pending/TODO (use task_set_status pending if it was moved too early).
|
||||||
|
|
@ -710,10 +753,11 @@ async function memberBriefing(context, memberName) {
|
||||||
'',
|
'',
|
||||||
`Bootstrap flow:`,
|
`Bootstrap flow:`,
|
||||||
`1. Use this briefing as your durable rules source.`,
|
`1. Use this briefing as your durable rules source.`,
|
||||||
`2. Use task_briefing as your compact queue view whenever you need to see assigned work.`,
|
`2. Use task_briefing as your primary working queue whenever you need to see assigned work. Use task_list only to search/browse inventory rows, not as your working queue.`,
|
||||||
`3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`,
|
`3. Act only on Actionable items in task_briefing. Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.`,
|
||||||
`4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`,
|
`4. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`,
|
||||||
`5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.`
|
`5. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`,
|
||||||
|
`6. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.`
|
||||||
);
|
);
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|
@ -754,7 +798,9 @@ module.exports = {
|
||||||
getTaskComment,
|
getTaskComment,
|
||||||
linkTask,
|
linkTask,
|
||||||
listDeletedTasks,
|
listDeletedTasks,
|
||||||
|
listTaskInventory,
|
||||||
listTasks,
|
listTasks,
|
||||||
|
leadBriefing,
|
||||||
removeTaskAttachment,
|
removeTaskAttachment,
|
||||||
resolveTaskId,
|
resolveTaskId,
|
||||||
restoreTask,
|
restoreTask,
|
||||||
|
|
@ -770,6 +816,6 @@ module.exports = {
|
||||||
taskBriefing,
|
taskBriefing,
|
||||||
unlinkTask,
|
unlinkTask,
|
||||||
updateTask: (context, taskRef, updater) =>
|
updateTask: (context, taskRef, updater) =>
|
||||||
taskStore.updateTask(context.paths, taskRef, updater),
|
withTeamBoardLock(context.paths, () => taskStore.updateTask(context.paths, taskRef, updater)),
|
||||||
updateTaskFields,
|
updateTaskFields,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
||||||
'task_unlink',
|
'task_unlink',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_LEAD_TOOL_NAMES = ['lead_briefing'];
|
||||||
|
|
||||||
const AGENT_TEAMS_REVIEW_TOOL_NAMES = [
|
const AGENT_TEAMS_REVIEW_TOOL_NAMES = [
|
||||||
'review_approve',
|
'review_approve',
|
||||||
'review_request',
|
'review_request',
|
||||||
|
|
@ -57,6 +59,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [
|
||||||
teammateOperational: true,
|
teammateOperational: true,
|
||||||
toolNames: AGENT_TEAMS_TASK_TOOL_NAMES,
|
toolNames: AGENT_TEAMS_TASK_TOOL_NAMES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'lead',
|
||||||
|
teammateOperational: false,
|
||||||
|
toolNames: AGENT_TEAMS_LEAD_TOOL_NAMES,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'kanban',
|
id: 'kanban',
|
||||||
teammateOperational: false,
|
teammateOperational: false,
|
||||||
|
|
@ -100,8 +107,17 @@ const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.
|
||||||
const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES =
|
const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES =
|
||||||
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
|
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
|
||||||
|
|
||||||
|
const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES = [
|
||||||
|
...AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
|
...AGENT_TEAMS_LEAD_TOOL_NAMES,
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES =
|
||||||
|
AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
AGENT_TEAMS_TASK_TOOL_NAMES,
|
AGENT_TEAMS_TASK_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_LEAD_TOOL_NAMES,
|
||||||
AGENT_TEAMS_REVIEW_TOOL_NAMES,
|
AGENT_TEAMS_REVIEW_TOOL_NAMES,
|
||||||
AGENT_TEAMS_MESSAGE_TOOL_NAMES,
|
AGENT_TEAMS_MESSAGE_TOOL_NAMES,
|
||||||
AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES,
|
AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES,
|
||||||
|
|
@ -112,4 +128,6 @@ module.exports = {
|
||||||
AGENT_TEAMS_REGISTERED_TOOL_NAMES,
|
AGENT_TEAMS_REGISTERED_TOOL_NAMES,
|
||||||
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,11 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(briefing).toContain('Implement carefully');
|
expect(briefing).toContain('Implement carefully');
|
||||||
expect(briefing).toContain('Working directory: /tmp/project-x');
|
expect(briefing).toContain('Working directory: /tmp/project-x');
|
||||||
expect(briefing).toContain('Task briefing for bob:');
|
expect(briefing).toContain('Task briefing for bob:');
|
||||||
|
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
|
||||||
|
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
|
||||||
|
expect(briefing).toContain(
|
||||||
|
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves member briefing from members.meta.json when config members are missing', async () => {
|
it('resolves member briefing from members.meta.json when config members are missing', async () => {
|
||||||
|
|
@ -280,7 +285,7 @@ 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 groups briefing by review-aware sections', async () => {
|
it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing into actionable and awareness queues', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
|
@ -355,24 +360,29 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||||
|
|
||||||
const briefing = await controller.tasks.taskBriefing('bob');
|
const briefing = await controller.tasks.taskBriefing('bob');
|
||||||
expect(briefing).toContain('In progress:');
|
expect(briefing).toContain(
|
||||||
|
'Primary queue for bob. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
|
||||||
|
);
|
||||||
|
expect(briefing).toContain(
|
||||||
|
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
||||||
|
);
|
||||||
|
expect(briefing).toContain('Actionable:');
|
||||||
expect(briefing).toContain(`#${activeTask.displayId}`);
|
expect(briefing).toContain(`#${activeTask.displayId}`);
|
||||||
expect(briefing).toContain('Description: Resume immediately');
|
expect(briefing).toContain('Description: Resume immediately');
|
||||||
expect(briefing).toContain('Resumed work with latest context.');
|
expect(briefing).toContain('Resumed work with latest context.');
|
||||||
expect(briefing).toContain('Needs fixes after review:');
|
|
||||||
expect(briefing).toContain(`#${needsFixTask.displayId}`);
|
expect(briefing).toContain(`#${needsFixTask.displayId}`);
|
||||||
expect(briefing).toContain('Pending:');
|
expect(briefing).toContain('reason=needs_fix');
|
||||||
expect(briefing).toContain(`#${pendingTask.displayId}`);
|
expect(briefing).toContain(`#${pendingTask.displayId}`);
|
||||||
expect(briefing).not.toContain('Description: Do this later');
|
expect(briefing).not.toContain('Description: Do this later');
|
||||||
expect(briefing).toContain('Review:');
|
expect(briefing).toContain('Awareness:');
|
||||||
expect(briefing).toContain(`#${reviewTask.displayId}`);
|
expect(briefing).toContain(`#${reviewTask.displayId}`);
|
||||||
expect(briefing).toContain('Completed:');
|
expect(briefing).toContain('reason=review_reviewer_missing');
|
||||||
expect(briefing).toContain(`#${completedTask.displayId}`);
|
expect(briefing).toContain(`#${completedTask.displayId}`);
|
||||||
expect(briefing).not.toContain(
|
expect(briefing).not.toContain(
|
||||||
'Completed task description should stay out of compact rows'
|
'Completed task description should stay out of compact rows'
|
||||||
);
|
);
|
||||||
expect(briefing).toContain('Approved (last 10):');
|
|
||||||
expect(briefing).toContain(`#${approvedTask.displayId}`);
|
expect(briefing).toContain(`#${approvedTask.displayId}`);
|
||||||
|
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||||
|
|
@ -584,6 +594,31 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(startedEvents).toHaveLength(1);
|
expect(startedEvents).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({ subject: 'Queued for review', owner: 'bob' });
|
||||||
|
|
||||||
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
|
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||||
|
const started = controller.review.startReview(task.id, { from: 'alice' });
|
||||||
|
|
||||||
|
expect(started.ok).toBe(true);
|
||||||
|
const reloaded = controller.tasks.getTask(task.id);
|
||||||
|
const requestedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_requested');
|
||||||
|
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
|
||||||
|
expect(requestedEvents).toHaveLength(1);
|
||||||
|
expect(startedEvents).toHaveLength(1);
|
||||||
|
expect(startedEvents[0].from).toBe('review');
|
||||||
|
expect(startedEvents[0].to).toBe('review');
|
||||||
|
expect(startedEvents[0].actor).toBe('alice');
|
||||||
|
|
||||||
|
const reviewerBriefing = await controller.tasks.taskBriefing('alice');
|
||||||
|
expect(reviewerBriefing).toContain(`#${task.displayId}`);
|
||||||
|
expect(reviewerBriefing).toContain('reason=review_in_progress');
|
||||||
|
expect(reviewerBriefing).toContain('reviewer=alice');
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when starting review on a deleted task', () => {
|
it('throws when starting review on a deleted task', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -639,7 +674,7 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(rows[0].leadSessionId).toBe('lead-session-1');
|
expect(rows[0].leadSessionId).toBe('lead-session-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not wake owner for self-comments and clears user clarification when user replies', () => {
|
it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
const task = controller.tasks.createTask({
|
const task = controller.tasks.createTask({
|
||||||
|
|
@ -662,12 +697,16 @@ describe('agent-teams-controller API', () => {
|
||||||
text: 'Please use the safer option.',
|
text: 'Please use the safer option.',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replied.task.needsClarification).toBeUndefined();
|
expect(replied.task.needsClarification).toBe('user');
|
||||||
const reloaded = controller.tasks.getTask(task.id);
|
const reloaded = controller.tasks.getTask(task.id);
|
||||||
expect(reloaded.needsClarification).toBeUndefined();
|
expect(reloaded.needsClarification).toBe('user');
|
||||||
const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0].text).toContain('Please use the safer option.');
|
expect(rows[0].text).toContain('Please use the safer option.');
|
||||||
|
|
||||||
|
const cleared = controller.tasks.setNeedsClarification(task.id, 'clear');
|
||||||
|
expect(cleared.needsClarification).toBeUndefined();
|
||||||
|
expect(controller.tasks.getTask(task.id).needsClarification).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wakes lead owner on comment from another member', () => {
|
it('wakes lead owner on comment from another member', () => {
|
||||||
|
|
@ -757,7 +796,7 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
|
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
|
it('keeps approved tasks in awareness ordered by freshness', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
|
@ -772,11 +811,112 @@ describe('agent-teams-controller API', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const briefing = await controller.tasks.taskBriefing('bob');
|
const briefing = await controller.tasks.taskBriefing('bob');
|
||||||
expect(briefing).toContain('Approved (last 10):');
|
expect(briefing).toContain('Awareness:');
|
||||||
expect(briefing).toContain(`#${approvedTasks[11].displayId}`);
|
expect(briefing).toContain(`#${approvedTasks[11].displayId}`);
|
||||||
expect(briefing).toContain(`#${approvedTasks[2].displayId}`);
|
expect(briefing).toContain(`#${approvedTasks[2].displayId}`);
|
||||||
expect(briefing).not.toContain(`#${approvedTasks[1].displayId}`);
|
expect(briefing).toContain(`#${approvedTasks[1].displayId}`);
|
||||||
expect(briefing).not.toContain(`#${approvedTasks[0].displayId}`);
|
expect(briefing).toContain(`#${approvedTasks[0].displayId}`);
|
||||||
|
expect(briefing.indexOf(`#${approvedTasks[11].displayId}`)).toBeLessThan(
|
||||||
|
briefing.indexOf(`#${approvedTasks[0].displayId}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds derived lead briefing and filtered task inventory', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
const queuedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Queued implementation',
|
||||||
|
owner: 'bob',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
const unassignedTask = controller.tasks.createTask({
|
||||||
|
subject: 'Needs owner',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
const reviewTask = controller.tasks.createTask({
|
||||||
|
subject: 'Needs review pickup',
|
||||||
|
owner: 'bob',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.tasks.completeTask(reviewTask.id, 'bob');
|
||||||
|
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'alice' });
|
||||||
|
|
||||||
|
const leadBriefing = await controller.tasks.leadBriefing();
|
||||||
|
expect(leadBriefing).toContain('Lead queue for alice on team "my-team":');
|
||||||
|
expect(leadBriefing).toContain(
|
||||||
|
'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.'
|
||||||
|
);
|
||||||
|
expect(leadBriefing).toContain(
|
||||||
|
'Use task_list only for search, filtering, and drill-down inventory lookups.'
|
||||||
|
);
|
||||||
|
expect(leadBriefing).toContain('Needs owner assignment:');
|
||||||
|
expect(leadBriefing).toContain(`#${unassignedTask.displayId}`);
|
||||||
|
expect(leadBriefing).toContain('Lead-owned follow-up:');
|
||||||
|
expect(leadBriefing).toContain(`#${reviewTask.displayId}`);
|
||||||
|
|
||||||
|
const reviewInventory = controller.tasks.listTaskInventory({ reviewState: 'review' });
|
||||||
|
expect(reviewInventory).toHaveLength(1);
|
||||||
|
expect(reviewInventory[0].id).toBe(reviewTask.id);
|
||||||
|
|
||||||
|
const ownerPendingInventory = controller.tasks.listTaskInventory({
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
expect(ownerPendingInventory.map((task) => task.id)).toEqual([queuedTask.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses legacy kanban reviewer as a migration fallback for active review tasks', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
config.members.push({ name: 'carol', role: 'reviewer' });
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const reviewTask = controller.tasks.createTask({
|
||||||
|
subject: 'Legacy review assignment',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'completed',
|
||||||
|
reviewState: 'review',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
teamName: 'my-team',
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {
|
||||||
|
[reviewTask.id]: {
|
||||||
|
column: 'review',
|
||||||
|
reviewer: 'carol',
|
||||||
|
movedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewerBriefing = await controller.tasks.taskBriefing('carol');
|
||||||
|
expect(reviewerBriefing).toContain(
|
||||||
|
'Primary queue for carol. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
|
||||||
|
);
|
||||||
|
expect(reviewerBriefing).toContain('Actionable:');
|
||||||
|
expect(reviewerBriefing).toContain(`#${reviewTask.displayId}`);
|
||||||
|
expect(reviewerBriefing).toContain('reviewer=carol');
|
||||||
|
|
||||||
|
const leadBriefing = await controller.tasks.leadBriefing();
|
||||||
|
expect(leadBriefing).toContain(
|
||||||
|
'Use task_list only for search, filtering, and drill-down inventory lookups.'
|
||||||
|
);
|
||||||
|
expect(leadBriefing).toContain('Watching:');
|
||||||
|
expect(leadBriefing).toContain(`#${reviewTask.displayId}`);
|
||||||
|
expect(leadBriefing).not.toContain('review_reviewer_missing');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks stale processes stopped during listing and supports unregister', () => {
|
it('marks stale processes stopped during listing and supports unregister', () => {
|
||||||
|
|
|
||||||
6
mcp-server/src/agent-teams-controller.d.ts
vendored
6
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -9,6 +9,7 @@ declare module 'agent-teams-controller' {
|
||||||
getTask(taskId: string): unknown;
|
getTask(taskId: string): unknown;
|
||||||
getTaskComment(taskId: string, commentId: string): { comment: Record<string, unknown>; task: { id: string; displayId: string; subject: string; status: string; owner: string | null; commentCount: number } };
|
getTaskComment(taskId: string, commentId: string): { comment: Record<string, unknown>; task: { id: string; displayId: string; subject: string; status: string; owner: string | null; commentCount: number } };
|
||||||
listTasks(): unknown[];
|
listTasks(): unknown[];
|
||||||
|
listTaskInventory(filters?: Record<string, unknown>): unknown[];
|
||||||
listDeletedTasks(): unknown[];
|
listDeletedTasks(): unknown[];
|
||||||
resolveTaskId(taskRef: string): string;
|
resolveTaskId(taskRef: string): string;
|
||||||
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
|
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
|
||||||
|
|
@ -27,6 +28,7 @@ declare module 'agent-teams-controller' {
|
||||||
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;
|
||||||
memberBriefing(memberName: string): Promise<string>;
|
memberBriefing(memberName: string): Promise<string>;
|
||||||
|
leadBriefing(): Promise<string>;
|
||||||
taskBriefing(memberName: string): Promise<string>;
|
taskBriefing(memberName: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +113,7 @@ declare module 'agent-teams-controller' {
|
||||||
|
|
||||||
export type AgentTeamsMcpToolGroupId =
|
export type AgentTeamsMcpToolGroupId =
|
||||||
| 'task'
|
| 'task'
|
||||||
|
| 'lead'
|
||||||
| 'kanban'
|
| 'kanban'
|
||||||
| 'review'
|
| 'review'
|
||||||
| 'message'
|
| 'message'
|
||||||
|
|
@ -125,6 +128,7 @@ declare module 'agent-teams-controller' {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_LEAD_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[];
|
||||||
|
|
@ -135,4 +139,6 @@ declare module 'agent-teams-controller' {
|
||||||
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } =
|
||||||
|
|
||||||
import { registerCrossTeamTools } from './crossTeamTools';
|
import { registerCrossTeamTools } from './crossTeamTools';
|
||||||
import { registerKanbanTools } from './kanbanTools';
|
import { registerKanbanTools } from './kanbanTools';
|
||||||
|
import { registerLeadTools } from './leadTools';
|
||||||
import { registerMessageTools } from './messageTools';
|
import { registerMessageTools } from './messageTools';
|
||||||
import { registerProcessTools } from './processTools';
|
import { registerProcessTools } from './processTools';
|
||||||
import { registerReviewTools } from './reviewTools';
|
import { registerReviewTools } from './reviewTools';
|
||||||
|
|
@ -15,6 +16,7 @@ import { registerTaskTools } from './taskTools';
|
||||||
|
|
||||||
const REGISTRATION_BY_GROUP = {
|
const REGISTRATION_BY_GROUP = {
|
||||||
task: registerTaskTools,
|
task: registerTaskTools,
|
||||||
|
lead: registerLeadTools,
|
||||||
kanban: registerKanbanTools,
|
kanban: registerKanbanTools,
|
||||||
review: registerReviewTools,
|
review: registerReviewTools,
|
||||||
message: registerMessageTools,
|
message: registerMessageTools,
|
||||||
|
|
|
||||||
32
mcp-server/src/tools/leadTools.ts
Normal file
32
mcp-server/src/tools/leadTools.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { FastMCP } from 'fastmcp';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getController } from '../controller';
|
||||||
|
|
||||||
|
const toolContextSchema = {
|
||||||
|
teamName: z.string().min(1),
|
||||||
|
claudeDir: z.string().min(1).optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALWAYS_LOAD_META = {
|
||||||
|
'anthropic/alwaysLoad': true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
server.addTool({
|
||||||
|
name: 'lead_briefing',
|
||||||
|
description: 'Get the compact operational lead queue for a team',
|
||||||
|
_meta: ALWAYS_LOAD_META,
|
||||||
|
parameters: z.object({
|
||||||
|
...toolContextSchema,
|
||||||
|
}),
|
||||||
|
execute: async ({ teamName, claudeDir }) => ({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import path from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { agentBlocks, getController } from '../controller';
|
import { agentBlocks, getController } from '../controller';
|
||||||
import { jsonTextContent, taskWriteResult, slimTask, slimTaskForList } from '../utils/format';
|
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
||||||
|
|
||||||
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
||||||
const stripAgentBlocksFn = (text: string): string => agentBlocks.stripAgentBlocks(text);
|
const stripAgentBlocksFn = (text: string): string => agentBlocks.stripAgentBlocks(text);
|
||||||
|
|
@ -19,6 +19,18 @@ const ALWAYS_LOAD_META = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
||||||
|
const inventoryTaskStatusSchema = z.enum(['pending', 'in_progress', 'completed']);
|
||||||
|
const reviewStateSchema = z.enum(['none', 'review', 'needsFix', 'approved']);
|
||||||
|
const inventoryKanbanColumnSchema = z.enum(['review', 'approved']);
|
||||||
|
const DEFAULT_TASK_LIST_LIMIT = 50;
|
||||||
|
const MAX_TASK_LIST_LIMIT = 200;
|
||||||
|
|
||||||
|
function normalizeTaskListLimit(limit: number | undefined): number {
|
||||||
|
if (limit == null) {
|
||||||
|
return DEFAULT_TASK_LIST_LIMIT;
|
||||||
|
}
|
||||||
|
return Math.min(Math.max(1, Math.floor(limit)), MAX_TASK_LIST_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
|
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
|
||||||
const USER_ORIGINATED_SOURCES = new Set(['user_sent']);
|
const USER_ORIGINATED_SOURCES = new Set(['user_sent']);
|
||||||
|
|
@ -299,14 +311,40 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_list',
|
name: 'task_list',
|
||||||
description: 'List tasks for a team',
|
description:
|
||||||
|
'List compact active task inventory/search rows for a team. Deleted tasks are excluded. Use it to browse, filter, and drill into inventory, not as a primary working queue. Defaults to 50 rows and caps at 200 rows; use filters or a smaller limit to narrow results. Supports stable conjunctive filters for owner, active status, reviewState, review overlay column, and task relationships.',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
|
owner: z.string().min(1).optional(),
|
||||||
|
status: inventoryTaskStatusSchema.optional(),
|
||||||
|
reviewState: reviewStateSchema.optional(),
|
||||||
|
kanbanColumn: inventoryKanbanColumnSchema.optional(),
|
||||||
|
relatedTo: z.string().min(1).optional(),
|
||||||
|
blockedBy: z.string().min(1).optional(),
|
||||||
|
limit: z.number().int().positive().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({
|
||||||
|
teamName,
|
||||||
|
claudeDir,
|
||||||
|
owner,
|
||||||
|
status,
|
||||||
|
reviewState,
|
||||||
|
kanbanColumn,
|
||||||
|
relatedTo,
|
||||||
|
blockedBy,
|
||||||
|
limit,
|
||||||
|
}) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
(getController(teamName, claudeDir).tasks.listTasks() as Record<string, unknown>[]).map(slimTaskForList)
|
getController(teamName, claudeDir).tasks.listTaskInventory({
|
||||||
|
...(owner ? { owner } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(reviewState ? { reviewState } : {}),
|
||||||
|
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||||
|
...(relatedTo ? { relatedTo } : {}),
|
||||||
|
...(blockedBy ? { blockedBy } : {}),
|
||||||
|
limit: normalizeTaskListLimit(limit),
|
||||||
|
})
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import path from 'path';
|
||||||
import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools';
|
import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools';
|
||||||
|
|
||||||
type RegisteredTool = {
|
type RegisteredTool = {
|
||||||
|
description?: string;
|
||||||
name: string;
|
name: string;
|
||||||
parameters?: { safeParse: (value: unknown) => { success: boolean } };
|
parameters?: { safeParse: (value: unknown) => { success: boolean } };
|
||||||
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
|
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||||
|
|
@ -484,7 +485,12 @@ describe('agent-teams-mcp tools', () => {
|
||||||
const memberBriefingText = (memberBriefing as { content: Array<{ text: string }> }).content[0]
|
const memberBriefingText = (memberBriefing as { content: Array<{ text: string }> }).content[0]
|
||||||
?.text;
|
?.text;
|
||||||
expect(memberBriefingText).toContain('Member briefing for alice on team "alpha" (alpha).');
|
expect(memberBriefingText).toContain('Member briefing for alice on team "alpha" (alpha).');
|
||||||
expect(memberBriefingText).toContain('Use task_briefing as your compact queue view');
|
expect(memberBriefingText).toContain(
|
||||||
|
'Use task_briefing as your primary working queue whenever you need to see assigned work.'
|
||||||
|
);
|
||||||
|
expect(memberBriefingText).toContain(
|
||||||
|
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
||||||
|
);
|
||||||
expect(memberBriefingText).toContain('Review MCP adapter');
|
expect(memberBriefingText).toContain('Review MCP adapter');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -593,14 +599,19 @@ describe('agent-teams-mcp tools', () => {
|
||||||
memberName: 'alice',
|
memberName: 'alice',
|
||||||
})) as { content: Array<{ text: string }> };
|
})) as { content: Array<{ text: string }> };
|
||||||
const briefingText = briefing.content[0]?.text ?? '';
|
const briefingText = briefing.content[0]?.text ?? '';
|
||||||
expect(briefingText).toContain('In progress:');
|
expect(briefingText).toContain(
|
||||||
|
'Primary queue for alice. Act only on Actionable items. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
|
||||||
|
);
|
||||||
|
expect(briefingText).toContain(
|
||||||
|
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
||||||
|
);
|
||||||
|
expect(briefingText).toContain('Actionable:');
|
||||||
expect(briefingText).toContain(`#${activeTask.displayId}`);
|
expect(briefingText).toContain(`#${activeTask.displayId}`);
|
||||||
expect(briefingText).toContain('Description: This one is already in progress');
|
expect(briefingText).toContain('Description: This one is already in progress');
|
||||||
expect(briefingText).toContain('Investigating the active task now.');
|
expect(briefingText).toContain('Investigating the active task now.');
|
||||||
expect(briefingText).toContain('Pending:');
|
|
||||||
expect(briefingText).toContain(`#${queuedTask.displayId}`);
|
expect(briefingText).toContain(`#${queuedTask.displayId}`);
|
||||||
expect(briefingText).not.toContain('Pending description should stay out of briefing details');
|
expect(briefingText).not.toContain('Pending description should stay out of briefing details');
|
||||||
expect(briefingText).toContain('Completed:');
|
expect(briefingText).toContain('Awareness:');
|
||||||
expect(briefingText).toContain(`#${completedTask.displayId}`);
|
expect(briefingText).toContain(`#${completedTask.displayId}`);
|
||||||
expect(briefingText).not.toContain('Completed description should also stay compact');
|
expect(briefingText).not.toContain('Completed description should also stay compact');
|
||||||
|
|
||||||
|
|
@ -618,6 +629,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
);
|
);
|
||||||
expect(memberBriefingText).toContain('reason and your best ETA or what you are waiting on');
|
expect(memberBriefingText).toContain('reason and your best ETA or what you are waiting on');
|
||||||
expect(memberBriefingText).toContain('IMPORTANT: Communicate in English.');
|
expect(memberBriefingText).toContain('IMPORTANT: Communicate in English.');
|
||||||
|
expect(memberBriefingText).toContain(
|
||||||
|
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||||
|
);
|
||||||
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
||||||
expect(memberBriefingText).toContain('Task briefing for alice:');
|
expect(memberBriefingText).toContain('Task briefing for alice:');
|
||||||
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
||||||
|
|
@ -662,6 +676,95 @@ describe('agent-teams-mcp tools', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns compact lead_briefing output and filtered task_list inventory', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const teamName = 'lead-queue';
|
||||||
|
writeTeamConfig(claudeDir, teamName, {
|
||||||
|
members: [
|
||||||
|
{ name: 'lead', role: 'team-lead' },
|
||||||
|
{ name: 'alice', role: 'developer' },
|
||||||
|
{ name: 'bob', role: 'reviewer' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedTask = parseJsonToolResult(
|
||||||
|
await getTool('task_create').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
subject: 'Queued work',
|
||||||
|
owner: 'alice',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const unassignedTask = parseJsonToolResult(
|
||||||
|
await getTool('task_create').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
subject: 'Unassigned work',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const reviewTask = parseJsonToolResult(
|
||||||
|
await getTool('task_create').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
subject: 'Awaiting reviewer',
|
||||||
|
owner: 'alice',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await getTool('task_complete').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: reviewTask.id,
|
||||||
|
actor: 'alice',
|
||||||
|
});
|
||||||
|
await getTool('review_request').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: reviewTask.id,
|
||||||
|
from: 'lead',
|
||||||
|
reviewer: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
|
const leadBriefing = (await getTool('lead_briefing').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
})) as { content: Array<{ text: string }> };
|
||||||
|
const leadBriefingText = leadBriefing.content[0]?.text ?? '';
|
||||||
|
expect(leadBriefingText).toContain('Lead queue for lead on team "lead-queue":');
|
||||||
|
expect(leadBriefingText).toContain(
|
||||||
|
'Primary lead queue. Sections below already represent lead-owned actions or watch-only context.'
|
||||||
|
);
|
||||||
|
expect(leadBriefingText).toContain(
|
||||||
|
'Use task_list only for search, filtering, and drill-down inventory lookups.'
|
||||||
|
);
|
||||||
|
expect(leadBriefingText).toContain('Needs owner assignment:');
|
||||||
|
expect(leadBriefingText).toContain(`#${unassignedTask.displayId}`);
|
||||||
|
expect(leadBriefingText).toContain('Watching:');
|
||||||
|
expect(leadBriefingText).toContain(`#${reviewTask.displayId}`);
|
||||||
|
|
||||||
|
const reviewInventory = parseJsonToolResult(
|
||||||
|
await getTool('task_list').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
reviewState: 'review',
|
||||||
|
kanbanColumn: 'review',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(reviewInventory).toHaveLength(1);
|
||||||
|
expect(reviewInventory[0].id).toBe(reviewTask.id);
|
||||||
|
|
||||||
|
const ownerPendingInventory = parseJsonToolResult(
|
||||||
|
await getTool('task_list').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(ownerPendingInventory).toHaveLength(1);
|
||||||
|
expect(ownerPendingInventory[0].id).toBe(queuedTask.id);
|
||||||
|
});
|
||||||
|
|
||||||
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';
|
||||||
|
|
@ -915,7 +1018,13 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
|
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('write operations return slim task (no comments/historyEvents arrays)', async () => {
|
it('write operations return slim task and task_list returns allowlisted inventory rows', async () => {
|
||||||
|
expect(getTool('task_list').description).toContain(
|
||||||
|
'Use it to browse, filter, and drill into inventory, not as a primary working queue.'
|
||||||
|
);
|
||||||
|
expect(getTool('task_list').description).toContain('Deleted tasks are excluded.');
|
||||||
|
expect(getTool('task_list').description).toContain('Defaults to 50 rows and caps at 200 rows');
|
||||||
|
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const teamName = 'slim-check';
|
const teamName = 'slim-check';
|
||||||
|
|
||||||
|
|
@ -980,20 +1089,27 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(completed.status).toBe('completed');
|
expect(completed.status).toBe('completed');
|
||||||
expect(completed.comments).toBeUndefined();
|
expect(completed.comments).toBeUndefined();
|
||||||
|
|
||||||
// task_list: uses blocklist, includes description but not comments array
|
// task_list: explicit inventory shape only
|
||||||
const listed = parseJsonToolResult(
|
const listed = parseJsonToolResult(
|
||||||
await getTool('task_list').execute({ claudeDir, teamName })
|
await getTool('task_list').execute({ claudeDir, teamName })
|
||||||
);
|
);
|
||||||
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
||||||
expect(listedTask).toBeDefined();
|
expect(listedTask).toBeDefined();
|
||||||
expect(listedTask.subject).toBe('Slim task test');
|
expect(listedTask).toEqual({
|
||||||
expect(listedTask.commentCount).toBe(1);
|
id: task.id,
|
||||||
|
displayId: task.displayId,
|
||||||
|
subject: 'Slim task test',
|
||||||
|
status: 'completed',
|
||||||
|
owner: 'lead',
|
||||||
|
reviewState: 'none',
|
||||||
|
commentCount: 1,
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(listedTask.description).toBeUndefined();
|
||||||
expect(listedTask.comments).toBeUndefined();
|
expect(listedTask.comments).toBeUndefined();
|
||||||
expect(listedTask.historyEvents).toBeUndefined();
|
expect(listedTask.historyEvents).toBeUndefined();
|
||||||
expect(listedTask.workIntervals).toBeUndefined();
|
expect(listedTask.workIntervals).toBeUndefined();
|
||||||
// task_list preserves non-heavy fields
|
|
||||||
expect(listedTask.status).toBeDefined();
|
|
||||||
expect(listedTask.id).toBeDefined();
|
|
||||||
|
|
||||||
// task_get: still returns full task with comments
|
// task_get: still returns full task with comments
|
||||||
const full = parseJsonToolResult(
|
const full = parseJsonToolResult(
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ export class TeamDataService {
|
||||||
kanbanTaskState?: KanbanState['tasks'][string]
|
kanbanTaskState?: KanbanState['tasks'][string]
|
||||||
): TeamTaskWithKanban {
|
): TeamTaskWithKanban {
|
||||||
const reviewState = this.resolveTaskReviewState(task);
|
const reviewState = this.resolveTaskReviewState(task);
|
||||||
const reviewer = kanbanTaskState?.reviewer ?? this.resolveReviewerFromHistory(task) ?? null;
|
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
reviewState,
|
reviewState,
|
||||||
|
|
@ -251,23 +251,45 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract reviewer name from task history events as a fallback
|
* Extract reviewer name from the current review cycle history.
|
||||||
* when kanban state doesn't have it (e.g. review done via MCP agent-teams).
|
* For legacy boards that stored reviewer only in kanban state, preserve that
|
||||||
|
* value as a migration fallback while the task is still actively in review.
|
||||||
*/
|
*/
|
||||||
private resolveReviewerFromHistory(task: TeamTask): string | null {
|
private resolveReviewerFromHistory(
|
||||||
if (!task.historyEvents?.length) return null;
|
task: TeamTask,
|
||||||
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
kanbanTaskState?: KanbanState['tasks'][string],
|
||||||
const event = task.historyEvents[i];
|
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task)
|
||||||
if (event.type === 'review_approved' && event.actor) {
|
): string | null {
|
||||||
return event.actor;
|
if (task.historyEvents?.length) {
|
||||||
}
|
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
||||||
if (event.type === 'review_started' && event.actor) {
|
const event = task.historyEvents[i];
|
||||||
return event.actor;
|
if (event.type === 'review_started' && event.actor) {
|
||||||
}
|
return event.actor;
|
||||||
if (event.type === 'review_requested' && event.reviewer) {
|
}
|
||||||
return event.reviewer;
|
if (event.type === 'review_requested' && event.reviewer) {
|
||||||
|
return event.reviewer;
|
||||||
|
}
|
||||||
|
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (event.type === 'task_created') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
reviewState === 'review' &&
|
||||||
|
kanbanTaskState?.column === 'review' &&
|
||||||
|
typeof kanbanTaskState.reviewer === 'string' &&
|
||||||
|
kanbanTaskState.reviewer.trim().length > 0
|
||||||
|
) {
|
||||||
|
return kanbanTaskState.reviewer.trim();
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,11 +214,16 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamProvisioning');
|
const logger = createLogger('Service:TeamProvisioning');
|
||||||
const { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, protocols } =
|
const {
|
||||||
agentTeamsControllerModule;
|
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
|
createController,
|
||||||
|
protocols,
|
||||||
|
} = agentTeamsControllerModule;
|
||||||
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 RUN_TIMEOUT_MS = 300_000;
|
const RUN_TIMEOUT_MS = 300_000;
|
||||||
const VERIFY_TIMEOUT_MS = 15_000;
|
const VERIFY_TIMEOUT_MS = 15_000;
|
||||||
|
const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000;
|
||||||
const VERIFY_POLL_MS = 500;
|
const VERIFY_POLL_MS = 500;
|
||||||
const MCP_PREFLIGHT_SHUTDOWN_GRACE_MS = 250;
|
const MCP_PREFLIGHT_SHUTDOWN_GRACE_MS = 250;
|
||||||
const MCP_PREFLIGHT_SHUTDOWN_TIMEOUT_MS = 2_000;
|
const MCP_PREFLIGHT_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||||
|
|
@ -1581,14 +1586,27 @@ function extractBootstrapFailureReason(text: string): string | null {
|
||||||
(lower.includes('член') || lower.includes('member') || lower.includes('inbox'))) ||
|
(lower.includes('член') || lower.includes('member') || lower.includes('inbox'))) ||
|
||||||
lower.includes('member_briefing tool is not available') ||
|
lower.includes('member_briefing tool is not available') ||
|
||||||
lower.includes('member_briefing tool not found') ||
|
lower.includes('member_briefing tool not found') ||
|
||||||
|
lower.includes('lead_briefing tool is not available') ||
|
||||||
|
lower.includes('lead_briefing tool not found') ||
|
||||||
lower.includes('no such tool available: mcp__agent_teams__member_briefing') ||
|
lower.includes('no such tool available: mcp__agent_teams__member_briefing') ||
|
||||||
|
lower.includes('no such tool available: mcp__agent_teams__lead_briefing') ||
|
||||||
lower.includes('agent calls that include team_name must also include name') ||
|
lower.includes('agent calls that include team_name must also include name') ||
|
||||||
(lower.includes('member_briefing') &&
|
(lower.includes('member_briefing') &&
|
||||||
(lower.includes('not available') ||
|
(lower.includes('not available') ||
|
||||||
lower.includes('not found') ||
|
lower.includes('not found') ||
|
||||||
lower.includes('lookup failure') ||
|
lower.includes('lookup failure') ||
|
||||||
lower.includes('validation error') ||
|
lower.includes('validation error') ||
|
||||||
lower.includes('api error'))) ||
|
lower.includes('api error') ||
|
||||||
|
lower.includes('empty content') ||
|
||||||
|
lower.includes('unspecified error'))) ||
|
||||||
|
(lower.includes('lead_briefing') &&
|
||||||
|
(lower.includes('not available') ||
|
||||||
|
lower.includes('not found') ||
|
||||||
|
lower.includes('lookup failure') ||
|
||||||
|
lower.includes('validation error') ||
|
||||||
|
lower.includes('api error') ||
|
||||||
|
lower.includes('empty content') ||
|
||||||
|
lower.includes('unspecified error'))) ||
|
||||||
lower.includes('model is not supported') ||
|
lower.includes('model is not supported') ||
|
||||||
lower.includes('model is not available') ||
|
lower.includes('model is not available') ||
|
||||||
lower.includes('model not available') ||
|
lower.includes('model not available') ||
|
||||||
|
|
@ -1767,7 +1785,9 @@ After member_briefing succeeds:
|
||||||
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.
|
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.
|
||||||
- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report.
|
- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report.
|
||||||
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
||||||
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
|
- When you later receive work or reconnect after a restart, use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue.
|
||||||
|
- Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.
|
||||||
|
- Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
|
||||||
- If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.
|
- If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.
|
||||||
- CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply.
|
- CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply.
|
||||||
- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.
|
- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.
|
||||||
|
|
@ -1838,7 +1858,8 @@ ${actionModeProtocol}
|
||||||
- If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately.
|
- If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately.
|
||||||
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap.
|
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap.
|
||||||
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
||||||
- Use task_briefing as your compact queue view.
|
- Use task_briefing as your primary working queue. Use task_list only to search/browse inventory rows, not as your working queue.
|
||||||
|
- Act only on Actionable items in task_briefing. Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.
|
||||||
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
|
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
|
||||||
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
|
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
|
||||||
- Before you start any needsFix or pending task, call task_get for that specific task.
|
- Before you start any needsFix or pending task, call task_get for that specific task.
|
||||||
|
|
@ -2016,7 +2037,7 @@ function buildDeterministicCreateBootstrapSpec(
|
||||||
...(request.skipPermissions === false
|
...(request.skipPermissions === false
|
||||||
? {
|
? {
|
||||||
permissionSeedTools: [
|
permissionSeedTools: [
|
||||||
...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
'Edit',
|
'Edit',
|
||||||
'Write',
|
'Write',
|
||||||
'NotebookEdit',
|
'NotebookEdit',
|
||||||
|
|
@ -2065,7 +2086,7 @@ function buildDeterministicLaunchBootstrapSpec(
|
||||||
...(request.skipPermissions === false
|
...(request.skipPermissions === false
|
||||||
? {
|
? {
|
||||||
permissionSeedTools: [
|
permissionSeedTools: [
|
||||||
...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
'Edit',
|
'Edit',
|
||||||
'Write',
|
'Write',
|
||||||
'NotebookEdit',
|
'NotebookEdit',
|
||||||
|
|
@ -2149,13 +2170,16 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`,
|
` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`,
|
||||||
` - When splitting, make each task have a clear completion criterion and a single accountable owner.`,
|
` - When splitting, make each task have a clear completion criterion and a single accountable owner.`,
|
||||||
``,
|
``,
|
||||||
`IMPORTANT: The board MCP only supports these domains: task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`,
|
`IMPORTANT: The board MCP supports these domains: lead, task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`,
|
||||||
``,
|
``,
|
||||||
`Task board operations — use MCP tools directly:`,
|
`Task board operations — use MCP tools directly:`,
|
||||||
|
`- FIRST inspect the compact lead queue: lead_briefing { teamName: "${teamName}" }`,
|
||||||
|
` lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.`,
|
||||||
`- Get task details: task_get { teamName: "${teamName}", taskId: "<id>" }`,
|
`- Get task details: task_get { teamName: "${teamName}", taskId: "<id>" }`,
|
||||||
`- Get a single comment without loading full task: task_get_comment { teamName: "${teamName}", taskId: "<id>", commentId: "<commentId or prefix>" }`,
|
`- Get a single comment without loading full task: task_get_comment { teamName: "${teamName}", taskId: "<id>", commentId: "<commentId or prefix>" }`,
|
||||||
` When a teammate reports "#abcd1234 done ... task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }", use that taskId and commentId to fetch the full result text.`,
|
` When a teammate reports "#abcd1234 done ... task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }", use that taskId and commentId to fetch the full result text.`,
|
||||||
`- List all tasks: task_list { teamName: "${teamName}" }`,
|
`- Browse/search compact inventory rows only: task_list { teamName: "${teamName}", owner?: "<member>", status?: "pending|in_progress|completed|deleted", reviewState?: "none|review|needsFix|approved", kanbanColumn?: "review|approved", relatedTo?: "<taskId or #displayId>", blockedBy?: "<taskId or #displayId>", limit?: <n> }`,
|
||||||
|
` task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead's working queue.`,
|
||||||
`- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "<actual-member-name>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
|
`- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "<actual-member-name>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
|
||||||
`- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "<exact-messageId>", subject: "...", owner?: "<member>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
|
`- Create task from user message (preferred when you have a MessageId from a relayed inbox message): task_create_from_message { teamName: "${teamName}", messageId: "<exact-messageId>", subject: "...", owner?: "<member>", createdBy?: "<your-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
|
||||||
`- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: "<member-name>" }`,
|
`- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: "<member-name>" }`,
|
||||||
|
|
@ -2214,9 +2238,9 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
`- Set createdBy when creating tasks so workflow history shows who created the task.`,
|
`- Set createdBy when creating tasks so workflow history shows who created the task.`,
|
||||||
``,
|
``,
|
||||||
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
|
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
|
||||||
`- When a teammate needs clarification (needsClarification: "lead"), you MUST reply via task comment first. This is the durable answer, auto-clears the flag, and wakes the owner.`,
|
`- When a teammate needs clarification (needsClarification: "lead"), you MUST reply via task comment first. This is the durable answer on the board.`,
|
||||||
`- If you also send a SendMessage for urgency/visibility, treat it as an extra notification only — never as a substitute for the task-comment reply.`,
|
`- If you also send a SendMessage for urgency/visibility, treat it as an extra notification only — never as a substitute for the task-comment reply.`,
|
||||||
`- If you somehow reply via SendMessage before commenting, add the missing task comment immediately, and if needed also clear the flag manually:`,
|
`- Clarification flags are not assumed to auto-clear. After the blocker is truly resolved, clear the flag explicitly with:`,
|
||||||
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
|
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
|
||||||
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
|
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
|
||||||
` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`,
|
` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`,
|
||||||
|
|
@ -6975,7 +6999,7 @@ export class TeamProvisioningService {
|
||||||
providerBackendId: request.providerBackendId,
|
providerBackendId: request.providerBackendId,
|
||||||
});
|
});
|
||||||
if (request.skipPermissions === false) {
|
if (request.skipPermissions === false) {
|
||||||
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
child = spawnCli(claudePath, spawnArgs, {
|
child = spawnCli(claudePath, spawnArgs, {
|
||||||
|
|
@ -7623,7 +7647,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (request.skipPermissions === false) {
|
if (request.skipPermissions === false) {
|
||||||
await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd);
|
await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd);
|
||||||
}
|
}
|
||||||
child = spawnCli(claudePath, launchArgs, {
|
child = spawnCli(claudePath, launchArgs, {
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
|
|
@ -12174,28 +12198,25 @@ export class TeamProvisioningService {
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async seedTeammateOperationalPermissionRules(
|
private async seedLeadBootstrapPermissionRules(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
projectCwd: string
|
projectCwd: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json');
|
const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json');
|
||||||
try {
|
try {
|
||||||
// FACT: Teammates need both MCP tools AND standard file tools (Write/Edit).
|
|
||||||
// FACT: Standard tools use "setMode: acceptEdits" permission_suggestions, but
|
|
||||||
// we can't change subprocess session mode — so we pre-add them as allow rules.
|
|
||||||
const allTools = [
|
const allTools = [
|
||||||
...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
'Edit',
|
'Edit',
|
||||||
'Write',
|
'Write',
|
||||||
'NotebookEdit',
|
'NotebookEdit',
|
||||||
];
|
];
|
||||||
const added = await this.addPermissionRulesToSettings(settingsPath, allTools, 'allow');
|
const added = await this.addPermissionRulesToSettings(settingsPath, allTools, 'allow');
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${teamName}] Seeded teammate operational MCP rules in ${settingsPath} (${added} added)`
|
`[${teamName}] Seeded lead bootstrap MCP rules in ${settingsPath} (${added} added)`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${teamName}] Failed to seed teammate operational MCP rules: ${
|
`[${teamName}] Failed to seed lead bootstrap MCP rules: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
|
|
@ -14950,11 +14971,15 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
await request('initialize', {
|
await request(
|
||||||
protocolVersion: '2024-11-05',
|
'initialize',
|
||||||
capabilities: {},
|
{
|
||||||
clientInfo: { name: 'claude-agent-teams-ui', version: '1.0.0' },
|
protocolVersion: '2024-11-05',
|
||||||
});
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'claude-agent-teams-ui', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS
|
||||||
|
);
|
||||||
await notify('notifications/initialized');
|
await notify('notifications/initialized');
|
||||||
|
|
||||||
const toolsList = await request<McpToolsListResult>('tools/list', {});
|
const toolsList = await request<McpToolsListResult>('tools/list', {});
|
||||||
|
|
@ -14964,6 +14989,12 @@ export class TeamProvisioningService {
|
||||||
if (!memberBriefingTool) {
|
if (!memberBriefingTool) {
|
||||||
throw new Error('agent-teams MCP started but tools/list did not include member_briefing');
|
throw new Error('agent-teams MCP started but tools/list did not include member_briefing');
|
||||||
}
|
}
|
||||||
|
const leadBriefingTool = (toolsList.tools ?? []).find(
|
||||||
|
(tool) => tool.name === 'lead_briefing'
|
||||||
|
);
|
||||||
|
if (!leadBriefingTool) {
|
||||||
|
throw new Error('agent-teams MCP started but tools/list did not include lead_briefing');
|
||||||
|
}
|
||||||
|
|
||||||
const memberBriefing = await request<McpToolCallResult>('tools/call', {
|
const memberBriefing = await request<McpToolCallResult>('tools/call', {
|
||||||
name: 'member_briefing',
|
name: 'member_briefing',
|
||||||
|
|
@ -14985,6 +15016,27 @@ export class TeamProvisioningService {
|
||||||
if (briefingText.trim().length === 0) {
|
if (briefingText.trim().length === 0) {
|
||||||
throw new Error('agent-teams MCP returned empty content for member_briefing');
|
throw new Error('agent-teams MCP returned empty content for member_briefing');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const leadBriefing = await request<McpToolCallResult>('tools/call', {
|
||||||
|
name: 'lead_briefing',
|
||||||
|
arguments: {
|
||||||
|
claudeDir: fixture.claudeDir,
|
||||||
|
teamName: fixture.teamName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (leadBriefing.isError) {
|
||||||
|
throw new Error(
|
||||||
|
leadBriefing.content?.[0]?.text ??
|
||||||
|
'agent-teams MCP returned an unspecified error for lead_briefing'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadBriefingText =
|
||||||
|
leadBriefing.content?.find((item) => item.type === 'text')?.text ?? '';
|
||||||
|
if (leadBriefingText.trim().length === 0) {
|
||||||
|
throw new Error('agent-teams MCP returned empty content for lead_briefing');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = buildCombinedLogs('', stderrBuffer).trim();
|
const detail = buildCombinedLogs('', stderrBuffer).trim();
|
||||||
const errorText =
|
const errorText =
|
||||||
|
|
|
||||||
29
src/types/agent-teams-controller.d.ts
vendored
29
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -7,7 +7,22 @@ declare module 'agent-teams-controller' {
|
||||||
export interface ControllerTaskApi {
|
export interface ControllerTaskApi {
|
||||||
createTask(flags: Record<string, unknown>): unknown;
|
createTask(flags: Record<string, unknown>): unknown;
|
||||||
getTask(taskId: string): unknown;
|
getTask(taskId: string): unknown;
|
||||||
|
getTaskComment(
|
||||||
|
taskId: string,
|
||||||
|
commentId: string
|
||||||
|
): {
|
||||||
|
comment: Record<string, unknown>;
|
||||||
|
task: {
|
||||||
|
id: string;
|
||||||
|
displayId: string;
|
||||||
|
subject: string;
|
||||||
|
status: string;
|
||||||
|
owner: string | null;
|
||||||
|
commentCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
listTasks(): unknown[];
|
listTasks(): unknown[];
|
||||||
|
listTaskInventory(filters?: Record<string, unknown>): unknown[];
|
||||||
listDeletedTasks(): unknown[];
|
listDeletedTasks(): unknown[];
|
||||||
resolveTaskId(taskRef: string): string;
|
resolveTaskId(taskRef: string): string;
|
||||||
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
|
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
|
||||||
|
|
@ -25,6 +40,8 @@ declare module 'agent-teams-controller' {
|
||||||
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;
|
||||||
|
memberBriefing(memberName: string): Promise<string>;
|
||||||
|
leadBriefing(): Promise<string>;
|
||||||
taskBriefing(memberName: string): Promise<string>;
|
taskBriefing(memberName: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +59,7 @@ declare module 'agent-teams-controller' {
|
||||||
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
|
startReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerMessageApi {
|
export interface ControllerMessageApi {
|
||||||
|
|
@ -67,6 +85,12 @@ declare module 'agent-teams-controller' {
|
||||||
getCrossTeamOutbox(): unknown;
|
getCrossTeamOutbox(): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ControllerRuntimeApi {
|
||||||
|
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||||
|
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||||
|
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentBlocksApi {
|
export interface AgentBlocksApi {
|
||||||
AGENT_BLOCK_TAG: string;
|
AGENT_BLOCK_TAG: string;
|
||||||
AGENT_BLOCK_OPEN: string;
|
AGENT_BLOCK_OPEN: string;
|
||||||
|
|
@ -84,6 +108,7 @@ declare module 'agent-teams-controller' {
|
||||||
processes: ControllerProcessApi;
|
processes: ControllerProcessApi;
|
||||||
maintenance: ControllerMaintenanceApi;
|
maintenance: ControllerMaintenanceApi;
|
||||||
crossTeam: ControllerCrossTeamApi;
|
crossTeam: ControllerCrossTeamApi;
|
||||||
|
runtime: ControllerRuntimeApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Context-free protocol text builders, shared across lead and member prompts. */
|
/** Context-free protocol text builders, shared across lead and member prompts. */
|
||||||
|
|
@ -95,6 +120,7 @@ declare module 'agent-teams-controller' {
|
||||||
|
|
||||||
export type AgentTeamsMcpToolGroupId =
|
export type AgentTeamsMcpToolGroupId =
|
||||||
| 'task'
|
| 'task'
|
||||||
|
| 'lead'
|
||||||
| 'kanban'
|
| 'kanban'
|
||||||
| 'review'
|
| 'review'
|
||||||
| 'message'
|
| 'message'
|
||||||
|
|
@ -114,6 +140,7 @@ declare module 'agent-teams-controller' {
|
||||||
|
|
||||||
export const protocols: ProtocolsApi;
|
export const protocols: ProtocolsApi;
|
||||||
export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_LEAD_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[];
|
||||||
|
|
@ -124,4 +151,6 @@ declare module 'agent-teams-controller' {
|
||||||
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[];
|
||||||
|
export const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1182,6 +1182,65 @@ describe('TeamDataService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves legacy kanban reviewer for tasks still in review without review history', async () => {
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{
|
||||||
|
listTeams: vi.fn(),
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: 'My team',
|
||||||
|
members: [
|
||||||
|
{ name: 'lead', role: 'team lead' },
|
||||||
|
{ name: 'bob', role: 'developer' },
|
||||||
|
{ name: 'carol', role: 'reviewer' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getTasks: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 'task-legacy-review',
|
||||||
|
subject: 'Legacy review task',
|
||||||
|
status: 'completed',
|
||||||
|
owner: 'bob',
|
||||||
|
reviewState: 'review',
|
||||||
|
historyEvents: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
listInboxNames: vi.fn(async () => []),
|
||||||
|
getMessages: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
resolveMembers: vi.fn(() => []),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getState: vi.fn(async () => ({
|
||||||
|
teamName: 'my-team',
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {
|
||||||
|
'task-legacy-review': {
|
||||||
|
column: 'review',
|
||||||
|
reviewer: 'carol',
|
||||||
|
movedAt: '2026-03-01T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await service.getTeamData('my-team');
|
||||||
|
|
||||||
|
expect(data.tasks[0]).toMatchObject({
|
||||||
|
id: 'task-legacy-review',
|
||||||
|
reviewState: 'review',
|
||||||
|
kanbanColumn: 'review',
|
||||||
|
reviewer: 'carol',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('propagates leadSessionId for kanban-driven review transitions', async () => {
|
it('propagates leadSessionId for kanban-driven review transitions', async () => {
|
||||||
const requestReviewMock = vi.fn();
|
const requestReviewMock = vi.fn();
|
||||||
const approveReviewMock = vi.fn();
|
const approveReviewMock = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,10 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { spawnCli } from '@main/utils/childProcess';
|
import { spawnCli } from '@main/utils/childProcess';
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import { encodePath } from '@main/utils/pathDecoder';
|
import { encodePath } from '@main/utils/pathDecoder';
|
||||||
import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller';
|
import {
|
||||||
|
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
|
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
|
} from 'agent-teams-controller';
|
||||||
import {
|
import {
|
||||||
killTmuxPaneForCurrentPlatformSync,
|
killTmuxPaneForCurrentPlatformSync,
|
||||||
listTmuxPanePidsForCurrentPlatform,
|
listTmuxPanePidsForCurrentPlatform,
|
||||||
|
|
@ -2073,7 +2076,7 @@ describe('TeamProvisioningService', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pre-seeds teammate operational MCP permissions before createTeam spawn', async () => {
|
it('pre-seeds lead bootstrap MCP permissions before createTeam spawn', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
vi.mocked(spawnCli).mockImplementation(() => {
|
vi.mocked(spawnCli).mockImplementation(() => {
|
||||||
|
|
@ -2124,8 +2127,9 @@ describe('TeamProvisioningService', () => {
|
||||||
permissions?: { allow?: string[] };
|
permissions?: { allow?: string[] };
|
||||||
};
|
};
|
||||||
expect(settings.permissions?.allow).toEqual(
|
expect(settings.permissions?.allow).toEqual(
|
||||||
expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES])
|
expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES])
|
||||||
);
|
);
|
||||||
|
expect(settings.permissions?.allow).toContain('mcp__agent-teams__lead_briefing');
|
||||||
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop');
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop');
|
||||||
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('agent-teams-controller', () => ({
|
vi.mock('agent-teams-controller', () => ({
|
||||||
|
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: [] as readonly string[],
|
||||||
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[],
|
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[],
|
||||||
createController: ({ teamName }: { teamName: string }) => ({
|
createController: ({ teamName }: { teamName: string }) => ({
|
||||||
messages: {
|
messages: {
|
||||||
|
|
|
||||||
|
|
@ -137,23 +137,19 @@ function writeMcpConfig(
|
||||||
|
|
||||||
function writeMockMcpServer(
|
function writeMockMcpServer(
|
||||||
targetDir: string,
|
targetDir: string,
|
||||||
variant: 'missing-member-briefing' | 'member-briefing-error'
|
variant:
|
||||||
|
| 'missing-member-briefing'
|
||||||
|
| 'missing-lead-briefing'
|
||||||
|
| 'member-briefing-error'
|
||||||
|
| 'lead-briefing-error'
|
||||||
): string {
|
): string {
|
||||||
const scriptPath = path.join(targetDir, `mock-mcp-${variant}.js`);
|
const scriptPath = path.join(targetDir, `mock-mcp-${variant}.js`);
|
||||||
const tools =
|
const tools =
|
||||||
variant === 'missing-member-briefing'
|
variant === 'missing-member-briefing'
|
||||||
? [{ name: 'task_create' }]
|
? [{ name: 'lead_briefing' }]
|
||||||
: [{ name: 'member_briefing' }];
|
: variant === 'missing-lead-briefing'
|
||||||
const toolCallResult =
|
? [{ name: 'member_briefing' }]
|
||||||
variant === 'member-briefing-error'
|
: [{ name: 'member_briefing' }, { name: 'lead_briefing' }];
|
||||||
? {
|
|
||||||
content: [{ type: 'text', text: 'mock member_briefing failure' }],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
content: [{ type: 'text', text: 'ok' }],
|
|
||||||
isError: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
scriptPath,
|
scriptPath,
|
||||||
|
|
@ -192,10 +188,26 @@ process.stdin.on('data', (chunk) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (message.method === 'tools/call') {
|
if (message.method === 'tools/call') {
|
||||||
|
const toolName = message.params?.name;
|
||||||
|
const toolCallResult =
|
||||||
|
(${JSON.stringify(variant)} === 'member-briefing-error' && toolName === 'member_briefing')
|
||||||
|
? {
|
||||||
|
content: [{ type: 'text', text: 'mock member_briefing failure' }],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
: (${JSON.stringify(variant)} === 'lead-briefing-error' && toolName === 'lead_briefing')
|
||||||
|
? {
|
||||||
|
content: [{ type: 'text', text: 'mock lead_briefing failure' }],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
content: [{ type: 'text', text: 'ok' }],
|
||||||
|
isError: false,
|
||||||
|
};
|
||||||
send({
|
send({
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: message.id,
|
id: message.id,
|
||||||
result: ${JSON.stringify(toolCallResult)},
|
result: toolCallResult,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1099,6 +1111,21 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
).rejects.toThrow('tools/list did not include member_briefing');
|
).rejects.toThrow('tools/list did not include member_briefing');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails validation when tools/list does not include lead_briefing', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const mockServerPath = writeMockMcpServer(tempRoot, 'missing-lead-briefing');
|
||||||
|
const configPath = writeMcpConfig(tempRoot, {
|
||||||
|
'agent-teams': {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mockServerPath],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
||||||
|
).rejects.toThrow('tools/list did not include lead_briefing');
|
||||||
|
});
|
||||||
|
|
||||||
it('fails validation when member_briefing itself returns an MCP error', async () => {
|
it('fails validation when member_briefing itself returns an MCP error', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const mockServerPath = writeMockMcpServer(tempRoot, 'member-briefing-error');
|
const mockServerPath = writeMockMcpServer(tempRoot, 'member-briefing-error');
|
||||||
|
|
@ -1114,4 +1141,19 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
||||||
).rejects.toThrow('mock member_briefing failure');
|
).rejects.toThrow('mock member_briefing failure');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails validation when lead_briefing itself returns an MCP error', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const mockServerPath = writeMockMcpServer(tempRoot, 'lead-briefing-error');
|
||||||
|
const configPath = writeMcpConfig(tempRoot, {
|
||||||
|
'agent-teams': {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mockServerPath],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath)
|
||||||
|
).rejects.toThrow('mock lead_briefing failure');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,15 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
role: 'developer',
|
role: 'developer',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain(
|
||||||
|
'When you later receive work or reconnect after a restart, use task_briefing as your primary working queue.'
|
||||||
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
||||||
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
'Awareness items are watch-only context unless the lead reroutes the task or you become the actionOwner.'
|
||||||
|
);
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
'If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.'
|
'If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.'
|
||||||
);
|
);
|
||||||
|
|
@ -676,6 +685,15 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
expect(prompt).toContain('task_create_from_message');
|
expect(prompt).toContain('task_create_from_message');
|
||||||
expect(prompt).toContain('task_set_owner');
|
expect(prompt).toContain('task_set_owner');
|
||||||
expect(prompt).toContain('cross_team_send');
|
expect(prompt).toContain('cross_team_send');
|
||||||
|
expect(prompt).toContain(
|
||||||
|
'lead_briefing is the primary lead queue. Decisions about what to act on now come from lead_briefing, not from raw task_list rows.'
|
||||||
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
'Browse/search compact inventory rows only: task_list'
|
||||||
|
);
|
||||||
|
expect(prompt).toContain(
|
||||||
|
'task_list is inventory/search/drill-down only. Do NOT treat task_list as the lead\'s working queue.'
|
||||||
|
);
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
'review_request already notifies the reviewer'
|
'review_request already notifies the reviewer'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('agent-teams-controller', () => ({
|
vi.mock('agent-teams-controller', () => ({
|
||||||
|
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: [] as readonly string[],
|
||||||
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[],
|
AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[],
|
||||||
createController: ({ teamName }: { teamName: string }) => ({
|
createController: ({ teamName }: { teamName: string }) => ({
|
||||||
messages: {
|
messages: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue