feat: enhance workspace management and MCP integration

- Added new workspace commands for type checking, building, and testing across multiple packages.
- Updated CI workflow to include paths for new packages and utilize workspace commands.
- Refactored MCP server to integrate with the agent-teams-controller, enhancing task management capabilities.
- Improved task boundary detection and logging for MCP tools, ensuring better tracking of task states.
- Updated documentation and prompts to reflect new MCP tool usage, replacing previous teamctl.js references.
This commit is contained in:
iliya 2026-03-07 15:02:55 +02:00
parent 00ca6698fa
commit 6091f4f7ae
42 changed files with 3074 additions and 1638 deletions

View file

@ -5,6 +5,8 @@ on:
branches: [main]
paths:
- 'src/**'
- 'agent-teams-controller/**'
- 'mcp-server/**'
- 'test/**'
- 'package.json'
- 'pnpm-lock.yaml'
@ -17,6 +19,8 @@ on:
branches: [main]
paths:
- 'src/**'
- 'agent-teams-controller/**'
- 'mcp-server/**'
- 'test/**'
- 'package.json'
- 'pnpm-lock.yaml'
@ -55,13 +59,13 @@ jobs:
eslint-${{ runner.os }}-
- name: Typecheck
run: pnpm typecheck
run: pnpm typecheck:workspace
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build
run: pnpm build:workspace
test:
strategy:
@ -87,4 +91,4 @@ jobs:
run: pnpm install --no-frozen-lockfile
- name: Test
run: pnpm test
run: pnpm test:workspace

1
agent-teams-controller/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

View file

@ -0,0 +1,22 @@
{
"name": "agent-teams-controller",
"version": "1.0.0",
"private": true,
"description": "Controller package for Claude agent teams operations and legacy teamctl CLI compatibility",
"type": "commonjs",
"main": "src/index.js",
"bin": {
"teamctl": "src/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "node ./scripts/build.mjs",
"test": "vitest run --config vitest.config.js",
"test:watch": "vitest --config vitest.config.js"
},
"engines": {
"node": ">=20"
}
}

View file

@ -0,0 +1,37 @@
import { chmod, copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageRoot = path.resolve(__dirname, '..');
const srcDir = path.join(packageRoot, 'src');
const distDir = path.join(packageRoot, 'dist');
async function copyRecursive(sourceDir, targetDir) {
await mkdir(targetDir, { recursive: true });
const entries = await readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await copyRecursive(sourcePath, targetPath);
continue;
}
if (entry.isFile()) {
await copyFile(sourcePath, targetPath);
}
}
}
await rm(distDir, { recursive: true, force: true });
await mkdir(distDir, { recursive: true });
await copyRecursive(srcDir, distDir);
for (const executablePath of ['cli.js', path.join('legacy', 'teamctl.cli.js')]) {
const absPath = path.join(distDir, executablePath);
const info = await stat(absPath);
await chmod(absPath, info.mode | 0o111);
}

View file

@ -0,0 +1,4 @@
#!/usr/bin/env node
'use strict';
require('./legacy/teamctl.cli.js');

View file

@ -0,0 +1,38 @@
const { createControllerContext } = require('./internal/context.js');
const tasks = require('./internal/tasks.js');
const kanban = require('./internal/kanban.js');
const review = require('./internal/review.js');
const messages = require('./internal/messages.js');
const processes = require('./internal/processes.js');
function bindModule(context, moduleApi) {
return Object.fromEntries(
Object.entries(moduleApi).map(([name, fn]) => [
name,
(...args) => fn(context, ...args),
])
);
}
function createController(options) {
const context = createControllerContext(options);
return {
context,
tasks: bindModule(context, tasks),
kanban: bindModule(context, kanban),
review: bindModule(context, review),
messages: bindModule(context, messages),
processes: bindModule(context, processes),
};
}
module.exports = {
createController,
createControllerContext,
tasks,
kanban,
review,
messages,
processes,
};

View file

@ -0,0 +1,17 @@
const fs = require('fs');
const path = require('path');
const controller = require('./controller.js');
function getLegacyTeamctlCliPath() {
return path.join(__dirname, 'legacy', 'teamctl.cli.js');
}
function readLegacyTeamctlCliSource() {
return fs.readFileSync(getLegacyTeamctlCliPath(), 'utf8');
}
module.exports = {
...controller,
getLegacyTeamctlCliPath,
readLegacyTeamctlCliSource,
};

View file

@ -0,0 +1,31 @@
function captureStreamOutput(stream, fn) {
let output = '';
const originalWrite = stream.write.bind(stream);
stream.write = ((chunk, encoding, callback) => {
output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(encoding || 'utf8');
if (typeof callback === 'function') {
callback();
}
return true;
});
try {
const result = fn();
if (result && typeof result.then === 'function') {
return result.finally(() => {
stream.write = originalWrite;
}).then((value) => ({ value, output }));
}
stream.write = originalWrite;
return { value: result, output };
} catch (error) {
stream.write = originalWrite;
throw error;
}
}
module.exports = {
captureStreamOutput,
};

View file

@ -0,0 +1,24 @@
const legacy = require('../legacy/teamctl.cli.js');
function createControllerContext(options = {}) {
const teamName = String(options.teamName || '').trim();
if (!teamName) {
throw new Error('Missing teamName');
}
const flags = {};
if (typeof options.claudeDir === 'string' && options.claudeDir.trim()) {
flags['claude-dir'] = options.claudeDir.trim();
}
const paths = legacy.getPaths(flags, teamName);
return {
teamName,
claudeDir: paths.claudeDir,
paths,
};
}
module.exports = {
createControllerContext,
};

View file

@ -0,0 +1,49 @@
const legacy = require('../legacy/teamctl.cli.js');
function getKanbanState(context) {
return legacy.readKanbanState(context.paths, context.teamName);
}
function setKanbanColumn(context, taskId, column) {
legacy.setKanbanColumn(context.paths, context.teamName, String(taskId), String(column));
return getKanbanState(context);
}
function clearKanban(context, taskId) {
legacy.clearKanban(context.paths, context.teamName, String(taskId));
return getKanbanState(context);
}
function listReviewers(context) {
return getKanbanState(context).reviewers;
}
function addReviewer(context, reviewer) {
const state = getKanbanState(context);
const next = new Set(state.reviewers);
next.add(String(reviewer));
legacy.writeKanbanState(context.paths, {
...state,
reviewers: [...next],
});
return listReviewers(context);
}
function removeReviewer(context, reviewer) {
const state = getKanbanState(context);
const next = state.reviewers.filter((entry) => entry !== reviewer);
legacy.writeKanbanState(context.paths, {
...state,
reviewers: next,
});
return listReviewers(context);
}
module.exports = {
getKanbanState,
setKanbanColumn,
clearKanban,
listReviewers,
addReviewer,
removeReviewer,
};

