diff --git a/src/api/server.ts b/src/api/server.ts index 5288789..d2b2a75 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -132,6 +132,9 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return { totalActive: runs.length, agents, usage: await getUsage() }; }); + // Claude 订阅额度(5 小时 + 周窗口)单独透传;查询失败 → null(前端/CLI 降级显示) + app.get('/api/usage', async () => await getUsage()); + app.get('/api/projects/:id/tasks', (req) => { const { id } = req.params as { id: string }; return store.listTasks(id); diff --git a/src/cli/index.ts b/src/cli/index.ts index d72db86..83af9c8 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,10 +5,16 @@ * 子命令: * project add [--name N] [--branch B] [--verify CMD] [--concurrency N] * project list + * project set [--autonomy A] [--concurrency N] [--verify CMD] [--model M] [--logo L] [--status active|paused] * task list * task add --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}`); diff --git a/src/mcp/index.ts b/src/mcp/index.ts index d41cff1..b3bf58c 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -5,22 +5,36 @@ * 给任意 Claude Code 会话提供任务读写工具:全部经 REST 调本机 maestrod, * 自身不碰数据库。base URL 取环境变量 MAESTRO_URL(默认 http://127.0.0.1:4517)。 * - * 注意:审批(accept/reject)是用户在 Web 看板上的动作,本 server 只提供 - * 只读的 get_pending_approvals,不提供 decide 类工具。 + * 工具分两类: + * 读:list_projects / list_tasks / get_task / get_next_executable / get_pending_approvals + * / get_agents / get_usage / list_archived + * 写:create_project / patch_project / sync_project / create_task / decompose_task + * / write_plan / write_spec / write_operations / patch_task / update_status + * / decide / requeue_task + * + * 审批裁决(decide)是把关动作:accept/reject 走 store 守卫,reject 必带 reason; + * exec_review 的 accept 默认「通过并合并」(merge=false 则仅通过不合并)。 */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; +import { z, type ZodRawShape } from 'zod'; import { STATUS_LABEL, type TaskStatus } from '../model/status.js'; +import { buildDependentsIndex, scoreTask } from '../model/scoring.js'; +import type { Task } from '../model/types.js'; const BASE_URL = process.env.MAESTRO_URL ?? 'http://127.0.0.1:4517'; // ---------- REST 薄封装 ---------- /** 业务错误(daemon 返回 400 {error}),消息原样透传给调用方。 */ -class ApiError extends Error {} +export class ApiError extends Error {} -async function api<T = unknown>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> { +export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +/** 注入点:默认直连本机 daemon;测试可传入 mock 断言调用。 */ +export type ApiFn = <T = unknown>(method: HttpMethod, path: string, body?: unknown) => Promise<T>; + +const defaultApi: ApiFn = async <T = unknown>(method: HttpMethod, path: string, body?: unknown): Promise<T> => { let res: Response; try { res = await fetch(`${BASE_URL}${path}`, { @@ -41,11 +55,11 @@ async function api<T = unknown>(method: 'GET' | 'POST', path: string, body?: unk throw new ApiError(msg ?? `daemon 返回 HTTP ${res.status}: ${text}`); } return data as T; -} +}; // ---------- 工具结果辅助 ---------- -interface ToolResult { +export interface ToolResult { content: { type: 'text'; text: string }[]; isError?: boolean; [key: string]: unknown; @@ -84,217 +98,379 @@ const subtaskSchema = z.object({ priority: z.number().int().min(0).max(2).optional().describe('优先级 0/1/2:P0 最高、P1 中(默认)、P2 最低'), }); -// ---------- MCP server ---------- +/** 终态(归档区):done / cancelled */ +const TERMINAL_STATUSES: readonly TaskStatus[] = ['done', 'cancelled']; -const server = new McpServer({ name: 'maestro', version: '0.1.0' }); +// ---------- 工具定义(与 McpServer 解耦,便于单测) ---------- -server.registerTool( - 'list_projects', - { - title: '列出项目', - description: '列出 maestro 中注册的所有项目(id、名称、repo 路径、自治级别等)。', - inputSchema: {}, - }, - async () => run(() => api('GET', '/api/projects')), -); +export interface ToolDef { + name: string; + config: { title: string; description: string; inputSchema: ZodRawShape }; + handler: (args: Record<string, unknown>) => Promise<ToolResult>; +} -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-easy(Easy 自动执行)| auto-approved(过审即自动执行)'), - model: z.string().optional().describe('执行 agent 使用的模型'), - concurrency: z.number().int().min(1).optional().describe('worker 并发数(默认 1)'), +/** + * 构造全部工具定义。api 默认直连 daemon;单测可注入 mock 断言 method/path/body。 + */ +export function createTools(api: ApiFn = defaultApi): ToolDef[] { + return [ + // ---------- 读 ---------- + { + name: 'list_projects', + config: { + title: '列出项目', + description: '列出 maestro 中注册的所有项目(id、名称、repo 路径、自治级别、并发数等)。', + inputSchema: {}, + }, + handler: () => run(() => api('GET', '/api/projects')), }, - }, - async (args) => run(() => api('POST', '/api/projects', args)), -); - -server.registerTool( - 'list_tasks', - { - title: '列出任务', - description: '列出某项目的全部任务(含层级、复杂度、状态、依赖与审批历史)。', - inputSchema: { projectId: z.string().min(1).describe('项目 id(prj_ 开头)') }, - }, - 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('任务 id(tsk_ 开头)') }, - }, - 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().min(0).max(2).optional().describe('优先级 0/1/2:P0 最高、P1 中(默认)、P2 最低'), + { + name: 'list_tasks', + config: { + title: '列出任务', + description: '列出某项目的全部任务(含层级、复杂度、状态、依赖与审批历史)。', + inputSchema: { projectId: z.string().min(1).describe('项目 id(prj_ 开头)') }, + }, + handler: ({ projectId }) => + run(() => api('GET', `/api/projects/${encodeURIComponent(String(projectId))}/tasks`)), }, - }, - 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('子任务列表,各自带复杂度'), + { + name: 'get_task', + config: { + title: '查看任务详情', + description: + '取单个任务的完整详情:产出字段(plan/spec/operations)、执行结果 result(branch/diffSummary/commits/prUrl)、' + + '复审结论(result.summary + result.verdict = code review;result.securitySummary + result.securityVerdict = 安全审计;verdict/securityVerdict ∈ approve|reject|null)、' + + '审批历史 approvals、全部子任务 children,以及调度 score(=自身分 + 链条惯性Σ已完成依赖分 + 解锁加权Σ等它解锁的 blocked 任务分;P0=3/P1=2/P2=1)。' + + '为便于把关,summary/verdict/securitySummary/securityVerdict/score 已在返回对象的 review/score 字段中显式列出。', + inputSchema: { taskId: z.string().min(1).describe('任务 id(tsk_ 开头)') }, + }, + handler: ({ taskId }) => + run(async () => { + const tid = encodeURIComponent(String(taskId)); + const task = await api<Task>('GET', `/api/tasks/${tid}`); + const children = await api<Task[]>('GET', `/api/tasks/${tid}/children`); + // score 现算(与编排器同口径):失败不致命,置 null + let score: number | null = null; + try { + const all = await api<Task[]>('GET', `/api/projects/${encodeURIComponent(task.projectId)}/tasks`); + const byId = new Map(all.map((t) => [t.id, t])); + score = scoreTask(task, byId, buildDependentsIndex(all)); + } catch { score = null; } + const r = task.result; + return { + task, + children, + score, + review: { + summary: r?.summary ?? null, + verdict: r?.verdict ?? null, + securitySummary: r?.securitySummary ?? null, + securityVerdict: r?.securityVerdict ?? null, + }, + }; + }), }, - }, - 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)'), + { + name: 'get_next_executable', + config: { + title: '取下一个可执行任务', + description: + '取项目中下一个可执行任务(叶子、ready、依赖全部 done,按 score 降序:自身分 + 链条惯性 + 解锁加权);没有则返回 {"next": null}。', + inputSchema: { projectId: z.string().min(1).describe('项目 id') }, + }, + handler: ({ projectId }) => + run(() => api('GET', `/api/projects/${encodeURIComponent(String(projectId))}/next`)), }, - }, - 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)'), + { + name: 'get_pending_approvals', + config: { + title: '查看待审批任务', + description: + '列出处于审批闸(plan_review / spec_review / exec_review)的任务,可按项目过滤。' + + '裁决用 decide 工具(accept/reject,reject 必带 reason;exec_review 的 accept 默认通过并合并)。', + inputSchema: { projectId: z.string().min(1).optional().describe('项目 id(不填则跨全部项目)') }, + }, + handler: ({ projectId }) => + run(() => + api('GET', projectId ? `/api/approvals?projectId=${encodeURIComponent(String(projectId))}` : '/api/approvals'), + ), }, - }, - 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)'), + { + name: 'get_agents', + config: { + title: '查看各项目执行 agent 状态', + description: + '汇总每个项目的执行情况:自治级别 autonomy、并发上限 concurrency、调度模式 scheduling(score:自身分 + 链条惯性 + 解锁加权)、' + + '各复杂度实际使用的模型 models、在途运行 active(含 planner/executor/reviewer/security),并附 Claude 订阅额度 usage(查询失败为 null)。', + inputSchema: {}, + }, + handler: () => run(() => api('GET', '/api/agents')), }, - }, - 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('目标状态'), + { + name: 'get_usage', + config: { + title: '查看 Claude 订阅额度', + description: + '查询 Claude 订阅额度(执行 agent 烧的就是这个池子):session(5 小时滚动窗口)与 weekly(7 天窗口),各含 percent 已用百分比与 resetsAt 重置时刻。查询失败返回 null。', + inputSchema: {}, + }, + handler: () => run(() => api('GET', '/api/usage')), }, - }, - 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')), -); - -server.registerTool( - 'requeue_task', - { - title: '重投 needs_attention 任务', - description: - '将 needs_attention 状态的任务重置重试计数并重新入队(queued),让编排器下一轮自动领取执行。只适用于 needs_attention 状态,其他状态会返回错误。', - inputSchema: { - taskId: z.string().min(1).describe('任务 id(tsk_ 开头)'), + { + name: 'list_archived', + config: { + title: '列出归档任务(分页)', + description: + '分页列出某项目的归档任务(终态 done / cancelled),按更新时间倒序。默认每页 20 条、第 1 页。返回 { page, size, total, totalPages, tasks }。', + inputSchema: { + projectId: z.string().min(1).describe('项目 id'), + page: z.number().int().min(1).optional().describe('页码(从 1 起,默认 1)'), + size: z.number().int().min(1).max(100).optional().describe('每页条数(1-100,默认 20)'), + }, + }, + handler: ({ projectId, page, size }) => + run(async () => { + const tasks = await api<Task[]>('GET', `/api/projects/${encodeURIComponent(String(projectId))}/tasks`); + const archived = tasks + .filter((t) => TERMINAL_STATUSES.includes(t.status)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const pageSize = (size as number | undefined) ?? 20; + const pageNum = (page as number | undefined) ?? 1; + const total = archived.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const start = (pageNum - 1) * pageSize; + return { page: pageNum, size: pageSize, total, totalPages, tasks: archived.slice(start, start + pageSize) }; + }), }, - }, - async ({ taskId }) => - run(() => api('POST', `/api/tasks/${encodeURIComponent(taskId)}/requeue`, {})), -); + + // ---------- 写 ---------- + { + name: 'create_project', + config: { + 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-easy(Easy 自动执行)| auto-approved(过审即自动执行)'), + model: z.string().optional().describe('执行 agent 使用的模型'), + concurrency: z.number().int().min(1).optional().describe('worker 并发数(默认 1)'), + }, + }, + handler: (args) => run(() => api('POST', '/api/projects', args)), + }, + { + name: 'patch_project', + config: { + title: '更新项目配置', + description: + '部分更新项目配置(仅传需要改的字段,校验在 store.patchProject):' + + 'autonomy(manual|auto-easy|auto-approved)、concurrency(>=1 整数)、status(active|paused)、' + + 'verifyCmd(验证命令,传 null 清空)、model(执行模型,null 清空)、logo(URL/仓库内相对路径,null 清空)、' + + 'maxRetries(>=0 整数)、timeoutMs(>=1000 毫秒)。', + inputSchema: { + projectId: z.string().min(1).describe('项目 id'), + autonomy: z.enum(['manual', 'auto-easy', 'auto-approved']).optional().describe('自治级别'), + concurrency: z.number().int().min(1).optional().describe('worker 并发数(>=1)'), + status: z.enum(['active', 'paused']).optional().describe('项目状态'), + verifyCmd: z.string().nullable().optional().describe('验证命令(null 清空)'), + model: z.string().nullable().optional().describe('执行模型(null 清空)'), + logo: z.string().nullable().optional().describe('logo(null 清空)'), + maxRetries: z.number().int().min(0).optional().describe('最大自动重试次数(>=0)'), + timeoutMs: z.number().int().min(1000).optional().describe('单次执行超时毫秒(>=1000)'), + }, + }, + handler: ({ projectId, ...patch }) => + run(() => api('PATCH', `/api/projects/${encodeURIComponent(String(projectId))}`, patch)), + }, + { + name: 'sync_project', + config: { + title: '同步项目 todo.json', + description: + '触发一次单向同步:读 <repoPath>/todo/todo.json 增量同步到 maestro(幂等可重复)。返回 { created, doneAdvanced, skipped, warnings, lastSyncAt }。', + inputSchema: { projectId: z.string().min(1).describe('项目 id') }, + }, + handler: ({ projectId }) => + run(() => api('POST', `/api/projects/${encodeURIComponent(String(projectId))}/sync`)), + }, + { + name: 'create_task', + config: { + 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().min(0).max(2).optional().describe('优先级 0/1/2:P0 最高、P1 中(默认)、P2 最低'), + }, + }, + handler: ({ projectId, ...rest }) => + run(() => api('POST', `/api/projects/${encodeURIComponent(String(projectId))}/tasks`, rest)), + }, + { + name: 'decompose_task', + config: { + 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('子任务列表,各自带复杂度'), + }, + }, + handler: ({ taskId, subtasks }) => + run(async () => { + const parent = await api<{ projectId: string }>('GET', `/api/tasks/${encodeURIComponent(String(taskId))}`); + const results: unknown[] = []; + for (const st of subtasks as Array<Record<string, unknown>>) { + 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 }; + }), + }, + { + name: 'write_plan', + config: { + title: '写入分析拆解(plan)', + description: 'Hard 任务的产出:写入「分析 + 拆解说明」。写完用 update_status 把任务转到 plan_review 等用户审批。', + inputSchema: { + taskId: z.string().min(1).describe('任务 id'), + content: z.string().min(1).describe('plan 内容(Markdown)'), + }, + }, + handler: ({ taskId, content }) => + run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/plan`, { plan: content })), + }, + { + name: 'write_spec', + config: { + title: '写入方案(spec)', + description: 'Medium 任务的产出:写入「具体方案 = 改动内容 + 为什么这么做」。写完用 update_status 转到 spec_review 等用户审批。', + inputSchema: { + taskId: z.string().min(1).describe('任务 id'), + content: z.string().min(1).describe('spec 内容(Markdown)'), + }, + }, + handler: ({ taskId, content }) => + run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/spec`, { spec: content })), + }, + { + name: 'write_operations', + config: { + title: '写入操作记录(operations)', + description: 'Easy 任务的产出:写清「将执行的操作」。Easy 无前置审批闸,写完即可 update_status 到 ready。', + inputSchema: { + taskId: z.string().min(1).describe('任务 id'), + content: z.string().min(1).describe('operations 内容(Markdown)'), + }, + }, + handler: ({ taskId, content }) => + run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/operations`, { operations: content })), + }, + { + name: 'patch_task', + config: { + title: '更新任务字段', + description: + '部分更新任务(仅传需要改的字段,校验复用 store.patchTask):title、priority(0/1/2,P0 最高)、complexity(hard|medium|easy)。' + + 'complexity 仅在未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked)可改,改后状态重置为新复杂度的初始态。' + + '(依赖 deps 当前不支持热改:创建时固定以保证依赖图无环。)', + inputSchema: { + taskId: z.string().min(1).describe('任务 id'), + title: z.string().min(1).optional().describe('新标题'), + priority: z.number().int().min(0).max(2).optional().describe('优先级 0/1/2(P0 最高)'), + complexity: complexitySchema.optional(), + }, + }, + handler: ({ taskId, ...patch }) => + run(() => api('PATCH', `/api/tasks/${encodeURIComponent(String(taskId))}`, patch)), + }, + { + name: 'update_status', + config: { + title: '变更任务状态', + description: + `按状态机流转任务状态(合法流转见 daemon 守卫;非法流转会返回中文错误,原样透传)。可选值:${STATUSES.join(' | ')}。` + + `注意:审批闸(plan_review/spec_review/exec_review)的 accept/reject 请用 decide 工具(带 reason 与合并语义),不要用本工具绕过审批闸。`, + inputSchema: { + taskId: z.string().min(1).describe('任务 id'), + to: z.enum(STATUSES).describe('目标状态'), + }, + }, + handler: ({ taskId, to }) => + run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/transition`, { to })), + }, + { + name: 'decide', + config: { + title: '审批裁决(通过 / 拒绝)', + description: + '对处于审批闸(plan_review / spec_review / exec_review)的任务做裁决。' + + 'action=accept 通过:plan_review→decomposed、spec_review→ready、exec_review→done。' + + 'action=reject 退回返工:plan_review→analyzing、spec_review→speccing、exec_review→ready,必带 reason(改进意见),否则报错。' + + 'merge 仅对 exec_review 的 accept 生效:true(默认)= 通过并合并(把任务分支并入默认分支,闭环;冲突则建 P0 补救任务、原任务留在闸);' + + 'false = 仅通过不合并(逃生口:目标分支被工作区占用等导致自动合并不可用时用)。', + inputSchema: { + taskId: z.string().min(1).describe('任务 id'), + action: z.enum(['accept', 'reject']).describe('accept 通过 / reject 拒绝'), + merge: z.boolean().optional().describe('仅 exec_review accept 生效:true(默认)=通过并合并;false=仅通过不合并'), + reason: z.string().min(1).optional().describe('改进意见;reject 时必填'), + }, + }, + handler: ({ taskId, action, merge, reason }) => + run(() => + api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/decide`, { action, merge, reason }), + ), + }, + { + name: 'requeue_task', + config: { + title: '重投 needs_attention 任务', + description: + '将 needs_attention 状态的任务重置重试计数并重新入队(queued),让编排器下一轮自动领取执行。只适用于 needs_attention 状态,其他状态会返回错误。', + inputSchema: { taskId: z.string().min(1).describe('任务 id(tsk_ 开头)') }, + }, + handler: ({ taskId }) => + run(() => api('POST', `/api/tasks/${encodeURIComponent(String(taskId))}/requeue`, {})), + }, + ]; +} // ---------- 启动 ---------- async function main(): Promise<void> { + const server = new McpServer({ name: 'maestro', version: '0.1.0' }); + for (const t of createTools()) { + server.registerTool(t.name, t.config, t.handler as never); + } 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); -}); +// 仅作为入口脚本运行时启动(被测试 import 时不启动 stdio server) +const isEntry = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; +if (isEntry) { + main().catch((err) => { + console.error('maestro-mcp 启动失败:', err); + process.exit(1); + }); +} diff --git a/test/mcp.test.ts b/test/mcp.test.ts new file mode 100644 index 0000000..574aed6 --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,210 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { z } from 'zod'; +import { createTools, type ApiFn, type ToolDef, type HttpMethod } from '../src/mcp/index.js'; + +/** 记录每次 REST 调用的 mock;responder 决定返回值(默认 null)。 */ +function mockApi(responder?: (method: HttpMethod, path: string, body?: unknown) => unknown): { + api: ApiFn; + calls: Array<{ method: HttpMethod; path: string; body?: unknown }>; +} { + const calls: Array<{ method: HttpMethod; path: string; body?: unknown }> = []; + const api: ApiFn = (async (method: HttpMethod, path: string, body?: unknown) => { + calls.push({ method, path, body }); + return responder ? responder(method, path, body) : null; + }) as ApiFn; + return { api, calls }; +} + +function tool(tools: ToolDef[], name: string): ToolDef { + const t = tools.find((x) => x.name === name); + assert.ok(t, `工具 ${name} 应存在`); + return t!; +} + +/** 用工具自带的 zod inputSchema 校验参数(同 McpServer 注册时的校验口径) */ +function parseArgs(t: ToolDef, args: unknown): Record<string, unknown> { + return z.object(t.config.inputSchema).parse(args) as Record<string, unknown>; +} + +// ---------- 工具齐全性 ---------- + +test('createTools:读写工具齐全', () => { + const names = createTools(mockApi().api).map((t) => t.name); + for (const expected of [ + // 读 + 'list_projects', 'list_tasks', 'get_task', 'get_next_executable', 'get_pending_approvals', + 'get_agents', 'get_usage', 'list_archived', + // 写 + 'create_project', 'patch_project', 'sync_project', 'create_task', 'decompose_task', + 'write_plan', 'write_spec', 'write_operations', 'patch_task', 'update_status', + 'decide', 'requeue_task', + ]) { + assert.ok(names.includes(expected), `应注册工具 ${expected}`); + } +}); + +// ---------- zod schema 校验(含 .min() 在 .optional() 之前的边界) ---------- + +test('patch_task schema:priority 0..2 边界、complexity 枚举、可选字段', () => { + const t = tool(createTools(mockApi().api), 'patch_task'); + // 合法 + assert.deepEqual(parseArgs(t, { taskId: 'tsk_1', priority: 0 }), { taskId: 'tsk_1', priority: 0 }); + assert.deepEqual(parseArgs(t, { taskId: 'tsk_1', complexity: 'hard' }), { taskId: 'tsk_1', complexity: 'hard' }); + // priority 越界 / 非整数被拒 + assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: 3 })); + assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: -1 })); + assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: 1.5 })); + // 非法 complexity + assert.throws(() => parseArgs(t, { taskId: 'tsk_1', complexity: 'trivial' })); + // 空 taskId 被拒(min(1)) + assert.throws(() => parseArgs(t, { taskId: '' })); + // title min(1) 在 optional 之前:传则不能空,省略可以 + assert.throws(() => parseArgs(t, { taskId: 'tsk_1', title: '' })); + assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1' })); +}); + +test('decide schema:action 枚举、reason/merge 可选', () => { + const t = tool(createTools(mockApi().api), 'decide'); + assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'accept' })); + assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'reject', reason: '请补测试' })); + assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'accept', merge: false })); + assert.throws(() => parseArgs(t, { taskId: 'tsk_1', action: 'approve' })); + // reason 给了就不能空串(min(1) 在 optional 之前) + assert.throws(() => parseArgs(t, { taskId: 'tsk_1', action: 'reject', reason: '' })); +}); + +test('patch_project schema:可空字段 + 枚举校验', () => { + const t = tool(createTools(mockApi().api), 'patch_project'); + assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', verifyCmd: null, model: null, logo: null })); + assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', autonomy: 'auto-easy', concurrency: 2, status: 'paused' })); + assert.throws(() => parseArgs(t, { projectId: 'prj_1', autonomy: 'yolo' })); + assert.throws(() => parseArgs(t, { projectId: 'prj_1', concurrency: 0 })); + assert.throws(() => parseArgs(t, { projectId: 'prj_1', status: 'stopped' })); +}); + +test('list_archived schema:page/size 边界', () => { + const t = tool(createTools(mockApi().api), 'list_archived'); + assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1' })); + assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', page: 2, size: 50 })); + assert.throws(() => parseArgs(t, { projectId: 'prj_1', page: 0 })); + assert.throws(() => parseArgs(t, { projectId: 'prj_1', size: 0 })); + assert.throws(() => parseArgs(t, { projectId: 'prj_1', size: 101 })); +}); + +// ---------- 对 mock 的调用断言(method / path / body) ---------- + +test('patch_task → PATCH /api/tasks/:id,body 仅含 patch 字段', async () => { + const { api, calls } = mockApi(); + const t = tool(createTools(api), 'patch_task'); + await t.handler({ taskId: 'tsk_abc', priority: 0, complexity: 'easy' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'PATCH'); + assert.equal(calls[0].path, '/api/tasks/tsk_abc'); + assert.deepEqual(calls[0].body, { priority: 0, complexity: 'easy' }); +}); + +test('patch_project → PATCH /api/projects/:id', async () => { + const { api, calls } = mockApi(); + const t = tool(createTools(api), 'patch_project'); + await t.handler({ projectId: 'prj_x', autonomy: 'auto-approved', verifyCmd: null }); + assert.equal(calls[0].method, 'PATCH'); + assert.equal(calls[0].path, '/api/projects/prj_x'); + assert.deepEqual(calls[0].body, { autonomy: 'auto-approved', verifyCmd: null }); +}); + +test('sync_project → POST /api/projects/:id/sync', async () => { + const { api, calls } = mockApi(); + const t = tool(createTools(api), 'sync_project'); + await t.handler({ projectId: 'prj_y' }); + assert.deepEqual({ method: calls[0].method, path: calls[0].path }, { method: 'POST', path: '/api/projects/prj_y/sync' }); +}); + +test('decide → POST /api/tasks/:id/decide,透传 action/merge/reason', async () => { + const { api, calls } = mockApi(); + const t = tool(createTools(api), 'decide'); + await t.handler({ taskId: 'tsk_z', action: 'accept', merge: false }); + assert.equal(calls[0].method, 'POST'); + assert.equal(calls[0].path, '/api/tasks/tsk_z/decide'); + assert.deepEqual(calls[0].body, { action: 'accept', merge: false, reason: undefined }); +}); + +test('update_status → POST /api/tasks/:id/transition', async () => { + const { api, calls } = mockApi(); + const t = tool(createTools(api), 'update_status'); + await t.handler({ taskId: 'tsk_t', to: 'queued' }); + assert.equal(calls[0].path, '/api/tasks/tsk_t/transition'); + assert.deepEqual(calls[0].body, { to: 'queued' }); +}); + +test('get_agents / get_usage → 对应只读端点', async () => { + const { api, calls } = mockApi(); + const tools = createTools(api); + await tool(tools, 'get_agents').handler({}); + await tool(tools, 'get_usage').handler({}); + assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ['GET /api/agents', 'GET /api/usage']); +}); + +test('get_task:返回 score 与 review 字段(summary/verdict/securityVerdict)', async () => { + const taskObj = { + id: 'tsk_1', projectId: 'prj_1', parentId: null, depth: 1, title: 'T', + complexity: 'easy', status: 'exec_review', priority: 0, deps: [], + plan: null, spec: null, operations: null, approvals: [], assignee: null, + retryBaseline: 0, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', + result: { + branch: 'b', worktree: null, diffSummary: null, commits: [], prUrl: null, + summary: 'looks good', verdict: 'approve', securitySummary: 'no issues', + securityVerdict: 'approve', mergeTaskId: null, + }, + }; + const { api } = mockApi((method, path) => { + if (path === '/api/tasks/tsk_1') return taskObj; + if (path === '/api/tasks/tsk_1/children') return []; + if (path === '/api/projects/prj_1/tasks') return [taskObj]; + return null; + }); + const t = tool(createTools(api), 'get_task'); + const res = await t.handler({ taskId: 'tsk_1' }); + const payload = JSON.parse(res.content[0].text) as { + score: number; review: { summary: string; verdict: string; securityVerdict: string }; + }; + // P0 自身分=3,无依赖/解锁 → score=3 + assert.equal(payload.score, 3); + assert.equal(payload.review.summary, 'looks good'); + assert.equal(payload.review.verdict, 'approve'); + assert.equal(payload.review.securityVerdict, 'approve'); +}); + +test('list_archived:仅终态 + 分页 + 按更新时间倒序', async () => { + const mk = (id: string, status: string, updatedAt: string): Record<string, unknown> => ({ + id, projectId: 'prj_1', parentId: null, depth: 1, title: id, complexity: 'easy', + status, priority: 1, deps: [], plan: null, spec: null, operations: null, approvals: [], + result: null, assignee: null, retryBaseline: 0, createdAt: updatedAt, updatedAt, + }); + const tasks = [ + mk('t_done1', 'done', '2026-01-01T00:00:00Z'), + mk('t_active', 'executing', '2026-01-05T00:00:00Z'), + mk('t_cancel', 'cancelled', '2026-01-03T00:00:00Z'), + mk('t_done2', 'done', '2026-01-02T00:00:00Z'), + ]; + const { api } = mockApi((_m, path) => (path === '/api/projects/prj_1/tasks' ? tasks : null)); + const t = tool(createTools(api), 'list_archived'); + + const res = await t.handler({ projectId: 'prj_1', page: 1, size: 2 }); + const out = JSON.parse(res.content[0].text) as { total: number; totalPages: number; tasks: Array<{ id: string }> }; + assert.equal(out.total, 3); // 仅 done/cancelled + assert.equal(out.totalPages, 2); + assert.deepEqual(out.tasks.map((x) => x.id), ['t_cancel', 't_done2']); // 倒序:03 > 02 > 01 + + const res2 = await t.handler({ projectId: 'prj_1', page: 2, size: 2 }); + const out2 = JSON.parse(res2.content[0].text) as { tasks: Array<{ id: string }> }; + assert.deepEqual(out2.tasks.map((x) => x.id), ['t_done1']); +}); + +test('错误透传:ApiError 转为 isError 工具结果', async () => { + const { api } = mockApi(() => { throw new Error('非法状态流转'); }); + const t = tool(createTools(api), 'update_status'); + const res = await t.handler({ taskId: 'tsk_1', to: 'done' }); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /非法状态流转/); +});