feat: MCP/CLI 追平 Phase 2 后端能力(score/复审/合并/额度/归档)
任务 ID:tsk_sjzC4B9ctg6W MCP(src/mcp/index.ts,重构为可测的 createTools(apiFn)): - 读:新增 get_agents(自治/并发/调度/模型/在途)、get_usage(订阅额度)、 list_archived(终态分页);get_task 补 score(自身分+链条惯性+解锁加权) 与 review(summary/verdict/securitySummary/securityVerdict)显式字段。 - 写:新增 patch_task(复用 store.patchTask 校验)、patch_project、sync_project、 decide(action + merge:通过并合并 / 仅通过;reject 必带 reason)。 - 复核全部工具 description 与状态机语义一致;entry-guard 便于 import 测试。 CLI(src/cli/index.ts)对齐子命令: - project set / task patch / decide <id> accept|reject [--no-merge] / agents / usage / archive。两端均薄封装走 REST,不重复实现。 API(src/api/server.ts):新增 GET /api/usage 单独透传订阅额度。 测试:新增 test/mcp.test.ts(zod schema 边界 + 对 mock api 的 method/path/body 断言 + score/分页/错误透传),npm test 103 全过、typecheck/build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+192
-1
@@ -5,10 +5,16 @@
|
||||
* 子命令:
|
||||
* project add <repoPath> [--name N] [--branch B] [--verify CMD] [--concurrency N]
|
||||
* project list
|
||||
* project set <projectIdOrName> [--autonomy A] [--concurrency N] [--verify CMD] [--model M] [--logo L] [--status active|paused]
|
||||
* task list <projectIdOrName>
|
||||
* task add <projectIdOrName> <title> --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)]
|
||||
* task patch <taskId> [--title T] [--priority 0|1|2] [--complexity hard|medium|easy]
|
||||
* next <projectIdOrName>
|
||||
* approvals [projectIdOrName]
|
||||
* decide <taskId> accept|reject [--no-merge] [--reason R]
|
||||
* agents
|
||||
* usage
|
||||
* archive <projectIdOrName> [--page N] [--size N]
|
||||
* import-todo <repoPath> [--name N] (导入=首次同步,读 <repoPath>/todo/todo.json,幂等)
|
||||
*
|
||||
* 环境变量:MAESTRO_URL(默认 http://127.0.0.1:4517)
|
||||
@@ -28,14 +34,26 @@ const USAGE = `maestro — 多项目 TODO 管理与 Agent 执行系统 CLI
|
||||
注册项目(name 缺省取目录名)
|
||||
maestro project list
|
||||
列出所有项目
|
||||
maestro project set <项目id或名称> [--autonomy manual|auto-easy|auto-approved] [--concurrency 并发数] [--verify 命令] [--model 模型] [--logo 路径或URL] [--status active|paused]
|
||||
更新项目配置(仅传需要改的字段;--verify/--model/--logo 传空串 "" 清空)
|
||||
maestro task list <项目id或名称>
|
||||
树形列出项目任务(项目名支持模糊匹配)
|
||||
maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 0|1|2,P0 最高,默认 P1]
|
||||
新建任务
|
||||
maestro task patch <任务id> [--title 标题] [--priority 0|1|2] [--complexity hard|medium|easy]
|
||||
更新任务字段(complexity 仅在未进入执行链路时可改)
|
||||
maestro next <项目id或名称>
|
||||
取下一个可执行任务
|
||||
maestro approvals [项目id或名称]
|
||||
列出待审批任务(不带参数 = 全部项目)
|
||||
maestro decide <任务id> accept|reject [--no-merge] [--reason 改进意见]
|
||||
审批裁决:accept 通过 / reject 拒绝(reject 必带 --reason);exec_review 的 accept 默认通过并合并,--no-merge 仅通过不合并
|
||||
maestro agents
|
||||
汇总各项目执行 agent 状态(自治/并发/调度/模型/在途)+ Claude 订阅额度
|
||||
maestro usage
|
||||
查看 Claude 订阅额度(5 小时窗口 + 周窗口)
|
||||
maestro archive <项目id或名称> [--page 页码] [--size 每页条数]
|
||||
分页列出归档任务(done/cancelled,按更新时间倒序)
|
||||
maestro import-todo <仓库路径> [--name 项目名]
|
||||
导入/同步旧 todo skill 数据(读 <仓库路径>/todo/todo.json;项目不存在则建;幂等可重复)
|
||||
maestro --help | help
|
||||
@@ -53,7 +71,7 @@ function fail(msg: string): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function api<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||
async function api<T>(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${BASE}${path}`, {
|
||||
@@ -178,6 +196,46 @@ async function cmdProjectList(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdProjectSet(rest: string[]): Promise<void> {
|
||||
const { values, positionals } = parseCmdArgs(rest, {
|
||||
autonomy: { type: 'string' },
|
||||
concurrency: { type: 'string' },
|
||||
verify: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
logo: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
});
|
||||
if (!positionals[0]) {
|
||||
fail('用法:maestro project set <项目id或名称> [--autonomy A] [--concurrency N] [--verify CMD] [--model M] [--logo L] [--status active|paused]');
|
||||
}
|
||||
const project = await resolveProject(positionals[0]);
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (values.autonomy !== undefined) {
|
||||
if (!['manual', 'auto-easy', 'auto-approved'].includes(values.autonomy as string)) {
|
||||
fail('--autonomy 只能是 manual | auto-easy | auto-approved');
|
||||
}
|
||||
patch.autonomy = values.autonomy;
|
||||
}
|
||||
if (values.concurrency !== undefined) {
|
||||
const n = Number(values.concurrency);
|
||||
if (!Number.isInteger(n) || n < 1) fail('--concurrency 必须是 >=1 的整数');
|
||||
patch.concurrency = n;
|
||||
}
|
||||
if (values.status !== undefined) {
|
||||
if (!['active', 'paused'].includes(values.status as string)) fail('--status 只能是 active | paused');
|
||||
patch.status = values.status;
|
||||
}
|
||||
// --verify/--model/--logo 传空串 = 清空(null)
|
||||
if (values.verify !== undefined) patch.verifyCmd = values.verify === '' ? null : values.verify;
|
||||
if (values.model !== undefined) patch.model = values.model === '' ? null : values.model;
|
||||
if (values.logo !== undefined) patch.logo = values.logo === '' ? null : values.logo;
|
||||
if (Object.keys(patch).length === 0) fail('未指定任何要更新的字段。');
|
||||
|
||||
const p = await api<Project>('PATCH', `/api/projects/${project.id}`, patch);
|
||||
console.log(`已更新项目「${p.name}」(${p.id})`);
|
||||
console.log(` 自治: ${p.autonomy} · 并发: ${p.concurrency} · 状态: ${p.status} · 模型: ${p.model ?? '默认'} · verify: ${p.verifyCmd ?? '无'}`);
|
||||
}
|
||||
|
||||
// ---------- task ----------
|
||||
|
||||
function taskLine(t: Task): string {
|
||||
@@ -241,6 +299,33 @@ async function cmdTaskAdd(rest: string[]): Promise<void> {
|
||||
console.log(`已创建任务 ${taskLine(t)}`);
|
||||
}
|
||||
|
||||
async function cmdTaskPatch(rest: string[]): Promise<void> {
|
||||
const { values, positionals } = parseCmdArgs(rest, {
|
||||
title: { type: 'string' },
|
||||
priority: { type: 'string' },
|
||||
complexity: { type: 'string' },
|
||||
});
|
||||
const taskId = positionals[0];
|
||||
if (!taskId) fail('用法:maestro task patch <任务id> [--title T] [--priority 0|1|2] [--complexity hard|medium|easy]');
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (values.title !== undefined) {
|
||||
if (!(values.title as string).trim()) fail('--title 不能为空');
|
||||
patch.title = values.title;
|
||||
}
|
||||
if (values.priority !== undefined) {
|
||||
const n = Number(values.priority);
|
||||
if (![0, 1, 2].includes(n)) fail('--priority 必须是 0/1/2(P0 最高,P1 中,P2 最低)');
|
||||
patch.priority = n;
|
||||
}
|
||||
if (values.complexity !== undefined) {
|
||||
if (!isComplexity(values.complexity)) fail('--complexity 只能是 hard | medium | easy');
|
||||
patch.complexity = values.complexity;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) fail('未指定任何要更新的字段。');
|
||||
const t = await api<Task>('PATCH', `/api/tasks/${encodeURIComponent(taskId)}`, patch);
|
||||
console.log(`已更新任务 ${taskLine(t)}`);
|
||||
}
|
||||
|
||||
// ---------- next / approvals ----------
|
||||
|
||||
async function cmdNext(rest: string[]): Promise<void> {
|
||||
@@ -285,6 +370,106 @@ async function cmdApprovals(rest: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- decide ----------
|
||||
|
||||
async function cmdDecide(rest: string[]): Promise<void> {
|
||||
const { values, positionals } = parseCmdArgs(rest, {
|
||||
'no-merge': { type: 'boolean' },
|
||||
reason: { type: 'string' },
|
||||
});
|
||||
const taskId = positionals[0];
|
||||
const action = positionals[1];
|
||||
if (!taskId || (action !== 'accept' && action !== 'reject')) {
|
||||
fail('用法:maestro decide <任务id> accept|reject [--no-merge] [--reason 改进意见]');
|
||||
}
|
||||
const reason = values.reason as string | undefined;
|
||||
if (action === 'reject' && !reason?.trim()) fail('reject 必须填写 --reason(改进意见)。');
|
||||
// accept 默认通过并合并;--no-merge 仅通过不合并(仅 exec_review 生效)
|
||||
const merge = action === 'accept' ? !(values['no-merge'] as boolean | undefined) : undefined;
|
||||
const t = await api<Task>('POST', `/api/tasks/${encodeURIComponent(taskId)}/decide`, {
|
||||
action,
|
||||
merge,
|
||||
reason: reason ?? null,
|
||||
});
|
||||
const label = action === 'accept' ? (merge ? '已通过并合并' : '已通过(未合并)') : '已拒绝(退回返工)';
|
||||
console.log(`${label}:${taskLine(t)}`);
|
||||
}
|
||||
|
||||
// ---------- agents / usage / archive ----------
|
||||
|
||||
interface UsageWindow { percent: number; resetsAt: string | null }
|
||||
interface UsageInfo { session: UsageWindow | null; weekly: UsageWindow | null }
|
||||
interface AgentRow {
|
||||
projectId: string; projectName: string; autonomy: string; concurrency: number;
|
||||
status: string; scheduling: string; models: Record<string, string>;
|
||||
active: Array<{ runId: string; taskId: string; taskTitle: string; kind: string; startedAt: string }>;
|
||||
}
|
||||
interface AgentsResult { totalActive: number; agents: AgentRow[]; usage: UsageInfo | null }
|
||||
|
||||
function usageLine(u: UsageInfo | null): string {
|
||||
if (!u) return 'Claude 额度:查询不可用(未取到凭证或 API 失败)';
|
||||
const fmt = (w: UsageWindow | null): string => (w ? `${w.percent}%${w.resetsAt ? `(重置 ${w.resetsAt})` : ''}` : '—');
|
||||
return `Claude 额度 · 5h: ${fmt(u.session)} · 周: ${fmt(u.weekly)}`;
|
||||
}
|
||||
|
||||
async function cmdAgents(): Promise<void> {
|
||||
const r = await api<AgentsResult>('GET', '/api/agents');
|
||||
console.log(`在途运行 ${r.totalActive} 个 · ${usageLine(r.usage)}\n`);
|
||||
if (r.agents.length === 0) {
|
||||
console.log('暂无项目。');
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
['项目', '自治', '并发', '状态', '调度', '在途', '模型(easy/medium/hard)'],
|
||||
r.agents.map((a) => [
|
||||
a.projectName,
|
||||
a.autonomy,
|
||||
String(a.concurrency),
|
||||
a.status,
|
||||
a.scheduling,
|
||||
String(a.active.length),
|
||||
`${a.models.easy ?? '?'} / ${a.models.medium ?? '?'} / ${a.models.hard ?? '?'}`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function cmdUsage(): Promise<void> {
|
||||
const u = await api<UsageInfo | null>('GET', '/api/usage');
|
||||
console.log(usageLine(u));
|
||||
}
|
||||
|
||||
async function cmdArchive(rest: string[]): Promise<void> {
|
||||
const { values, positionals } = parseCmdArgs(rest, {
|
||||
page: { type: 'string' },
|
||||
size: { type: 'string' },
|
||||
});
|
||||
if (!positionals[0]) fail('用法:maestro archive <项目id或名称> [--page N] [--size N]');
|
||||
const project = await resolveProject(positionals[0]);
|
||||
const page = values.page === undefined ? 1 : Number(values.page);
|
||||
const size = values.size === undefined ? 20 : Number(values.size);
|
||||
if (!Number.isInteger(page) || page < 1) fail('--page 必须是 >=1 的整数');
|
||||
if (!Number.isInteger(size) || size < 1) fail('--size 必须是 >=1 的整数');
|
||||
|
||||
const tasks = await api<Task[]>('GET', `/api/projects/${project.id}/tasks`);
|
||||
const archived = tasks
|
||||
.filter((t) => t.status === 'done' || t.status === 'cancelled')
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
const total = archived.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
const start = (page - 1) * size;
|
||||
const slice = archived.slice(start, start + size);
|
||||
|
||||
console.log(`项目「${project.name}」归档任务 ${total} 条 · 第 ${page}/${totalPages} 页\n`);
|
||||
if (slice.length === 0) {
|
||||
console.log('(本页无归档任务)');
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
['状态', '复杂度', '标题', '任务id', '更新时间'],
|
||||
slice.map((t) => [STATUS_LABEL[t.status], COMPLEXITY_LABEL[t.complexity], t.title, t.id, t.updatedAt]),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- import-todo(同步引擎在 daemon 侧:src/sync/todo-sync.ts,经 POST /api/projects/:id/sync 调用) ----------
|
||||
|
||||
interface SyncResult {
|
||||
@@ -352,16 +537,22 @@ async function main(): Promise<void> {
|
||||
const sub = argv[1];
|
||||
if (sub === 'add') return cmdProjectAdd(argv.slice(2));
|
||||
if (sub === 'list') return cmdProjectList();
|
||||
if (sub === 'set') return cmdProjectSet(argv.slice(2));
|
||||
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));
|
||||
if (sub === 'patch') return cmdTaskPatch(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 === 'decide') return cmdDecide(argv.slice(1));
|
||||
if (cmd === 'agents') return cmdAgents();
|
||||
if (cmd === 'usage') return cmdUsage();
|
||||
if (cmd === 'archive') return cmdArchive(argv.slice(1));
|
||||
if (cmd === 'import-todo') return cmdImportTodo(argv.slice(1));
|
||||
|
||||
fail(`未知命令:${cmd}\n\n${USAGE}`);
|
||||
|
||||
Reference in New Issue
Block a user