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);
});