fa472a06a7
后端: - src/sync/todo-sync.ts: todo.json 单向同步引擎(导入=首次同步,幂等,source_ref 映射, subs 复杂度修正为 easy,旧侧 done 历史事实优先 forceDone) - 依赖自动落位:ready 意图按 deps 落位 blocked,依赖全 done 自动放行, daemon 启动 reconcileDeps 对账,手动绕过会弹回 - 新 API: PATCH projects/:id(autonomy/concurrency)、POST :id/sync、GET /api/agents、 PATCH tasks/:id(title/priority/complexity 重置) - daemon 定时同步(MAESTRO_SYNC_INTERVAL 默认 300s) + project.synced 事件 - priority 语义翻转: P0 最高/P1 默认/P2 最低,取值限 0..2,排序/映射/MCP/CLI 全跟进 - 静态服务发 no-cache 头(修浏览器吃旧 CSS/JS) - schema 迁移: tasks.source_ref / projects.last_sync_at(ensureColumn 平滑升级旧库) 看板: - 全屏预览模式(94vh 读完整方案+就地裁决,Esc/遮罩/裁决自动关闭) - 产出 markdown 渲染为 HTML(零依赖渲染器,转义优先) - 任务树筛选(复杂度/状态分组/关键字)+ 顶栏徽章组(待审批/可执行/执行中,hover 展开) - 依赖可视化:详情 DEPS 区块 + 行内⛓等依赖 + 锚点跳转定位 - 按钮收敛:提交评审/编辑产出移除(CC 经 MCP 操作),界面只留用户动作 - Agent 执行面板 + 项目配置(并发/工作模式)+ 同步按钮 测试:21 个全过(新增 sync 幂等/迁移/patch/依赖落位/对账幂等) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
375 lines
14 KiB
JavaScript
375 lines
14 KiB
JavaScript
#!/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 0|1|2(P0 最高,默认 P1)]
|
||
* next <projectIdOrName>
|
||
* approvals [projectIdOrName]
|
||
* import-todo <repoPath> [--name N] (导入=首次同步,读 <repoPath>/todo/todo.json,幂等)
|
||
*
|
||
* 环境变量:MAESTRO_URL(默认 http://127.0.0.1:4517)
|
||
*/
|
||
import { parseArgs, type ParseArgsConfig } from 'node:util';
|
||
import { resolve, basename } from 'node:path';
|
||
import type { Project, Task } from '../model/types.js';
|
||
import { STATUS_LABEL } from '../model/status.js';
|
||
import { COMPLEXITY_LABEL, isComplexity } 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 0|1|2,P0 最高,默认 P1]
|
||
新建任务
|
||
maestro next <项目id或名称>
|
||
取下一个可执行任务
|
||
maestro approvals [项目id或名称]
|
||
列出待审批任务(不带参数 = 全部项目)
|
||
maestro import-todo <仓库路径> [--name 项目名]
|
||
导入/同步旧 todo skill 数据(读 <仓库路径>/todo/todo.json;项目不存在则建;幂等可重复)
|
||
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 => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt); // P0 最高在前
|
||
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 0|1|2(P0 最高,默认 P1)]');
|
||
}
|
||
if (!isComplexity(values.complexity)) fail('--complexity 必填,且只能是 hard | medium | easy');
|
||
const priority = values.priority === undefined ? undefined : Number(values.priority);
|
||
if (priority !== undefined && ![0, 1, 2].includes(priority)) fail('--priority 必须是 0/1/2(P0 最高,P1 中,P2 最低)');
|
||
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(同步引擎在 daemon 侧:src/sync/todo-sync.ts,经 POST /api/projects/:id/sync 调用) ----------
|
||
|
||
interface SyncResult {
|
||
created: number;
|
||
doneAdvanced: number;
|
||
skipped: number;
|
||
warnings: string[];
|
||
lastSyncAt: string;
|
||
}
|
||
|
||
/**
|
||
* 导入 = 首次同步:项目(按 repoPath 匹配)不存在则先创建,然后触发一次 sync。
|
||
* 数据固定读 <repoPath>/todo/todo.json;幂等,可重复执行做增量同步。
|
||
*/
|
||
async function cmdImportTodo(rest: string[]): Promise<void> {
|
||
const { values, positionals } = parseCmdArgs(rest, {
|
||
repo: { type: 'string' },
|
||
name: { type: 'string' },
|
||
});
|
||
// 兼容旧用法 `import-todo <todo.json 路径> --repo <仓库路径>`:--repo 优先;否则把位置参数当 repoPath
|
||
const repoArg = (values.repo as string | undefined) ?? positionals[0];
|
||
if (!repoArg) {
|
||
fail('用法:maestro import-todo <仓库路径> [--name 项目名](同步引擎固定读 <仓库路径>/todo/todo.json)');
|
||
}
|
||
const repoPath = resolve(repoArg);
|
||
if (values.repo && positionals[0]) {
|
||
const legacyFile = resolve(positionals[0]);
|
||
const expected = resolve(repoPath, 'todo', 'todo.json');
|
||
if (legacyFile !== expected) {
|
||
console.log(`提示:同步引擎固定读 ${expected},忽略指定的 ${legacyFile}`);
|
||
}
|
||
}
|
||
|
||
// 项目不存在则建(按 repoPath 匹配),存在则直接增量同步
|
||
const projects = await api<Project[]>('GET', '/api/projects');
|
||
let project = projects.find((p) => p.repoPath === repoPath);
|
||
if (project) {
|
||
console.log(`项目「${project.name}」(${project.id}) 已存在,执行增量同步…\n`);
|
||
} else {
|
||
const name = (values.name as string | undefined) ?? basename(repoPath);
|
||
project = await api<Project>('POST', '/api/projects', { name, repoPath });
|
||
console.log(`已创建项目「${project.name}」(${project.id}),开始首次同步…\n`);
|
||
}
|
||
|
||
const r = await api<SyncResult>('POST', `/api/projects/${project.id}/sync`);
|
||
console.log('同步完成:');
|
||
console.log(` 新建 ${r.created} 条 · 推到 done ${r.doneAdvanced} 条 · 跳过(已映射)${r.skipped} 条 · 警告 ${r.warnings.length} 条`);
|
||
for (const w of r.warnings) console.log(` ⚠ ${w}`);
|
||
console.log(` 本次同步时间:${r.lastSyncAt}`);
|
||
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);
|
||
});
|