View file

@ -0,0 +1,9 @@
const legacy = require('../legacy/teamctl.cli.js');
function sendMessage(context, flags) {
return legacy.sendInboxMessage(context.paths, context.teamName, flags);
}
module.exports = {
sendMessage,
};

View file

@ -0,0 +1,27 @@
const legacy = require('../legacy/teamctl.cli.js');
const { captureStreamOutput } = require('./capture.js');
function registerProcess(context, flags) {
captureStreamOutput(process.stdout, () => legacy.processRegister(context.paths, flags));
return listProcesses(context).find((entry) => entry.pid === Number(flags.pid)) || null;
}
function unregisterProcess(context, flags) {
captureStreamOutput(process.stdout, () => legacy.processUnregister(context.paths, flags));
return listProcesses(context);
}
function listProcesses(context) {
return legacy.readProcessesSafe(context.paths.processesPath).map((entry) => ({
...entry,
alive: Number.isFinite(Number(entry && entry.pid))
? legacy.isProcessAlive(Number(entry.pid))
: false,
}));
}
module.exports = {
registerProcess,
unregisterProcess,
listProcesses,
};

View file

@ -0,0 +1,17 @@
const legacy = require('../legacy/teamctl.cli.js');
const tasks = require('./tasks.js');
function approveReview(context, taskId, flags = {}) {
legacy.reviewApprove(context.paths, context.teamName, String(taskId), flags);
return tasks.getTask(context, taskId);
}
function requestChanges(context, taskId, flags = {}) {
legacy.reviewRequestChanges(context.paths, context.teamName, String(taskId), flags);
return tasks.getTask(context, taskId);
}
module.exports = {
approveReview,
requestChanges,
};

View file

@ -0,0 +1,95 @@
const legacy = require('../legacy/teamctl.cli.js');
const { captureStreamOutput } = require('./capture.js');
function createTask(context, flags) {
return legacy.createTask(context.paths, flags);
}
function getTask(context, taskId) {
return legacy.readTask(context.paths, String(taskId)).task;
}
function listTasks(context) {
return legacy.listTaskIds(context.paths.tasksDir).map((taskId) => getTask(context, taskId));
}
function setTaskStatus(context, taskId, status, actor) {
legacy.setTaskStatus(context.paths, String(taskId), String(status), actor);
return getTask(context, taskId);
}
function startTask(context, taskId, actor) {
return setTaskStatus(context, taskId, 'in_progress', actor);
}
function completeTask(context, taskId, actor) {
return setTaskStatus(context, taskId, 'completed', actor);
}
function setTaskOwner(context, taskId, owner) {
return legacy.setTaskOwner(
context.paths,
String(taskId),
owner == null || owner === 'clear' || owner === 'none' ? null : String(owner)
);
}
function addTaskComment(context, taskId, flags) {
const result = legacy.addTaskComment(context.paths, String(taskId), flags);
return {
...result,
task: getTask(context, taskId),
};
}
function attachTaskFile(context, taskId, flags) {
const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags);
legacy.addAttachmentToTask(context.paths, String(taskId), saved.meta);
return saved.meta;
}
function attachCommentFile(context, taskId, commentId, flags) {
const saved = legacy.saveTaskAttachmentFile(context.paths, String(taskId), flags);
legacy.addAttachmentToComment(context.paths, String(taskId), String(commentId), saved.meta);
return saved.meta;
}
function setNeedsClarification(context, taskId, value) {
const normalized = value == null ? 'clear' : String(value);
legacy.setNeedsClarification(context.paths, String(taskId), normalized);
return getTask(context, taskId);
}
function linkTask(context, taskId, targetId, linkType) {
legacy.linkTasks(context.paths, String(taskId), String(targetId), String(linkType));
return getTask(context, taskId);
}
function unlinkTask(context, taskId, targetId, linkType) {
legacy.unlinkTasks(context.paths, String(taskId), String(targetId), String(linkType));
return getTask(context, taskId);
}
async function taskBriefing(context, memberName) {
const { output } = await captureStreamOutput(process.stdout, () =>
legacy.taskBriefing(context.paths, context.teamName, { for: memberName })
);
return output;
}
module.exports = {
createTask,
getTask,
listTasks,
setTaskStatus,
startTask,
completeTask,
setTaskOwner,
addTaskComment,
attachTaskFile,
attachCommentFile,
setNeedsClarification,
linkTask,
unlinkTask,
taskBriefing,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createController } = require('../src/index.js');
describe('agent-teams-controller API', () => {
function makeClaudeDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'teams', 'my-team', 'config.json'),
JSON.stringify(
{
name: 'my-team',
members: [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
return dir;
}
it('creates tasks and exposes grouped controller modules', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
controller.tasks.createTask({ subject: 'Base task' });
controller.tasks.createTask({ subject: 'Dependency task' });
const created = controller.tasks.createTask({
subject: 'Blocked task',
owner: 'bob',
'blocked-by': '1,2',
related: '1',
});
expect(created.id).toBe('3');
expect(created.status).toBe('pending');
expect(controller.tasks.getTask('1').blocks).toEqual(['3']);
expect(controller.tasks.getTask('3').blockedBy).toEqual(['1', '2']);
controller.kanban.addReviewer('alice');
controller.kanban.setKanbanColumn('3', 'review');
controller.review.approveReview('3', { 'notify-owner': true, from: 'alice' });
const kanbanState = controller.kanban.getKanbanState();
expect(kanbanState.reviewers).toEqual(['alice']);
expect(kanbanState.tasks['3'].column).toBe('approved');
const proc = controller.processes.registerProcess({
pid: process.pid,
label: 'dev-server',
port: '3000',
});
expect(proc.port).toBe(3000);
expect(controller.processes.listProcesses()).toHaveLength(1);
});
});

View file

@ -0,0 +1,13 @@
const { readLegacyTeamctlCliSource } = require('../src/index.js');
describe('agent-teams-controller legacy teamctl source', () => {
it('exposes the extracted CLI source', () => {
const source = readLegacyTeamctlCliSource();
expect(source.startsWith('#!/usr/bin/env node')).toBe(true);
expect(source).toContain("if (domain === 'task')");
expect(source).toContain("if (domain === 'process')");
expect(source).toContain('task comment-attach');
expect(source).toContain('process unregister --id <uuid>');
});
});

View file

@ -0,0 +1,10 @@
const { defineConfig } = require('vitest/config');
module.exports = defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/**/*.test.js'],
testTimeout: 15_000,
},
});

