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:
parent
00ca6698fa
commit
6091f4f7ae
42 changed files with 3074 additions and 1638 deletions
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -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
1
agent-teams-controller/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist/
|
||||
22
agent-teams-controller/package.json
Normal file
22
agent-teams-controller/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
agent-teams-controller/scripts/build.mjs
Normal file
37
agent-teams-controller/scripts/build.mjs
Normal 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);
|
||||
}
|
||||
4
agent-teams-controller/src/cli.js
Normal file
4
agent-teams-controller/src/cli.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
require('./legacy/teamctl.cli.js');
|
||||
38
agent-teams-controller/src/controller.js
Normal file
38
agent-teams-controller/src/controller.js
Normal 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,
|
||||
};
|
||||
17
agent-teams-controller/src/index.js
Normal file
17
agent-teams-controller/src/index.js
Normal 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,
|
||||
};
|
||||
31
agent-teams-controller/src/internal/capture.js
Normal file
31
agent-teams-controller/src/internal/capture.js
Normal 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,
|
||||
};
|
||||
24
agent-teams-controller/src/internal/context.js
Normal file
24
agent-teams-controller/src/internal/context.js
Normal 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,
|
||||
};
|
||||
49
agent-teams-controller/src/internal/kanban.js
Normal file
49
agent-teams-controller/src/internal/kanban.js
Normal 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,
|
||||
};
|
||||
9
agent-teams-controller/src/internal/messages.js
Normal file
9
agent-teams-controller/src/internal/messages.js
Normal 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,
|
||||
};
|
||||
27
agent-teams-controller/src/internal/processes.js
Normal file
27
agent-teams-controller/src/internal/processes.js
Normal 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,
|
||||
};
|
||||
17
agent-teams-controller/src/internal/review.js
Normal file
17
agent-teams-controller/src/internal/review.js
Normal 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,
|
||||
};
|
||||
95
agent-teams-controller/src/internal/tasks.js
Normal file
95
agent-teams-controller/src/internal/tasks.js
Normal 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,
|
||||
};
|
||||
1557
agent-teams-controller/src/legacy/teamctl.cli.js
Normal file
1557
agent-teams-controller/src/legacy/teamctl.cli.js
Normal file
File diff suppressed because it is too large
Load diff
63
agent-teams-controller/test/controller.test.js
Normal file
63
agent-teams-controller/test/controller.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
13
agent-teams-controller/test/legacyTeamctl.test.js
Normal file
13
agent-teams-controller/test/legacyTeamctl.test.js
Normal 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>');
|
||||
});
|
||||
});
|
||||
10
agent-teams-controller/vitest.config.js
Normal file
10
agent-teams-controller/vitest.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
57
mcp-server/src/agent-teams-controller.d.ts
vendored
Normal file
57
mcp-server/src/agent-teams-controller.d.ts
vendored
Normal 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;
|
||||
}
|
||||
8
mcp-server/src/controller.ts
Normal file
8
mcp-server/src/controller.ts
Normal 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
22
mcp-server/src/index.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
15
mcp-server/src/tools/index.ts
Normal file
15
mcp-server/src/tools/index.ts
Normal 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);
|
||||
}
|
||||
77
mcp-server/src/tools/kanbanTools.ts
Normal file
77
mcp-server/src/tools/kanbanTools.ts
Normal 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)),
|
||||
});
|
||||
}
|
||||
33
mcp-server/src/tools/messageTools.ts
Normal file
33
mcp-server/src/tools/messageTools.ts
Normal 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 } : {}),
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
70
mcp-server/src/tools/processTools.ts
Normal file
70
mcp-server/src/tools/processTools.ts
Normal 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 })),
|
||||
});
|
||||
}
|
||||
50
mcp-server/src/tools/reviewTools.ts
Normal file
50
mcp-server/src/tools/reviewTools.ts
Normal 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 } : {}),
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
261
mcp-server/src/tools/taskTools.ts
Normal file
261
mcp-server/src/tools/taskTools.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
10
mcp-server/src/utils/format.ts
Normal file
10
mcp-server/src/utils/format.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
142
mcp-server/test/tools.test.ts
Normal file
142
mcp-server/test/tools.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
packages:
|
||||
- agent-teams-controller
|
||||
- mcp-server
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
|||
85
src/main/services/team/TeamMcpConfigBuilder.ts
Normal file
85
src/main/services/team/TeamMcpConfigBuilder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue