feat: Phase1 完整交付——Web 看板(frontend-design) + MCP server(12 工具) + CLI/导入器

- web/: phosphor 调度台风格纯静态看板,审批闸内联 accept/reject(拒绝必填意见),WS 实时
- src/api/static.ts: 手写静态服务(路径穿越防护),daemon 接入
- src/mcp/: stdio MCP server,@modelcontextprotocol/sdk 1.29.0,12 工具薄封装 REST
- src/cli/: 零依赖 CLI(project/task/next/approvals/import-todo),旧 todo.json 导入器
- 实测: pangolin 18 条旧任务导入 45 条;端到端验证通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-12 21:23:08 +08:00
parent 3172718a94
commit c6e66baa19
11 changed files with 3219 additions and 8 deletions
+69
View File
@@ -0,0 +1,69 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, extname, join, resolve, sep } from 'node:path';
import type { FastifyInstance, FastifyReply } from 'fastify';
/**
* Web 看板静态文件服务(零依赖,手写)。
* web/ 不参与 tsc 构建,直接相对仓库根定位:
* dev 时本文件在 src/api/build 后在 dist/api/,两者到仓库根都是 ../..(与 store/db.ts 同思路)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(HERE, '..', '..', 'web');
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.map': 'application/json',
'.txt': 'text/plain; charset=utf-8',
};
async function serveFile(reply: FastifyReply, rawPath: string): Promise<FastifyReply> {
let rel: string;
try {
rel = decodeURIComponent(rawPath);
} catch {
return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request');
}
if (rel.includes('\0')) {
return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request');
}
// 路径穿越防护:解析后必须仍落在 WEB_ROOT 之内
const abs = resolve(WEB_ROOT, '.' + sep + rel);
if (abs !== WEB_ROOT && !abs.startsWith(WEB_ROOT + sep)) {
return reply.code(403).type('text/plain; charset=utf-8').send('Forbidden');
}
const file = abs === WEB_ROOT ? join(WEB_ROOT, 'index.html') : abs;
try {
const buf = await readFile(file);
const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
return reply.type(type).send(buf);
} catch {
return reply.code(404).type('text/plain; charset=utf-8').send('Not Found');
}
}
/**
* 在 Fastify 上注册看板静态路由。
* `/` → web/index.html`/*` → web/ 下对应文件(/api/**、/ws 等更具体路由优先匹配,不受影响)。
*/
export function registerStatic(app: FastifyInstance): void {
app.get('/', (_req, reply) => serveFile(reply, 'index.html'));
app.get('/*', (req, reply) => {
const params = req.params as { '*': string };
return serveFile(reply, params['*'] ?? '');
});
}
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env node
/**
* maestro CLI — 通过 REST API 操作 maestrod(零新依赖,node:util parseArgs)。
*
* 子命令:
* project add <repoPath> [--name N] [--branch B] [--verify CMD] [--concurrency N]
* project list
* task list <projectIdOrName>
* task add <projectIdOrName> <title> --complexity hard|medium|easy [--parent ID] [--priority N]
* next <projectIdOrName>
* approvals [projectIdOrName]
* import-todo <todo.json 路径> --repo <repoPath> [--name N]
*
* 环境变量:MAESTRO_URL(默认 http://127.0.0.1:4517
*/
import { parseArgs, type ParseArgsConfig } from 'node:util';
import { readFileSync } from 'node:fs';
import { resolve, basename } from 'node:path';
import type { Project, Task } from '../model/types.js';
import { STATUS_LABEL, type TaskStatus } from '../model/status.js';
import { COMPLEXITY_LABEL, TIER_TO_COMPLEXITY, isComplexity, type Complexity } from '../model/complexity.js';
const BASE = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517';
const USAGE = `maestro — 多项目 TODO 管理与 Agent 执行系统 CLI
用法:
maestro project add <repoPath> [--name 名称] [--branch 分支] [--verify 命令] [--concurrency 并发数]
注册项目(name 缺省取目录名)
maestro project list
列出所有项目
maestro task list <项目id或名称>
树形列出项目任务(项目名支持模糊匹配)
maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 优先级]
新建任务
maestro next <项目id或名称>
取下一个可执行任务
maestro approvals [项目id或名称]
列出待审批任务(不带参数 = 全部项目)
maestro import-todo <todo.json 路径> --repo <仓库路径> [--name 项目名]
新建项目并导入旧 todo skill 的数据(tier 1/2/3 → hard/medium/easy
maestro --help | help
显示本说明
环境变量:
MAESTRO_URL daemon 地址(默认 http://127.0.0.1:4517`;
// ---------- 基础工具 ----------
class ApiError extends Error {}
function fail(msg: string): never {
console.error(`错误:${msg}`);
process.exit(1);
}
async function api<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
method,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
console.error(`无法连接 maestro daemon${BASE})。`);
console.error('请先启动 maestrod:在 maestro 仓库目录执行 `npm run dev`');
console.error('若 daemon 跑在其他地址/端口,请设置 MAESTRO_URL 环境变量。');
process.exit(1);
}
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
/* 非 JSON 响应 */
}
if (!res.ok) {
const msg = (data as { error?: string } | null)?.error ?? `HTTP ${res.status}`;
throw new ApiError(msg);
}
return data as T;
}
/** 中日韩等全角字符按宽度 2 计算,保证表格对齐 */
function displayWidth(s: string): number {
let w = 0;
for (const ch of s) {
w += /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1;
}
return w;
}
function pad(s: string, width: number): string {
const gap = width - displayWidth(s);
return gap > 0 ? s + ' '.repeat(gap) : s;
}
function printTable(headers: string[], rows: string[][]): void {
const widths = headers.map((h, i) => {
let w = displayWidth(h);
for (const r of rows) w = Math.max(w, displayWidth(r[i] ?? ''));
return w;
});
const line = (cells: string[]): string => cells.map((c, i) => pad(c, widths[i])).join(' ');
console.log(line(headers));
console.log(widths.map((w) => '-'.repeat(w)).join(' '));
for (const r of rows) console.log(line(r));
}
function parseCmdArgs(
rest: string[],
options: NonNullable<ParseArgsConfig['options']>,
): { values: Record<string, string | boolean | undefined>; positionals: string[] } {
try {
const { values, positionals } = parseArgs({ args: rest, options, allowPositionals: true });
return { values: values as Record<string, string | boolean | undefined>, positionals };
} catch (e) {
fail(`参数不合法:${(e as Error).message}\n\n${USAGE}`);
}
}
async function resolveProject(idOrName: string): Promise<Project> {
const projects = await api<Project[]>('GET', '/api/projects');
const byId = projects.find((p) => p.id === idOrName);
if (byId) return byId;
let matched = projects.filter((p) => p.name === idOrName);
if (matched.length === 0) {
const lower = idOrName.toLowerCase();
matched = projects.filter((p) => p.name.toLowerCase().includes(lower));
}
if (matched.length === 1) return matched[0];
if (matched.length > 1) {
fail(
`项目「${idOrName}」匹配到 ${matched.length} 个,请用更精确的名称或直接用 id\n` +
matched.map((p) => ` - ${p.name} (${p.id})`).join('\n'),
);
}
fail(`找不到项目「${idOrName}」。可先用 \`maestro project list\` 查看全部项目。`);
}
// ---------- project ----------
async function cmdProjectAdd(rest: string[]): Promise<void> {
const { values, positionals } = parseCmdArgs(rest, {
name: { type: 'string' },
branch: { type: 'string' },
verify: { type: 'string' },
concurrency: { type: 'string' },
});
const repoArg = positionals[0];
if (!repoArg) fail('用法:maestro project add <repoPath> [--name N] [--branch B] [--verify CMD] [--concurrency N]');
const repoPath = resolve(repoArg);
const name = (values.name as string | undefined) ?? basename(repoPath);
const concurrency = values.concurrency === undefined ? undefined : Number(values.concurrency);
if (concurrency !== undefined && (!Number.isInteger(concurrency) || concurrency < 1)) {
fail('--concurrency 必须是 >=1 的整数');
}
const p = await api<Project>('POST', '/api/projects', {
name,
repoPath,
defaultBranch: values.branch as string | undefined,
verifyCmd: values.verify as string | undefined,
concurrency,
});
console.log(`已注册项目「${p.name}」(${p.id})`);
console.log(` repo: ${p.repoPath} · 分支: ${p.defaultBranch} · 并发: ${p.concurrency} · verify: ${p.verifyCmd ?? '无'}`);
}
async function cmdProjectList(): Promise<void> {
const projects = await api<Project[]>('GET', '/api/projects');
if (projects.length === 0) {
console.log('暂无项目。用 `maestro project add <repoPath>` 注册一个。');
return;
}
printTable(
['id', 'name', 'repoPath', 'status'],
projects.map((p) => [p.id, p.name, p.repoPath, p.status]),
);
}
// ---------- task ----------
function taskLine(t: Task): string {
const deps = t.deps.length ? ` deps:${t.deps.length}` : '';
return `[${COMPLEXITY_LABEL[t.complexity]}·${STATUS_LABEL[t.status]}] ${t.title}${deps} (${t.id})`;
}
function printTaskTree(tasks: Task[]): void {
const byParent = new Map<string | null, Task[]>();
for (const t of tasks) {
const key = t.parentId;
const list = byParent.get(key) ?? [];
list.push(t);
byParent.set(key, list);
}
const sortFn = (a: Task, b: Task): number => b.priority - a.priority || a.createdAt.localeCompare(b.createdAt);
const walk = (parentId: string | null, indent: string): void => {
const list = (byParent.get(parentId) ?? []).sort(sortFn);
for (const t of list) {
console.log(`${indent}- ${taskLine(t)}`);
walk(t.id, indent + ' ');
}
};
walk(null, '');
}
async function cmdTaskList(rest: string[]): Promise<void> {
const { positionals } = parseCmdArgs(rest, {});
if (!positionals[0]) fail('用法:maestro task list <项目id或名称>');
const project = await resolveProject(positionals[0]);
const tasks = await api<Task[]>('GET', `/api/projects/${project.id}/tasks`);
console.log(`项目「${project.name}」(${project.id}) · 共 ${tasks.length} 条任务\n`);
if (tasks.length === 0) {
console.log('(暂无任务)');
return;
}
printTaskTree(tasks);
}
async function cmdTaskAdd(rest: string[]): Promise<void> {
const { values, positionals } = parseCmdArgs(rest, {
complexity: { type: 'string' },
parent: { type: 'string' },
priority: { type: 'string' },
});
const projectArg = positionals[0];
const title = positionals.slice(1).join(' ').trim();
if (!projectArg || !title) {
fail('用法:maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent ID] [--priority N]');
}
if (!isComplexity(values.complexity)) fail('--complexity 必填,且只能是 hard | medium | easy');
const priority = values.priority === undefined ? undefined : Number(values.priority);
if (priority !== undefined && !Number.isFinite(priority)) fail('--priority 必须是数字');
const project = await resolveProject(projectArg);
const t = await api<Task>('POST', `/api/projects/${project.id}/tasks`, {
title,
complexity: values.complexity,
parentId: values.parent as string | undefined,
priority,
});
console.log(`已创建任务 ${taskLine(t)}`);
}
// ---------- next / approvals ----------
async function cmdNext(rest: string[]): Promise<void> {
const { positionals } = parseCmdArgs(rest, {});
if (!positionals[0]) fail('用法:maestro next <项目id或名称>');
const project = await resolveProject(positionals[0]);
const r = await api<Task | { next: null }>('GET', `/api/projects/${project.id}/next`);
if (!r || !('id' in r)) {
console.log(`项目「${project.name}」当前没有可执行任务(叶子 + ready + 依赖满足)。`);
return;
}
console.log(`下一个可执行任务:`);
console.log(` ${taskLine(r)}`);
}
async function cmdApprovals(rest: string[]): Promise<void> {
const { positionals } = parseCmdArgs(rest, {});
let query = '';
let scope = '全部项目';
if (positionals[0]) {
const project = await resolveProject(positionals[0]);
query = `?projectId=${encodeURIComponent(project.id)}`;
scope = `项目「${project.name}`;
}
const tasks = await api<Task[]>('GET', `/api/approvals${query}`);
if (tasks.length === 0) {
console.log(`${scope}当前没有待审批任务。`);
return;
}
const projects = await api<Project[]>('GET', '/api/projects');
const nameOf = new Map(projects.map((p) => [p.id, p.name]));
console.log(`${scope}待审批任务 ${tasks.length} 条:\n`);
printTable(
['闸', '复杂度', '标题', '任务id', '项目'],
tasks.map((t) => [
STATUS_LABEL[t.status],
COMPLEXITY_LABEL[t.complexity],
t.title,
t.id,
nameOf.get(t.projectId) ?? t.projectId,
]),
);
}
// ---------- import-todo ----------
interface OldGate {
kind?: string | null;
note?: string | null;
ref?: string | null;
approval?: string | null;
}
interface OldSub {
sid: string;
title?: string;
tier?: number | null;
deps?: string[];
status?: string;
}
interface OldItem {
id: number;
title?: string;
desc?: string | null;
level?: string | null;
tier?: number | null;
tags?: string[];
status?: string;
done?: boolean;
version?: string | null;
subtasks?: OldSub[];
gate?: OldGate | null;
reject_reason?: string | null;
}
interface OldDb {
meta?: { title?: string };
items?: OldItem[];
}
type OldStatus = 'open' | 'doing' | 'done' | 'accepted';
const OLD_STATUS_LABEL: Record<OldStatus, string> = {
open: '待开始',
doing: '开发中',
done: '待验收',
accepted: '已验收',
};
interface ImportStats {
total: number;
byComplexity: Record<Complexity, number>;
done: number;
skipped: number;
warnings: string[];
}
function oldStatusOf(raw: string | undefined, doneFlag: boolean | undefined): OldStatus {
if (raw === 'open' || raw === 'doing' || raw === 'done' || raw === 'accepted') return raw;
return doneFlag ? 'accepted' : 'open';
}
function complexityOfTier(tier: number | null | undefined, label: string, stats: ImportStats): Complexity {
const c = tier == null ? undefined : TIER_TO_COMPLEXITY[tier];
if (c) return c;
stats.warnings.push(`${label} 缺少有效 tier${tier ?? '无'}),按 medium 导入`);
return 'medium';
}
function priorityOfLevel(level: string | null | undefined): number {
if (level === 'high') return 2;
if (level === 'mid') return 1;
return 0;
}
/** 旧 item 的描述性字段合成产出内容(plan/spec/operations */
function buildImportNote(item: OldItem, st: OldStatus, extra: string[] = []): string {
const lines = [`【从旧 todo 导入】原 #${item.id} · 原状态:${st}${OLD_STATUS_LABEL[st]}`];
if (st === 'doing') lines.push('注:导入前处于「开发中」,导入后回到初始状态,需按新流程重新推进。');
for (const l of extra) lines.push(l);
if (item.desc) lines.push('', item.desc);
if (item.tags?.length) lines.push('', `标签:${item.tags.join(' / ')}`);
if (item.level) lines.push(`原重要度:${item.level}`);
if (item.gate && (item.gate.note || item.gate.ref)) {
const ref = item.gate.ref ? `(详见 ${item.gate.ref}` : '';
lines.push('', `原方案/改动说明(approval=${item.gate.approval ?? '无'}):${item.gate.note ?? ''}${ref}`);
}
if (item.reject_reason) lines.push(`原拒绝原因:${item.reject_reason}`);
if (item.version) lines.push(`原验收版本:${item.version}`);
return lines.join('\n');
}
const PRODUCE_PATH: Record<Complexity, 'plan' | 'spec' | 'operations'> = {
hard: 'plan',
medium: 'spec',
easy: 'operations',
};
async function setProduce(taskId: string, complexity: Complexity, content: string): Promise<void> {
const field = PRODUCE_PATH[complexity];
await api('POST', `/api/tasks/${taskId}/${field}`, { [field]: content });
}
async function doTransition(taskId: string, to: TaskStatus): Promise<void> {
await api('POST', `/api/tasks/${taskId}/transition`, { to });
}
async function doAccept(taskId: string): Promise<void> {
await api('POST', `/api/tasks/${taskId}/decide`, { action: 'accept', actor: 'importer' });
}
/**
* 把非 Hard 容器任务沿合法路径推到 done:
* easy: ready → queued → executing → exec_review → accept(done)
* medium: speccing → spec_review → accept(ready) → queued → executing → exec_review → accept(done)
* 推不动时记 warning、保留现状,返回 false。
*/
async function pushLeafToDone(taskId: string, complexity: Complexity, label: string, stats: ImportStats): Promise<boolean> {
try {
if (complexity === 'medium') {
await doTransition(taskId, 'spec_review');
await doAccept(taskId); // → ready
}
await doTransition(taskId, 'queued');
await doTransition(taskId, 'executing');
await doTransition(taskId, 'exec_review');
await doAccept(taskId); // → done
stats.done += 1;
return true;
} catch (e) {
stats.warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`);
return false;
}
}
/**
* Hard 容器推进:analyzing → plan_review → accept(decomposed)。
* 子任务全部 done(或无子任务,已在 plan 注明)时再 decomposed → done。
*/
async function pushHardContainer(
taskId: string,
label: string,
hasChildren: boolean,
allChildrenDone: boolean,
stats: ImportStats,
): Promise<void> {
try {
await doTransition(taskId, 'plan_review');
await doAccept(taskId); // → decomposed
if (!hasChildren || allChildrenDone) {
await doTransition(taskId, 'done');
stats.done += 1;
} else {
stats.warnings.push(`${label} 原状态为已完成,但子任务未能全部推到 done,容器停在 decomposed`);
}
} catch (e) {
stats.warnings.push(`${label} 推进 done 失败:${(e as Error).message}(已保留当前状态)`);
}
}
async function importItem(projectId: string, item: OldItem, stats: ImportStats): Promise<void> {
const label = `旧 #${item.id}${item.title ?? '?'}`;
if (!item.title) {
stats.skipped += 1;
stats.warnings.push(`旧 #${item.id} 缺少 title,已跳过`);
return;
}
const st = oldStatusOf(item.status, item.done);
const complexity = complexityOfTier(item.tier, label, stats);
const subs = item.subtasks ?? [];
const isDone = st === 'done' || st === 'accepted';
// 1) 建父任务
const task = await api<Task>('POST', `/api/projects/${projectId}/tasks`, {
title: item.title,
complexity,
priority: priorityOfLevel(item.level),
});
stats.total += 1;
stats.byComplexity[complexity] += 1;
// 2) 写产出字段(hard→plan / medium→spec / easy→operations
const extra: string[] = [];
if (isDone && complexity === 'hard' && subs.length === 0) {
extra.push('注:原任务已完成且无子任务,导入时容器直接推到 done。');
}
await setProduce(task.id, complexity, buildImportNote(item, st, extra));
// 3) 建子任务(sid 依赖 → 新任务 id)
const sidToId = new Map<string, string>();
const childPlan: Array<{ id: string; complexity: Complexity; oldStatus: string; label: string }> = [];
for (const sub of subs) {
const subLabel = `旧子任务 ${sub.sid}${sub.title ?? '?'}`;
if (!sub.title) {
stats.skipped += 1;
stats.warnings.push(`${subLabel} 缺少 title,已跳过`);
continue;
}
const subComplexity = complexityOfTier(sub.tier, subLabel, stats);
const deps: string[] = [];
for (const dep of sub.deps ?? []) {
const depId = sidToId.get(dep.toUpperCase());
if (depId) deps.push(depId);
else stats.warnings.push(`${subLabel} 的依赖 ${dep} 未找到对应任务,已忽略该依赖`);
}
const subStatus = sub.status === 'accepted' ? 'done' : (sub.status ?? 'open');
const child = await api<Task>('POST', `/api/projects/${projectId}/tasks`, {
title: sub.title,
complexity: subComplexity,
parentId: task.id,
deps,
});
sidToId.set(sub.sid.toUpperCase(), child.id);
stats.total += 1;
stats.byComplexity[subComplexity] += 1;
const subNote = [
`【从旧 todo 导入】原子任务 ${sub.sid}(父:旧 #${item.id})· 原状态:${subStatus}`,
subStatus === 'doing' ? '注:导入前处于「开发中」,导入后回到初始状态。' : '',
].filter(Boolean).join('\n');
await setProduce(child.id, subComplexity, subNote);
childPlan.push({ id: child.id, complexity: subComplexity, oldStatus: subStatus, label: subLabel });
}
// 4) 先推子任务,再推父任务
let allChildrenDone = childPlan.length > 0;
for (const c of childPlan) {
if (c.oldStatus === 'done') {
let ok: boolean;
if (c.complexity === 'hard') {
// 已完成的 hard 子任务(无下级)按容器路径收口
await pushHardContainer(c.id, c.label, false, true, stats);
const cur = await api<Task>('GET', `/api/tasks/${c.id}`);
ok = cur.status === 'done';
} else {
ok = await pushLeafToDone(c.id, c.complexity, c.label, stats);
}
if (!ok) allChildrenDone = false;
} else {
allChildrenDone = false; // open/doing:建完即停
}
}
if (!isDone) return; // open / doing:建完即停(doing 已在产出里备注)
if (complexity === 'hard') {
await pushHardContainer(task.id, label, childPlan.length > 0, allChildrenDone, stats);
} else {
await pushLeafToDone(task.id, complexity, label, stats);
}
}
async function cmdImportTodo(rest: string[]): Promise<void> {
const { values, positionals } = parseCmdArgs(rest, {
repo: { type: 'string' },
name: { type: 'string' },
});
const file = positionals[0];
if (!file || !values.repo) {
fail('用法:maestro import-todo <todo.json 路径> --repo <仓库路径> [--name 项目名]');
}
const filePath = resolve(file);
let db: OldDb;
try {
db = JSON.parse(readFileSync(filePath, 'utf8')) as OldDb;
} catch (e) {
fail(`读取 ${filePath} 失败:${(e as Error).message}`);
}
const items = db.items ?? [];
if (!Array.isArray(items)) fail(`${filePath} 不是合法的旧 todo.json(缺少 items 数组)`);
const repoPath = resolve(values.repo as string);
const name = (values.name as string | undefined) ?? basename(repoPath);
const project = await api<Project>('POST', '/api/projects', { name, repoPath });
console.log(`已创建项目「${project.name}」(${project.id}),开始导入 ${items.length} 条旧任务…\n`);
const stats: ImportStats = {
total: 0,
byComplexity: { hard: 0, medium: 0, easy: 0 },
done: 0,
skipped: 0,
warnings: [],
};
for (const item of items) {
await importItem(project.id, item, stats);
}
console.log('导入完成:');
console.log(
` 共导入 ${stats.total} 条任务(hard ${stats.byComplexity.hard} / medium ${stats.byComplexity.medium} / easy ${stats.byComplexity.easy}`,
);
console.log(` 推到 done${stats.done} 条 · 跳过:${stats.skipped} 条 · 警告:${stats.warnings.length}`);
for (const w of stats.warnings) console.log(`${w}`);
console.log(`\n查看结果:maestro task list ${project.name}`);
}
// ---------- 入口 ----------
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const cmd = argv[0];
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
console.log(USAGE);
return;
}
if (cmd === 'project') {
const sub = argv[1];
if (sub === 'add') return cmdProjectAdd(argv.slice(2));
if (sub === 'list') return cmdProjectList();
fail(`未知子命令:project ${sub ?? ''}\n\n${USAGE}`);
}
if (cmd === 'task') {
const sub = argv[1];
if (sub === 'list') return cmdTaskList(argv.slice(2));
if (sub === 'add') return cmdTaskAdd(argv.slice(2));
fail(`未知子命令:task ${sub ?? ''}\n\n${USAGE}`);
}
if (cmd === 'next') return cmdNext(argv.slice(1));
if (cmd === 'approvals') return cmdApprovals(argv.slice(1));
if (cmd === 'import-todo') return cmdImportTodo(argv.slice(1));
fail(`未知命令:${cmd}\n\n${USAGE}`);
}
main().catch((err) => {
if (err instanceof ApiError) fail(err.message);
console.error('执行失败:', err);
process.exit(1);
});
+2
View File
@@ -1,5 +1,6 @@
import { Store } from '../store/index.js';
import { buildServer, attachWebSocket } from '../api/server.js';
import { registerStatic } from '../api/static.js';
import { loadConfig } from './config.js';
/** maestrod:核心 daemon。Phase 1 = Store + REST/WS API(手动驱动;编排器在 Phase 2 接入)。 */
@@ -8,6 +9,7 @@ async function main(): Promise<void> {
const store = new Store(cfg.dbFile);
const app = buildServer({ store, logger: true });
registerStatic(app); // Web 看板(web/ 静态文件)
attachWebSocket(app, store);
await app.listen({ host: cfg.host, port: cfg.port });
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env node
/**
* maestro MCP serverstdio transport)。
*
* 给任意 Claude Code 会话提供任务读写工具:全部经 REST 调本机 maestrod
* 自身不碰数据库。base URL 取环境变量 MAESTRO_URL(默认 http://127.0.0.1:4517)。
*
* 注意:审批(accept/reject)是用户在 Web 看板上的动作,本 server 只提供
* 只读的 get_pending_approvals,不提供 decide 类工具。
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { STATUS_LABEL, type TaskStatus } from '../model/status.js';
const BASE_URL = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517';
// ---------- REST 薄封装 ----------
/** 业务错误(daemon 返回 400 {error}),消息原样透传给调用方。 */
class ApiError extends Error {}
async function api<T = unknown>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(`${BASE_URL}${path}`, {
method,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new ApiError(
`无法连接 maestro daemon${BASE_URL})。请先启动 maestrod:在 maestro 目录执行 \`npm run dev\`(或设置 MAESTRO_URL 指向运行中的 daemon)。`,
);
}
const text = await res.text();
let data: unknown = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (!res.ok) {
const msg = (data as { error?: string } | null)?.error;
throw new ApiError(msg ?? `daemon 返回 HTTP ${res.status}: ${text}`);
}
return data as T;
}
// ---------- 工具结果辅助 ----------
interface ToolResult {
content: { type: 'text'; text: string }[];
isError?: boolean;
[key: string]: unknown;
}
function ok(data: unknown): ToolResult {
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
function fail(err: unknown): ToolResult {
const msg = err instanceof Error ? err.message : String(err);
return { content: [{ type: 'text', text: msg }], isError: true };
}
/** 包一层:把 ApiError / 网络错误转成 isError 的工具结果(消息原样给调用方)。 */
async function run(fn: () => Promise<unknown>): Promise<ToolResult> {
try {
return ok(await fn());
} catch (err) {
return fail(err);
}
}
// ---------- 共享 schema 片段 ----------
const STATUSES = Object.keys(STATUS_LABEL) as [TaskStatus, ...TaskStatus[]];
const complexitySchema = z
.enum(['hard', 'medium', 'easy'])
.describe('复杂度:hard(须分析拆解)| medium(须写方案过审)| easy(写明操作即可执行)');
const subtaskSchema = z.object({
title: z.string().min(1).describe('子任务标题'),
complexity: complexitySchema,
deps: z.array(z.string()).optional().describe('依赖的任务 id 列表(须全部 done 才可执行)'),
priority: z.number().int().optional().describe('优先级,数字越大越优先(默认 0)'),
});
// ---------- MCP server ----------
const server = new McpServer({ name: 'maestro', version: '0.1.0' });
server.registerTool(
'list_projects',
{
title: '列出项目',
description: '列出 maestro 中注册的所有项目(id、名称、repo 路径、自治级别等)。',
inputSchema: {},
},
async () => run(() => api('GET', '/api/projects')),
);
server.registerTool(
'create_project',
{
title: '创建项目',
description: '在 maestro 注册一个本地 git 项目。name 与 repoPath 必填。',
inputSchema: {
name: z.string().min(1).describe('项目名'),
repoPath: z.string().min(1).describe('本地 git 仓库绝对路径'),
defaultBranch: z.string().optional().describe('默认分支(默认 main'),
verifyCmd: z.string().optional().describe('验证命令(执行后跑,如 npm test)'),
autonomy: z.enum(['manual', 'auto-easy', 'auto-approved']).optional()
.describe('自治级别:manual(全手动)| auto-easyEasy 自动执行)| auto-approved(过审即自动执行)'),
model: z.string().optional().describe('执行 agent 使用的模型'),
concurrency: z.number().int().min(1).optional().describe('worker 并发数(默认 1'),
},
},
async (args) => run(() => api('POST', '/api/projects', args)),
);
server.registerTool(
'list_tasks',
{
title: '列出任务',
description: '列出某项目的全部任务(含层级、复杂度、状态、依赖与审批历史)。',
inputSchema: { projectId: z.string().min(1).describe('项目 idprj_ 开头)') },
},
async ({ projectId }) => run(() => api('GET', `/api/projects/${encodeURIComponent(projectId)}/tasks`)),
);
server.registerTool(
'get_task',
{
title: '查看任务详情',
description: '取单个任务的完整详情:字段(plan/spec/operations/result)、审批历史(approvals)以及全部子任务(children)。',
inputSchema: { taskId: z.string().min(1).describe('任务 idtsk_ 开头)') },
},
async ({ taskId }) =>
run(async () => {
const tid = encodeURIComponent(taskId);
const task = await api('GET', `/api/tasks/${tid}`);
const children = await api('GET', `/api/tasks/${tid}/children`);
return { task, children };
}),
);
server.registerTool(
'create_task',
{
title: '创建任务',
description:
'在项目下创建任务。初始状态由复杂度决定:hard→analyzing(须先分析拆解)、medium→speccing(须先写方案)、easy→ready(写明 operations 后即可执行)。',
inputSchema: {
projectId: z.string().min(1).describe('项目 id'),
title: z.string().min(1).describe('任务标题'),
complexity: complexitySchema,
parentId: z.string().optional().describe('父任务 id(建子任务时填,最多 3-4 层)'),
deps: z.array(z.string()).optional().describe('依赖的任务 id 列表'),
priority: z.number().int().optional().describe('优先级,数字越大越优先(默认 0)'),
},
},
async ({ projectId, ...rest }) =>
run(() => api('POST', `/api/projects/${encodeURIComponent(projectId)}/tasks`, rest)),
);
server.registerTool(
'decompose_task',
{
title: '拆解任务',
description:
'把一个任务批量拆解为子任务(常用于 Hard 任务 analyzing 阶段的产出)。逐个创建,返回每条的创建结果;某条失败不影响其余(结果里带 error)。拆完通常还需 write_plan 写入分析、update_status 到 plan_review 等用户审批。',
inputSchema: {
taskId: z.string().min(1).describe('要拆解的父任务 id'),
subtasks: z.array(subtaskSchema).min(1).describe('子任务列表,各自带复杂度'),
},
},
async ({ taskId, subtasks }) =>
run(async () => {
const parent = await api<{ projectId: string }>('GET', `/api/tasks/${encodeURIComponent(taskId)}`);
const results: unknown[] = [];
for (const st of subtasks) {
try {
const created = await api('POST', `/api/projects/${encodeURIComponent(parent.projectId)}/tasks`, {
...st,
parentId: taskId,
});
results.push({ ok: true, task: created });
} catch (err) {
results.push({ ok: false, title: st.title, error: err instanceof Error ? err.message : String(err) });
}
}
return { parentId: taskId, created: results };
}),
);
server.registerTool(
'write_plan',
{
title: '写入分析拆解(plan',
description: 'Hard 任务的产出:写入「分析 + 拆解说明」。写完用 update_status 把任务转到 plan_review 等用户审批。',
inputSchema: {
taskId: z.string().min(1).describe('任务 id'),
content: z.string().min(1).describe('plan 内容(Markdown'),
},
},
async ({ taskId, content }) =>
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/plan`, { plan: content })),
);
server.registerTool(
'write_spec',
{
title: '写入方案(spec',
description: 'Medium 任务的产出:写入「具体方案 = 改动内容 + 为什么这么做」。写完用 update_status 转到 spec_review 等用户审批。',
inputSchema: {
taskId: z.string().min(1).describe('任务 id'),
content: z.string().min(1).describe('spec 内容(Markdown'),
},
},
async ({ taskId, content }) =>
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/spec`, { spec: content })),
);
server.registerTool(
'write_operations',
{
title: '写入操作记录(operations',
description: 'Easy 任务的产出:写清「将执行的操作」。Easy 无前置审批闸,写完即可 update_status 到 ready。',
inputSchema: {
taskId: z.string().min(1).describe('任务 id'),
content: z.string().min(1).describe('operations 内容(Markdown'),
},
},
async ({ taskId, content }) =>
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/operations`, { operations: content })),
);
server.registerTool(
'update_status',
{
title: '变更任务状态',
description:
`按状态机流转任务状态(合法流转见 daemon 守卫;非法流转会返回中文错误,原样透传)。可选值:${STATUSES.join(' | ')}。注意:plan_review/spec_review/exec_review 的 accept/reject 是用户在看板上的动作,不要用本工具绕过审批闸。`,
inputSchema: {
taskId: z.string().min(1).describe('任务 id'),
to: z.enum(STATUSES).describe('目标状态'),
},
},
async ({ taskId, to }) =>
run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/transition`, { to })),
);
server.registerTool(
'get_next_executable',
{
title: '取下一个可执行任务',
description: '取项目中下一个可执行任务(叶子、ready、依赖全部 done,按优先级排序);没有则返回 {"next": null}。',
inputSchema: { projectId: z.string().min(1).describe('项目 id') },
},
async ({ projectId }) => run(() => api('GET', `/api/projects/${encodeURIComponent(projectId)}/next`)),
);
server.registerTool(
'get_pending_approvals',
{
title: '查看待审批任务(只读)',
description:
'列出处于审批闸(plan_review / spec_review / exec_review)的任务,可按项目过滤。只读:accept/reject 是用户在 Web 看板上的动作,本 MCP 不提供审批工具——发现待审批项请提醒用户去看板处理。',
inputSchema: { projectId: z.string().optional().describe('项目 id(不填则跨全部项目)') },
},
async ({ projectId }) =>
run(() => api('GET', projectId ? `/api/approvals?projectId=${encodeURIComponent(projectId)}` : '/api/approvals')),
);
// ---------- 启动 ----------
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
// stdio 模式下 stdout 是协议通道,日志只能走 stderr
console.error(`maestro-mcp 就绪 · daemon=${BASE_URL}`);
}
main().catch((err) => {
console.error('maestro-mcp 启动失败:', err);
process.exit(1);
});