View file

@ -1,7 +1,7 @@
{
"name": "agent-teams-mcp",
"version": "1.0.0",
"description": "MCP server for managing Claude Agent Teams kanban board and tasks via teamctl CLI",
"description": "MCP server for managing Claude Agent Teams through the agent-teams-controller API",
"type": "module",
"main": "dist/index.js",
"bin": {
@ -33,6 +33,7 @@
"prepublishOnly": "pnpm build"
},
"dependencies": {
"agent-teams-controller": "workspace:*",
"fastmcp": "^3.34.0",
"zod": "^4.3.6"
},

View file

@ -0,0 +1,57 @@
declare module 'agent-teams-controller' {
export interface ControllerContextOptions {
teamName: string;
claudeDir?: string;
}
export interface ControllerTaskApi {
createTask(flags: Record<string, unknown>): unknown;
getTask(taskId: string): unknown;
listTasks(): unknown[];
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
startTask(taskId: string, actor?: string): unknown;
completeTask(taskId: string, actor?: string): unknown;
setTaskOwner(taskId: string, owner: string | null): unknown;
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
attachCommentFile(taskId: string, commentId: string, flags: Record<string, unknown>): unknown;
setNeedsClarification(taskId: string, value: string | null): unknown;
linkTask(taskId: string, targetId: string, linkType: string): unknown;
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
taskBriefing(memberName: string): Promise<string>;
}
export interface ControllerKanbanApi {
getKanbanState(): unknown;
setKanbanColumn(taskId: string, column: string): unknown;
clearKanban(taskId: string): unknown;
listReviewers(): string[];
addReviewer(reviewer: string): string[];
removeReviewer(reviewer: string): string[];
}
export interface ControllerReviewApi {
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
}
export interface ControllerMessageApi {
sendMessage(flags: Record<string, unknown>): unknown;
}
export interface ControllerProcessApi {
registerProcess(flags: Record<string, unknown>): unknown;
unregisterProcess(flags: Record<string, unknown>): unknown;
listProcesses(): unknown[];
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
review: ControllerReviewApi;
messages: ControllerMessageApi;
processes: ControllerProcessApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;
}

View file

@ -0,0 +1,8 @@
import { createController } from 'agent-teams-controller';
export function getController(teamName: string, claudeDir?: string) {
return createController({
teamName,
...(claudeDir ? { claudeDir } : {}),
});
}

22
mcp-server/src/index.ts Normal file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env node
import { FastMCP } from 'fastmcp';
import { registerTools } from './tools';
export function createServer() {
const server = new FastMCP({
name: 'agent-teams-mcp',
version: '1.0.0',
});
registerTools(server);
return server;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const server = createServer();
server.start({
transportType: 'stdio',
});
}

View file

@ -0,0 +1,15 @@
import type { FastMCP } from 'fastmcp';
import { registerKanbanTools } from './kanbanTools';
import { registerMessageTools } from './messageTools';
import { registerProcessTools } from './processTools';
import { registerReviewTools } from './reviewTools';
import { registerTaskTools } from './taskTools';
export function registerTools(server: FastMCP) {
registerTaskTools(server);
registerKanbanTools(server);
registerReviewTools(server);
registerMessageTools(server);
registerProcessTools(server);
}

View file

@ -0,0 +1,77 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'kanban_get',
description: 'Get current kanban state',
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()),
});
server.addTool({
name: 'kanban_set_column',
description: 'Move task to review or approved column',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
column: z.enum(['review', 'approved']),
}),
execute: async ({ teamName, claudeDir, taskId, column }) =>
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column)),
});
server.addTool({
name: 'kanban_clear',
description: 'Remove task from kanban board',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, taskId }) =>
jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)),
});
server.addTool({
name: 'kanban_list_reviewers',
description: 'List configured review participants',
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()),
});
server.addTool({
name: 'kanban_add_reviewer',
description: 'Add a reviewer to kanban configuration',
parameters: z.object({
...toolContextSchema,
reviewer: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, reviewer }) =>
jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)),
});
server.addTool({
name: 'kanban_remove_reviewer',
description: 'Remove reviewer from kanban configuration',
parameters: z.object({
...toolContextSchema,
reviewer: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, reviewer }) =>
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer)),
});
}

View file

@ -0,0 +1,33 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'message_send',
description: 'Send a message into team inbox',
parameters: z.object({
...toolContextSchema,
to: z.string().min(1),
text: z.string().min(1),
from: z.string().optional(),
summary: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, to, text, from, summary }) =>
jsonTextContent(
getController(teamName, claudeDir).messages.sendMessage({
to,
text,
...(from ? { from } : {}),
...(summary ? { summary } : {}),
})
),
});
}

View file

@ -0,0 +1,70 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'process_register',
description: 'Register a running process for a team member',
parameters: z.object({
...toolContextSchema,
pid: z.number().int().positive(),
label: z.string().min(1),
from: z.string().optional(),
command: z.string().min(1).optional(),
port: z.number().int().min(1).max(65535).optional(),
url: z.string().min(1).optional(),
claudeProcessId: z.string().min(1).optional(),
}),
execute: async ({
teamName,
claudeDir,
pid,
label,
from,
command,
port,
url,
claudeProcessId,
}) =>
jsonTextContent(
getController(teamName, claudeDir).processes.registerProcess({
pid,
label,
...(from ? { from } : {}),
...(command ? { command } : {}),
...(port ? { port } : {}),
...(url ? { url } : {}),
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
})
),
});
server.addTool({
name: 'process_list',
description: 'List registered team processes',
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses()),
});
server.addTool({
name: 'process_unregister',
description: 'Unregister a previously registered process',
parameters: z.object({
...toolContextSchema,
pid: z.number().int().positive(),
}),
execute: async ({ teamName, claudeDir, pid }) =>
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid })),
});
}

View file

@ -0,0 +1,50 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'review_approve',
description: 'Approve task review and move kanban state accordingly',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
from: z.string().optional(),
note: z.string().optional(),
notifyOwner: z.boolean().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner }) =>
jsonTextContent(
getController(teamName, claudeDir).review.approveReview(taskId, {
...(from ? { from } : {}),
...(note ? { note } : {}),
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
})
),
});
server.addTool({
name: 'review_request_changes',
description: 'Request changes on a task under review',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
from: z.string().optional(),
comment: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, from, comment }) =>
jsonTextContent(
getController(teamName, claudeDir).review.requestChanges(taskId, {
...(from ? { from } : {}),
...(comment ? { comment } : {}),
})
),
});
}

View file

