feat: 同步引擎+Agent配置+依赖自动落位+看板大改(预览/md渲染/筛选/徽章组)
后端: - 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>
This commit is contained in:
+44
-281
@@ -6,19 +6,18 @@
|
||||
* 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]
|
||||
* task add <projectIdOrName> <title> --complexity hard|medium|easy [--parent ID] [--priority 0|1|2(P0 最高,默认 P1)]
|
||||
* next <projectIdOrName>
|
||||
* approvals [projectIdOrName]
|
||||
* import-todo <todo.json 路径> --repo <repoPath> [--name N]
|
||||
* 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 { 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';
|
||||
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';
|
||||
|
||||
@@ -31,14 +30,14 @@ const USAGE = `maestro — 多项目 TODO 管理与 Agent 执行系统 CLI
|
||||
列出所有项目
|
||||
maestro task list <项目id或名称>
|
||||
树形列出项目任务(项目名支持模糊匹配)
|
||||
maestro task add <项目id或名称> <标题> --complexity hard|medium|easy [--parent 父任务id] [--priority 优先级]
|
||||
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 <todo.json 路径> --repo <仓库路径> [--name 项目名]
|
||||
新建项目并导入旧 todo skill 的数据(tier 1/2/3 → hard/medium/easy)
|
||||
maestro import-todo <仓库路径> [--name 项目名]
|
||||
导入/同步旧 todo skill 数据(读 <仓库路径>/todo/todo.json;项目不存在则建;幂等可重复)
|
||||
maestro --help | help
|
||||
显示本说明
|
||||
|
||||
@@ -194,7 +193,7 @@ function printTaskTree(tasks: Task[]): void {
|
||||
list.push(t);
|
||||
byParent.set(key, list);
|
||||
}
|
||||
const sortFn = (a: Task, b: Task): number => b.priority - a.priority || a.createdAt.localeCompare(b.createdAt);
|
||||
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) {
|
||||
@@ -227,11 +226,11 @@ async function cmdTaskAdd(rest: string[]): Promise<void> {
|
||||
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]');
|
||||
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 && !Number.isFinite(priority)) fail('--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,
|
||||
@@ -286,291 +285,55 @@ async function cmdApprovals(rest: string[]): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- import-todo ----------
|
||||
// ---------- import-todo(同步引擎在 daemon 侧:src/sync/todo-sync.ts,经 POST /api/projects/:id/sync 调用) ----------
|
||||
|
||||
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;
|
||||
interface SyncResult {
|
||||
created: number;
|
||||
doneAdvanced: 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' });
|
||||
lastSyncAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把非 Hard 容器任务沿合法路径推到 done:
|
||||
* easy: ready → queued → executing → exec_review → accept(done)
|
||||
* medium: speccing → spec_review → accept(ready) → queued → executing → exec_review → accept(done)
|
||||
* 推不动时记 warning、保留现状,返回 false。
|
||||
* 导入 = 首次同步:项目(按 repoPath 匹配)不存在则先创建,然后触发一次 sync。
|
||||
* 数据固定读 <repoPath>/todo/todo.json;幂等,可重复执行做增量同步。
|
||||
*/
|
||||
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 项目名]');
|
||||
// 兼容旧用法 `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 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);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
// 项目不存在则建(按 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}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user