@ -0,0 +1,261 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
const toolContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
};
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'task_create',
description: 'Create a team task',
parameters: z.object({
...toolContextSchema,
subject: z.string().min(1),
description: z.string().optional(),
owner: z.string().optional(),
blockedBy: z.array(z.string().min(1)).optional(),
related: z.array(z.string().min(1)).optional(),
prompt: z.string().optional(),
startImmediately: z.boolean().optional(),
}),
execute: async ({ teamName, claudeDir, subject, description, owner, blockedBy, related, prompt, startImmediately }) => {
const controller = getController(teamName, claudeDir);
return jsonTextContent(
controller.tasks.createTask({
subject,
...(description ? { description } : {}),
...(owner ? { owner } : {}),
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
...(related?.length ? { related: related.join(',') } : {}),
...(prompt ? { prompt } : {}),
...(startImmediately === false && owner ? { status: 'pending' } : {}),
})
);
},
});
server.addTool({
name: 'task_get',
description: 'Get a task by id',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, taskId }) =>
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId)),
});
server.addTool({
name: 'task_list',
description: 'List tasks for a team',
parameters: z.object({
...toolContextSchema,
}),
execute: async ({ teamName, claudeDir }) =>
jsonTextContent(getController(teamName, claudeDir).tasks.listTasks()),
});
server.addTool({
name: 'task_set_status',
description: 'Set task work status',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor)),
});
server.addTool({
name: 'task_start',
description: 'Mark task as in progress',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, actor }) =>
jsonTextContent(getController(teamName, claudeDir).tasks.startTask(taskId, actor)),
});
server.addTool({
name: 'task_complete',
description: 'Mark task as completed',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
actor: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, actor }) =>
jsonTextContent(getController(teamName, claudeDir).tasks.completeTask(taskId, actor)),
});
server.addTool({
name: 'task_set_owner',
description: 'Assign or clear task owner',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
owner: z.string().nullable(),
}),
execute: async ({ teamName, claudeDir, taskId, owner }) =>
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner)),
});
server.addTool({
name: 'task_add_comment',
description: 'Add task comment',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
text: z.string().min(1),
from: z.string().optional(),
}),
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
jsonTextContent(
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
text,
...(from ? { from } : {}),
})
),
});
server.addTool({
name: 'task_attach_file',
description: 'Attach a file to a task',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
filePath: z.string().min(1),
mode: z.enum(['copy', 'link']).optional(),
filename: z.string().optional(),
mimeType: z.string().optional(),
noFallback: z.boolean().optional(),
}),
execute: async ({
teamName,
claudeDir,
taskId,
filePath,
mode,
filename,
mimeType,
noFallback,
}) =>
jsonTextContent(
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
file: filePath,
...(mode ? { mode } : {}),
...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}),
})
),
});
server.addTool({
name: 'task_attach_comment_file',
description: 'Attach a file to a task comment',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
commentId: z.string().min(1),
filePath: z.string().min(1),
mode: z.enum(['copy', 'link']).optional(),
filename: z.string().optional(),
mimeType: z.string().optional(),
noFallback: z.boolean().optional(),
}),
execute: async ({
teamName,
claudeDir,
taskId,
commentId,
filePath,
mode,
filename,
mimeType,
noFallback,
}) =>
jsonTextContent(
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
file: filePath,
...(mode ? { mode } : {}),
...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}),
})
),
});
server.addTool({
name: 'task_set_clarification',
description: 'Set or clear task clarification state',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
value: z.enum(['lead', 'user', 'clear']),
}),
execute: async ({ teamName, claudeDir, taskId, value }) =>
jsonTextContent(
getController(teamName, claudeDir).tasks.setNeedsClarification(
taskId,
value === 'clear' ? null : value
)
),
});
server.addTool({
name: 'task_link',
description: 'Link tasks by blockedBy, blocks, or related relationship',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
targetId: z.string().min(1),
relationship: relationshipTypeSchema,
}),
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
jsonTextContent(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship)),
});
server.addTool({
name: 'task_unlink',
description: 'Remove task relationship link',
parameters: z.object({
...toolContextSchema,
taskId: z.string().min(1),
targetId: z.string().min(1),
relationship: relationshipTypeSchema,
}),
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
jsonTextContent(
getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship)
),
});
server.addTool({
name: 'task_briefing',
description: 'Get formatted task briefing for a member',
parameters: z.object({
...toolContextSchema,
memberName: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, memberName }) => ({
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
},
],
}),
});
}

View file

@ -0,0 +1,10 @@
export function jsonTextContent(value: unknown): { content: Array<{ type: 'text'; text: string }> } {
return {
content: [
{
type: 'text',
text: JSON.stringify(value, null, 2),
},
],
};
}

View file

@ -0,0 +1,142 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { registerTools } from '../src/tools';
type RegisteredTool = {
name: string;
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
};
function collectTools() {
const tools = new Map<string, RegisteredTool>();
registerTools({
addTool(config: RegisteredTool) {
tools.set(config.name, config);
},
} as never);
return tools;
}
function parseJsonToolResult(result: unknown) {
const text = (result as { content: Array<{ text: string }> }).content[0]?.text;
return JSON.parse(text);
}
describe('agent-teams-mcp tools', () => {
const tools = collectTools();
function getTool(name: string) {
const tool = tools.get(name);
expect(tool).toBeDefined();
return tool!;
}
function makeClaudeDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
}
it('covers task clarification and comment attachment flows', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'alpha';
const attachmentPath = path.join(claudeDir, 'note.txt');
fs.writeFileSync(attachmentPath, 'ship it');
const createdTask = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Review MCP adapter',
owner: 'alice',
})
);
const commented = parseJsonToolResult(
await getTool('task_add_comment').execute({
claudeDir,
teamName,
taskId: createdTask.id,
text: 'Need one more check',
from: 'lead',
})
);
const commentId = commented.commentId;
expect(commentId).toBeTruthy();
const attachment = parseJsonToolResult(
await getTool('task_attach_comment_file').execute({
claudeDir,
teamName,
taskId: createdTask.id,
commentId,
filePath: attachmentPath,
mode: 'copy',
})
);
expect(attachment.filename).toBe('note.txt');
await getTool('task_set_clarification').execute({
claudeDir,
teamName,
taskId: createdTask.id,
value: 'user',
});
const loadedTask = parseJsonToolResult(
await getTool('task_get').execute({
claudeDir,
teamName,
taskId: createdTask.id,
})
);
expect(loadedTask.needsClarification).toBe('user');
expect(loadedTask.comments).toHaveLength(1);
expect(loadedTask.comments[0].attachments).toHaveLength(1);
});
it('covers process register/list/unregister without legacy stdout leaking into results', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'beta';
const registered = parseJsonToolResult(
await getTool('process_register').execute({
claudeDir,
teamName,
pid: 43210,
label: 'vite',
command: 'pnpm dev',
from: 'lead',
port: 3000,
})
);
expect(registered.pid).toBe(43210);
expect(registered.label).toBe('vite');
const listed = parseJsonToolResult(
await getTool('process_list').execute({
claudeDir,
teamName,
})
);
expect(listed).toHaveLength(1);
expect(listed[0].pid).toBe(43210);
const afterUnregister = parseJsonToolResult(
await getTool('process_unregister').execute({
claudeDir,
teamName,
pid: 43210,
})
);
expect(afterUnregister).toEqual([]);
});
});

View file

@ -30,11 +30,14 @@
"dist:linux": "electron-builder --linux",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck",
"lint": "eslint src/ --cache --cache-location .eslintcache --cache-strategy content",
"lint:fix": "eslint src/ --fix --cache --cache-location .eslintcache --cache-strategy content",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
"check": "pnpm typecheck:workspace && pnpm lint && pnpm test:workspace && pnpm build:workspace",
"fix": "pnpm lint:fix && pnpm format",
"quality": "pnpm check && pnpm format:check && npx knip",
"test:chunks": "tsx test/test-chunk-building.ts",

View file

@ -373,8 +373,13 @@ importers:
specifier: ^3.1.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0)
agent-teams-controller: {}
mcp-server:
dependencies:
agent-teams-controller:
specifier: workspace:*
version: link:../agent-teams-controller
fastmcp:
specifier: ^3.34.0
version: 3.34.0

View file

@ -1,4 +1,5 @@
packages:
- agent-teams-controller
- mcp-server
ignoredBuiltDependencies:
- esbuild

View file

@ -60,7 +60,6 @@ import {
ServiceContextRegistry,
SshConnectionManager,
TaskBoundaryParser,
TeamAgentToolsInstaller,
TeamDataService,
TeamMemberLogsFinder,
TeamProvisioningService,
@ -941,7 +940,6 @@ function createWindow(): void {
// The window is now visible and responsive; these run in the background.
setTimeout(() => {
void teamProvisioningService.warmup();
void new TeamAgentToolsInstaller().ensureInstalled();
teamDataService.startProcessHealthPolling();
}, 5000);
}

View file

@ -31,6 +31,7 @@ interface ToolUseInfo {
/** Regex для teamctl task команд */
const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/;
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
export class TaskBoundaryParser {
private cache = new Map<string, BoundaryCacheEntry>();
@ -56,7 +57,7 @@ export class TaskBoundaryParser {
const boundaries: TaskBoundary[] = [];
const allToolUsesByLine = new Map<number, ToolUseInfo[]>();
let lineNumber = 0;
let detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none' = 'none';
let detectedMechanism: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none' = 'none';
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
@ -96,6 +97,13 @@ export class TaskBoundaryParser {
continue;
}
const mcpBounds = this.extractMcpTaskBoundaries(content, lineNumber, timestamp);
if (mcpBounds.length > 0) {
detectedMechanism = 'mcp';
boundaries.push(...mcpBounds);
continue;
}
// Пробуем teamctl
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
if (teamctlBounds.length > 0) {
@ -206,6 +214,62 @@ export class TaskBoundaryParser {
return results;
}
/**
* Find MCP task tools that mark task boundaries.
*/
private extractMcpTaskBoundaries(
content: unknown[],
lineNumber: number,
timestamp: string
): TaskBoundary[] {
const results: TaskBoundary[] = [];
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as Record<string, unknown>;
if (b.type !== 'tool_use') continue;
const rawName = typeof b.name === 'string' ? b.name : '';
const toolName = rawName.replace(/^proxy_/, '');
if (!MCP_TASK_BOUNDARY_TOOLS.has(toolName)) continue;
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;
const rawTaskId = input.taskId;
const taskId =
typeof rawTaskId === 'string'
? rawTaskId
: typeof rawTaskId === 'number'
? String(rawTaskId)
: '';
if (!taskId) continue;
let event: 'start' | 'complete' | null = null;
if (toolName === 'task_start') event = 'start';
else if (toolName === 'task_complete') event = 'complete';
else {
const status = typeof input.status === 'string' ? input.status : '';
if (status === 'in_progress') event = 'start';
else if (status === 'completed') event = 'complete';
}
if (event) {
const toolUseId = typeof b.id === 'string' ? b.id : undefined;
results.push({
taskId,
event,
lineNumber,
timestamp,
mechanism: 'mcp',
toolUseId,
});
}
}
return results;
}
/**
* Найти teamctl task start/complete/set-status команды в Bash tool_use блоках.
* Regex: /task\s+(start|complete|set-status)\s+(\d+)/

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,6 @@ import * as path from 'path';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { atomicWriteAsync } from './atomicWrite';
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamInboxWriter } from './TeamInboxWriter';
@ -85,7 +84,7 @@ export class TeamDataService {
private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(),
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller(),
_legacyToolsInstaller: unknown = null,
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
) {}
@ -892,8 +891,6 @@ export class TeamDataService {
// Skip inbox notification when lead assigns a task to themselves (solo teams)
if (!this.isLeadOwner(request.owner, leadName)) {
const toolPath = await this.toolsInstaller.ensureInstalled();
// Build notification with full context — inbox is the primary delivery
// channel to agents (Claude Code monitors inbox via fs.watch)
const parts = [`New task assigned to you: #${task.id} "${task.subject}".`];
@ -908,9 +905,9 @@ export class TeamDataService {
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task start ${task.id}`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
`Update task status using the board MCP tools:`,
`task_start { teamName: "${teamName}", taskId: "${task.id}" }`,
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
AGENT_BLOCK_CLOSE
);
@ -948,15 +945,14 @@ export class TeamDataService {
// Skip inbox notification when lead starts their own task (solo teams)
if (!this.isLeadOwner(task.owner, leadName)) {
const toolPath = await this.toolsInstaller.ensureInstalled();
const parts = [`Task #${task.id} "${task.subject}" has been started.`];
if (task.description?.trim()) {
parts.push(`\nDetails:\n${task.description.trim()}`);
}
parts.push(
`\n${AGENT_BLOCK_OPEN}`,
`Update task status using:`,
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
`Update task status using the board MCP tools:`,
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
AGENT_BLOCK_CLOSE
);
await this.sendMessage(teamName, {
@ -1104,9 +1100,8 @@ export class TeamDataService {
});
try {
const [tasks, toolPath, config] = await Promise.all([
const [tasks, config] = await Promise.all([
this.taskReader.getTasks(teamName),
this.toolsInstaller.ensureInstalled(),
this.configReader.getConfig(teamName).catch(() => null),
]);
const task = tasks.find((t) => t.id === taskId);
@ -1123,8 +1118,8 @@ export class TeamDataService {
const parts = [
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
`\n${AGENT_BLOCK_OPEN}`,
`Reply to this comment using:`,
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "<your-name>"`,
`Reply to this comment using MCP tool task_add_comment:`,
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "<your-name>" }`,
AGENT_BLOCK_CLOSE,
];
await this.sendMessage(teamName, {
@ -1140,8 +1135,8 @@ export class TeamDataService {
const parts = [
`New comment from user on your task #${taskId} "${task.subject}":\n\n${text}`,
`\n${AGENT_BLOCK_OPEN}`,
`Reply to this comment using:`,
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "${leadName}"`,
`Reply to this comment using MCP tool task_add_comment:`,
`{ teamName: "${teamName}", taskId: "${taskId}", text: "<your reply>", from: "${leadName}" }`,
AGENT_BLOCK_CLOSE,
];
await this.sendMessage(teamName, {
@ -1266,20 +1261,17 @@ export class TeamDataService {
}
try {
const [toolPath, leadName] = await Promise.all([
this.toolsInstaller.ensureInstalled(),
this.resolveLeadName(teamName),
]);
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, {
member: reviewer,
from: leadName,
text:
`Please review task #${taskId}.\n\n` +
`${AGENT_BLOCK_OPEN}\n` +
`When approved, move it to APPROVED:\n` +
`node "${toolPath}" --team ${teamName} review approve ${taskId}\n\n` +
`If changes are needed:\n` +
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` +
`When approved, use MCP tool review_approve:\n` +
`{ teamName: "${teamName}", taskId: "${taskId}", notifyOwner: true }\n\n` +
`If changes are needed, use MCP tool review_request_changes:\n` +
`{ teamName: "${teamName}", taskId: "${taskId}", comment: "..." }\n` +
AGENT_BLOCK_CLOSE,
summary: `Review request for #${taskId}`,
source: 'system_notification',

View file

@ -0,0 +1,85 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
interface McpLaunchSpec {
command: string;
args: string[];
}
const MCP_SERVER_NAME = 'agent-teams';
function getWorkspaceRoot(): string {
return process.cwd();
}
function getMcpServerDir(): string {
return path.join(getWorkspaceRoot(), 'mcp-server');
}
function getBuiltServerEntry(): string {
return path.join(getMcpServerDir(), 'dist', 'index.js');
}
function getSourceServerEntry(): string {
return path.join(getMcpServerDir(), 'src', 'index.ts');
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await fs.promises.access(targetPath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
const builtEntry = getBuiltServerEntry();
if (await pathExists(builtEntry)) {
return {
command: process.execPath,
args: [builtEntry],
};
}
const sourceEntry = getSourceServerEntry();
if (await pathExists(sourceEntry)) {
return {
command: 'pnpm',
args: ['--dir', getMcpServerDir(), 'exec', 'tsx', sourceEntry],
};
}
throw new Error('agent-teams-mcp entrypoint not found in mcp-server package');
}
export class TeamMcpConfigBuilder {
async writeConfigFile(): Promise<string> {
const launchSpec = await resolveMcpLaunchSpec();
const configDir = path.join(os.tmpdir(), 'claude-team-mcp');
const configPath = path.join(configDir, `agent-teams-mcp-${randomUUID()}.json`);
await fs.promises.mkdir(configDir, { recursive: true });
await atomicWriteAsync(
configPath,
JSON.stringify(
{
mcpServers: {
[MCP_SERVER_NAME]: {
command: launchSpec.command,
args: launchSpec.args,
},
},
},
null,
2
)
);
return configPath;
}
}

View file

@ -322,6 +322,16 @@ export class TeamMemberLogsFinder {
stream.destroy();
return true;
}
if (
(line.includes('"task_start"') ||
line.includes('"task_complete"') ||
line.includes('"task_set_status"')) &&
pattern.test(line)
) {
rl.close();
stream.destroy();
return true;
}
if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) {
rl.close();
stream.destroy();

View file

@ -34,6 +34,7 @@ import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
@ -451,40 +452,40 @@ ${processRegistration}`;
function buildTaskStatusProtocol(teamName: string): string {
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
1. Use this command to mark task started:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <taskId>
1. Use MCP tool task_start to mark task started:
{ teamName: "${teamName}", taskId: "<taskId>" }
- Start the task ONLY when you are actually beginning work on it.
- Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work.
2. Use this command to mark task completed BEFORE sending your final reply:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete <taskId>
3. If you are asked to review and task is accepted, move it to APPROVED (not DONE):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review approve <taskId>
4. If review fails and changes are needed:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes <taskId> --comment "<what to fix>"
2. Use MCP tool task_complete BEFORE sending your final reply:
{ teamName: "${teamName}", taskId: "<taskId>" }
3. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
4. If review fails and changes are needed, use MCP tool review_request_changes:
{ teamName: "${teamName}", taskId: "<taskId>", comment: "<what to fix>" }
5. NEVER skip status updates. A task is NOT done until completed status is written.
- Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work.
6. To reply to a comment on a task:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<your reply>" --from "<your-name>"
6. To reply to a comment on a task, use MCP tool task_add_comment:
{ teamName: "${teamName}", taskId: "<taskId>", text: "<your reply>", from: "<your-name>" }
7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<summary of your finding or decision>" --from "<your-name>"
{ teamName: "${teamName}", taskId: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
8. When sending a message about a specific task, include #<taskId> in your SendMessage summary field for traceability.
9. Review workflow clarity (IMPORTANT):
- The work task (e.g. #1) is the thing that must end up APPROVED after review.
- If you are reviewing work for task #X, run review approve/request-changes on #X (the work task).
- If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task).
- Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) — that will put the wrong task into APPROVED.
- Typical flow:
a) Owner finishes work on #X → task complete #X
b) Reviewer accepts → review approve #X
a) Owner finishes work on #X -> task_complete #X
b) Reviewer accepts -> review_approve #X
10. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY):
When you are blocked and need information to continue a task, you MUST do BOTH steps below — skipping the Bash command breaks the task board:
a) STEP 1 — FIRST, set the clarification flag via Bash (this updates the task board):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> lead --from "<your-name>"
When you are blocked and need information to continue a task, you MUST do BOTH steps below — skipping the MCP update breaks the task board:
a) STEP 1 — FIRST, set the clarification flag with MCP tool task_set_clarification:
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
b) STEP 2 — THEN, send a message to your team lead via SendMessage explaining what you need.
IMPORTANT: Always run the Bash command BEFORE sending the message. The flag is what makes the task board show "needs clarification" — without it, your request is invisible on the board.
IMPORTANT: Always update the task board BEFORE sending the message. The flag is what makes the task board show "needs clarification" — without it, your request is invisible on the board.
c) The flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
11. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting.
@ -496,20 +497,20 @@ function buildProcessRegistrationProtocol(teamName: string): string {
return wrapInAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
1. Launch with & to get PID:
pnpm dev &
2. Register immediately (--port and --url are optional, use when the process listens on a port):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process register --pid $! --label "<description>" --from "<your-name>" [--port <PORT> --url "http://localhost:<PORT>"]
3. VERIFY registration succeeded (MANDATORY — never skip this step):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process list
4. When stopping a process:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process unregister --pid <PID>
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
{ teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" }
3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list:
{ teamName: "${teamName}" }
4. When stopping a process, use MCP tool process_unregister:
{ teamName: "${teamName}", pid: <PID> }
If verification in step 3 fails or the process is missing from the list, re-register it.`);
}
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
return wrapInAgentBlock(
[
`Internal task board tooling (teamctl.js):`,
`- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`,
`Internal task board tooling (MCP):`,
`- Use the board-management MCP tools for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`,
``,
`Execution discipline (CRITICAL — prevents misleading task boards):`,
`- Start a task (move to in_progress) ONLY when you are actually beginning work on it.`,
@ -520,61 +521,61 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`Parallelization guideline (IMPORTANT):`,
`- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`,
` - Prefer splitting by independent deliverables (e.g. frontend/backend, API/UI, parsing/rendering, tests/docs) rather than arbitrary slices.`,
` - Use --blocked-by only when one piece truly cannot start without another; otherwise link with --related.`,
` - Use blockedBy only when one piece truly cannot start without another; otherwise link with related.`,
` - 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.`,
``,
`IMPORTANT: teamctl.js 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 teamctl.`,
`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.`,
``,
`Task board operations — use teamctl.js via Bash:`,
`- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"`,
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
`- Start task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <id>`,
`- Complete task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete <id>`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
`- Add comment: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <id> --text "..." --from "${leadName}"`,
`- Attach file to task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task attach <id> --file "<path>" [--mode copy|link] [--filename "<name>"] [--mime-type "<type>"]`,
`Task board operations — use MCP tools directly:`,
`- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "<actual-member-name>", blockedBy?: ["1","2"], related?: ["3"] }`,
`- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: "<member-name>" }`,
`- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "<id>", owner: null }`,
`- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "<id>" }`,
`- Complete task (preferred over set-status): task_complete { teamName: "${teamName}", taskId: "<id>" }`,
`- Update status: task_set_status { teamName: "${teamName}", taskId: "<id>", status: "pending|in_progress|completed|deleted" }`,
`- Add comment: task_add_comment { teamName: "${teamName}", taskId: "<id>", text: "...", from: "${leadName}" }`,
`- Attach file to task: task_attach_file { teamName: "${teamName}", taskId: "<id>", filePath: "<path>", mode?: "copy|link", filename?: "<name>", mimeType?: "<type>" }`,
`- Attach file to a specific comment:`,
` 1) Find commentId: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task get <id>`,
` 2) Attach: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment-attach <id> <commentId> --file "<path>" [--mode copy|link] [--filename "<name>"] [--mime-type "<type>"]`,
`- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "<member>" --notify --from "${leadName}"`,
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
` 1) Find commentId: task_get { teamName: "${teamName}", taskId: "<id>" }`,
` 2) Attach: task_attach_comment_file { teamName: "${teamName}", taskId: "<id>", commentId: "<commentId>", filePath: "<path>", mode?: "copy|link", filename?: "<name>", mimeType?: "<type>" }`,
`- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "<member>", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`,
`- Link dependency: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "blocked-by" }`,
`- Link related: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "related" }`,
`- Unlink: task_unlink { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "blocked-by" }`,
``,
`Attachment storage modes (IMPORTANT):`,
`- Default is copy (safe, robust).`,
`- Use --mode link to try a hardlink (no duplication). It may fall back to copy unless you add --no-fallback.`,
`- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`,
``,
`Dependency guidelines:`,
`- Use --blocked-by when a task cannot start until another is done.`,
`- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`,
`- Use --related to link related work (e.g. frontend + backend) without blocking.`,
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via: review approve/request-changes #X.`,
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with --related (and optionally --blocked-by #X if it truly cannot start yet).`,
`- Use blockedBy when a task cannot start until another is done.`,
`- If you set blockedBy, create the task in pending (for example with startImmediately: false). Do NOT put blocked tasks into in_progress.`,
`- Use related to link related work (e.g. frontend + backend) without blocking.`,
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via review_approve/review_request_changes on #X.`,
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with related (and optionally blockedBy #X if it truly cannot start yet).`,
` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
``,
`Notification policy:`,
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
`- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`,
``,
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag) or SendMessage.`,
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "${leadName}"`,
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
` 1) FIRST, set the flag to "user" via Bash (this updates the task board):`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> user --from "${leadName}"`,
` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`,
` { teamName: "${teamName}", taskId: "<taskId>", value: "user" }`,
` 2) THEN, send a message to "user" explaining the question.`,
` 3) THEN, reply to the teammate telling them to wait.`,
` IMPORTANT: Always run the Bash command BEFORE sending messages. Without the flag, the task board won't show that the task is blocked waiting for user input.`,
` IMPORTANT: Always update the task board BEFORE sending messages. Without the flag, the task board won't show that the task is blocked waiting for user input.`,
].join('\n')
);
}
/**
* Builds the durable lead context — constraints, communication protocol, teamctl ops,
* Builds the durable lead context — constraints, communication protocol, board MCP ops,
* and agent block policy — that must survive context compaction.
*
* Used by: buildProvisioningPrompt, buildLaunchPrompt, and post-compact reinjection.
@ -630,7 +631,7 @@ Constraints:
- Use the team task board for assigned/substantial work.
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
- When messaging "user" (the human): NEVER mention internal MCP tools, scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint}
${teamCtlOps}
@ -650,9 +651,9 @@ function buildAgentBlockUsagePolicy(): string {
- Humans can see teammate inbox messages and coordination text in the UI.
- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks.
- Any internal operational instructions about tooling/scripts MUST be hidden inside an agent-only block, including:
- how to use internal scripts (e.g. teamctl.js), exact CLI commands, flags (e.g. --notify)
- review command phrases like "review approve <id>" / "review request-changes <id>"
- internal file paths under ~/.claude/ (tools, teams, tasks, kanban state, etc.)
- how to use internal MCP tools, exact tool names, and argument shapes
- review command phrases like "review_approve" / "review_request_changes"
- internal file paths under ~/.claude/ (teams, tasks, kanban state, etc.)
- meta coordination lines like "All teammates are online and have received their assignments via --notify."
- Use an agent-only fenced block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE):
- AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}
@ -664,10 +665,10 @@ ${AGENT_BLOCK_OPEN}
${AGENT_BLOCK_CLOSE}
- Put ONLY the internal instructions inside the agent-only block.
- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty.
- CRITICAL: Messages to "user" must NEVER mention internal tooling, scripts, or CLI commands — not even in plain text. The user interacts through the UI, NOT the terminal. Specifically, NEVER include in user-facing messages:
- teamctl.js commands or references
- any node/bash commands (e.g. node "$HOME/.claude/tools/...")
- internal file paths (~/.claude/tools/, ~/.claude/teams/, etc.)
- CRITICAL: Messages to "user" must NEVER mention internal tooling, MCP tools, scripts, or CLI commands — not even in plain text. The user interacts through the UI, NOT the terminal. Specifically, NEVER include in user-facing messages:
- internal MCP tool names or argument shapes
- any node/bash commands
- internal file paths (~/.claude/teams/, etc.)
- instructions to run commands in terminal
Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF — never ask the user to run a command.
- CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`;
@ -756,15 +757,15 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
- Assign each created task to an appropriate teammate as owner (NOT to yourself), based on role/workflow and current load.
- If ownership is unclear, pick the best default owner and note assumptions in the task description or a task comment.
- Avoid duplicate notifications for the same assignment (one message per member per topic is enough).
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
- When tasks have natural ordering (e.g. setup -> implementation -> testing), use blockedBy relationships.
- If a task is blocked (uses blockedBy), it MUST be created as pending (for example with task_create + startImmediately: false). Do NOT mark blocked tasks in_progress.
- Review guidance:
- Prefer NOT creating a separate “review task”. Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review_approve/review_request_changes on the implementation task #X.
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
- Use --related to connect it to #X (non-blocking link).
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
- There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task start / set-status in_progress) when ready.
- Use --related to connect tasks working on the same feature without blocking.`;
- Use related to connect it to #X (non-blocking link).
- If the review truly cannot start until #X is done, ALSO add blockedBy #X.
- There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task_start / task_set_status in_progress) when ready.
- Use related to connect tasks working on the same feature without blocking.`;
const step2Block = isSolo
? '2) Skip — this is a solo team with no teammates to spawn.'
@ -880,8 +881,8 @@ function buildLaunchPrompt(
The team has been reconnected after a restart.
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
Your FIRST action: run this command to get your full task briefing with descriptions and comments:
node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task briefing --for "${m.name}"
Your FIRST action: call MCP tool task_briefing with:
{ teamName: "${request.teamName}", memberName: "${m.name}" }
Then resume in_progress tasks first, then pending tasks.
If you have no tasks, wait for new assignments.`;
})
@ -901,7 +902,7 @@ ${processRegistration}
Per-member spawn instructions:
${memberSpawnInstructions}
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using the board MCP tools.`;
}
const persistentContext = buildPersistentLeadContext({
@ -1070,7 +1071,8 @@ export class TeamProvisioningService {
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
) {}
getClaudeLogs(
@ -1811,6 +1813,7 @@ export class TeamProvisioningService {
const prompt = buildProvisioningPrompt(request);
let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv();
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
const spawnArgs = [
'--input-format',
'stream-json',
@ -1819,6 +1822,9 @@ export class TeamProvisioningService {
'--verbose',
'--setting-sources',
'user,project,local',
'--strict-mcp-config',
'--mcp-config',
mcpConfigPath,
'--disallowedTools',
'TeamDelete,TodoWrite',
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
@ -2139,6 +2145,7 @@ export class TeamProvisioningService {
);
let child: ReturnType<typeof spawn>;
const { env: shellEnv } = await this.buildProvisioningEnv();
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
const launchArgs = [
'--input-format',
'stream-json',
@ -2147,6 +2154,9 @@ export class TeamProvisioningService {
'--verbose',
'--setting-sources',
'user,project,local',
'--strict-mcp-config',
'--mcp-config',
mcpConfigPath,
'--disallowedTools',
'TeamDelete,TodoWrite',
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
@ -2476,7 +2486,7 @@ export class TeamProvisioningService {
`If action is required, delegate via task creation or SendMessage, and keep responses minimal.`,
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
AGENT_BLOCK_OPEN,
`Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`,
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
AGENT_BLOCK_CLOSE,
``,
`Messages:`,
@ -3298,7 +3308,7 @@ export class TeamProvisioningService {
/**
* Injects a post-compact context reminder into the lead process via stdin.
* Reinjects durable lead rules (constraints, communication protocol, teamctl ops)
* Reinjects durable lead rules (constraints, communication protocol, board MCP ops)
* plus a fresh task board snapshot so the lead recovers full operational context
* after context compaction.
*

View file

@ -129,7 +129,7 @@ export interface TaskBoundary {
event: 'start' | 'complete';
lineNumber: number;
timestamp: string;
mechanism: 'TaskUpdate' | 'teamctl';
mechanism: 'TaskUpdate' | 'teamctl' | 'mcp';
toolUseId?: string;
}
@ -158,7 +158,7 @@ export interface TaskBoundariesResult {
boundaries: TaskBoundary[];
scopes: TaskChangeScope[];
isSingleTaskSession: boolean;
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none';
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none';
}
/** Расширенный TaskChangeSet с confidence деталями (backwards compatible) */

View file

@ -110,6 +110,14 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('PROGRESS REPORTING (MANDATORY)');
expect(prompt).toContain('Never bulk-move many tasks at the end');
expect(prompt).toContain('Default to working ONE task at a time');
expect(prompt).toContain('task_start');
expect(prompt).toContain('task_complete');
expect(prompt).not.toContain('teamctl.js');
expect(prompt).not.toContain('.claude/tools');
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toContain('--strict-mcp-config');
expect(launchArgs).toContain('--mcp-config');
await svc.cancelProvisioning(runId);
});
@ -163,6 +171,13 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
expect(prompt).toContain('Execute tasks sequentially and keep the board + user updated');
expect(prompt).toContain('Do NOT start the next task until the current task is completed');
expect(prompt).toContain('task_start');
expect(prompt).not.toContain('teamctl.js');
expect(prompt).not.toContain('.claude/tools');
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
expect(launchArgs).toContain('--strict-mcp-config');
expect(launchArgs).toContain('--mcp-config');
await svc.cancelProvisioning(runId);
});