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
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"maestro": {
"command": "node",
"args": ["/绝对路径/maestro/dist/mcp/index.js"],
"env": {
"MAESTRO_URL": "http://127.0.0.1:4517"
}
}
}
}
+19
View File
@@ -27,3 +27,22 @@ npm run dev # 启动 daemon(开发)
```
> 依赖版本为初始值;MCP SDK 与 Claude Agent SDK 在 Phase 2 接入时按官方文档核定后再加。
## 接入 Claude CodeMCP
`npm run build`,并保证 maestrod 在跑(`npm run dev`,默认 http://127.0.0.1:4517)。两种接法:
```bash
# 方式一:claude mcp add(在要接入的项目里执行)
claude mcp add maestro -e MAESTRO_URL=http://127.0.0.1:4517 -- node /绝对路径/maestro/dist/mcp/index.js
```
```jsonc
// 方式二:项目根放 .mcp.json(模板见本仓库 .mcp.json.example
{ "mcpServers": { "maestro": {
"command": "node",
"args": ["/绝对路径/maestro/dist/mcp/index.js"],
"env": { "MAESTRO_URL": "http://127.0.0.1:4517" } } } }
```
工具:`list_projects` / `create_project` / `list_tasks` / `get_task` / `create_task` / `decompose_task` / `write_plan` / `write_spec` / `write_operations` / `update_status` / `get_next_executable` / `get_pending_approvals`(只读;accept/reject 在 Web 看板做)。
+1027
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -4,8 +4,13 @@
"private": true,
"type": "module",
"description": "多项目 TODO 管理与 Agent 执行系统(本地优先 daemon",
"engines": { "node": ">=20" },
"bin": { "maestro": "dist/cli/index.js" },
"engines": {
"node": ">=20"
},
"bin": {
"maestro": "dist/cli/index.js",
"maestro-mcp": "dist/mcp/index.js"
},
"scripts": {
"build": "tsc -p tsconfig.json && cp src/store/schema.sql dist/store/schema.sql",
"typecheck": "tsc -p tsconfig.json --noEmit",
@@ -13,16 +18,17 @@
"test": "tsx --test test/*.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"better-sqlite3": "^11.3.0",
"fastify": "^4.28.1",
"ws": "^8.18.0",
"nanoid": "^5.0.7"
"nanoid": "^5.0.7",
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^5.5.4",
"tsx": "^4.16.5",
"@types/node": "^20.14.0",
"@types/better-sqlite3": "^7.6.11",
"@types/ws": "^8.5.12"
"@types/node": "^20.14.0",
"@types/ws": "^8.5.12",
"tsx": "^4.16.5",
"typescript": "^5.5.4"
}
}
+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);
});
+615
View File
@@ -0,0 +1,615 @@
/* ════════════════════════════════════════════════════════════
MAESTRO 调度台 · 前端逻辑(无框架,直连 daemon REST + WS
════════════════════════════════════════════════════════════ */
'use strict';
// ── 模型常量(与 src/model/status.ts / complexity.ts 对齐) ──
const STATUS_LABEL = {
init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解',
decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案',
ready: '可执行', blocked: '被依赖阻塞', queued: '排队中',
executing: '执行中', exec_review: '待审/合', failed: '失败',
needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消',
};
const STATUS_GROUP = {
init: 'idle', analyzing: 'work', speccing: 'work',
plan_review: 'gate', spec_review: 'gate', exec_review: 'gate',
decomposed: 'container', ready: 'go', queued: 'go', executing: 'run',
blocked: 'hold', paused: 'hold', failed: 'bad', needs_attention: 'bad',
done: 'done', cancelled: 'dead',
};
const GATE_OF = { plan_review: 'plan', spec_review: 'spec', exec_review: 'exec' };
const GATE_LABEL = { plan: '拆解评审', spec: '方案评审', exec: '结果评审' };
const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' };
const EVENT_LABEL = {
'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更',
'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回',
'run.started': '运行开始', 'run.finished': '运行结束',
};
const EVENT_CLASS = {
'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status',
'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve',
'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run',
};
// ── 全局状态 ──
const S = {
projects: [],
currentProjectId: null,
tasks: [],
approvals: [],
events: [], // 新→旧
collapsed: new Set(),
expanded: new Set(),
rejectOpen: new Set(),
};
const $ = (sel) => document.querySelector(sel);
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}
function fmtTime(iso) {
try { return new Date(iso).toLocaleTimeString('zh-CN', { hour12: false }); }
catch { return iso; }
}
// ── API ──
async function api(path, opts = {}) {
let res;
try {
res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
} catch (e) {
throw new Error('无法连接 daemon' + e.message);
}
let data = null;
try { data = await res.json(); } catch { /* 非 JSON 响应 */ }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
// ── Toast ──
function toast(msg, kind = 'err') {
const el = document.createElement('div');
el.className = `toast ${kind}`;
el.textContent = msg;
$('#toastRoot').appendChild(el);
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 350); }, 4200);
}
// ── 数据加载 ──
async function loadProjects() {
S.projects = await api('/api/projects');
if (!S.currentProjectId && S.projects.length) S.currentProjectId = S.projects[0].id;
if (S.currentProjectId && !S.projects.some((p) => p.id === S.currentProjectId)) {
S.currentProjectId = S.projects.length ? S.projects[0].id : null;
}
}
async function loadProjectData() {
const pid = S.currentProjectId;
if (!pid) { S.tasks = []; S.events = []; S.approvals = []; return; }
const [tasks, events, approvals] = await Promise.all([
api(`/api/projects/${pid}/tasks`),
api(`/api/projects/${pid}/events`),
api(`/api/approvals?projectId=${encodeURIComponent(pid)}`),
]);
S.tasks = tasks;
S.events = events.slice().reverse(); // 接口升序 → 展示新在前
S.approvals = approvals;
}
async function refresh() {
try {
await loadProjectData();
renderAll();
} catch (e) { toast(e.message); }
}
async function fullRefresh() {
try {
await loadProjects();
await loadProjectData();
renderAll();
} catch (e) { toast(e.message); }
}
// ── 渲染:草稿保护(重绘前保存输入框内容与焦点) ──
function snapshotDrafts() {
const map = new Map();
document.querySelectorAll('textarea[id], input[id][type=text]').forEach((el) => {
if (el.value) map.set(el.id, el.value);
});
const focusId = document.activeElement && document.activeElement.id;
return { map, focusId };
}
function restoreDrafts(snap) {
snap.map.forEach((v, id) => {
const el = document.getElementById(id);
if (el && !el.value) el.value = v;
});
if (snap.focusId) {
const el = document.getElementById(snap.focusId);
if (el) { el.focus(); try { el.selectionStart = el.value.length; } catch { /* 非文本 */ } }
}
}
function renderAll() {
const snap = snapshotDrafts();
renderSidebar();
renderTopbar();
renderGates();
renderTree();
renderEvents();
renderParentOptions();
restoreDrafts(snap);
}
// ── 渲染:侧栏 ──
function renderSidebar() {
const ul = $('#projectList');
if (!S.projects.length) {
ul.innerHTML = `<li class="p-empty">暂无项目<br>点「+ 新建」登记第一个仓库</li>`;
return;
}
ul.innerHTML = S.projects.map((p) => `
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}">
<span class="p-name">${esc(p.name)}</span>
<span class="p-path">${esc(p.repoPath)}</span>
</li>`).join('');
}
function renderTopbar() {
const p = S.projects.find((x) => x.id === S.currentProjectId);
$('#projTitle').textContent = p ? p.name : '未选择项目';
$('#projMeta').textContent = p
? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}`
: '';
const gc = $('#gateCount');
if (S.approvals.length) {
gc.hidden = false;
gc.textContent = `${S.approvals.length} 项待审批`;
} else gc.hidden = true;
}
// ── 渲染:审批闸 ──
function gateDocs(t) {
const gate = GATE_OF[t.status];
const blocks = [];
const doc = (label, text) => blocks.push(
`<div class="gate-doc-label">${label}</div>` +
(text ? `<pre class="doc">${esc(text)}</pre>` : `<pre class="doc empty">(未填写)</pre>`));
if (gate === 'plan') doc('PLAN · 分析与拆解', t.plan);
if (gate === 'spec') doc('SPEC · 改动方案', t.spec);
if (gate === 'exec') {
if (t.result) {
const r = t.result;
blocks.push(`<div class="gate-doc-label">RESULT · 执行结果</div>
<dl class="result-kv">
${r.branch ? `<dt>分支</dt><dd>${esc(r.branch)}</dd>` : ''}
${r.worktree ? `<dt>worktree</dt><dd>${esc(r.worktree)}</dd>` : ''}
${r.prUrl ? `<dt>PR</dt><dd><a href="${esc(r.prUrl)}" target="_blank">${esc(r.prUrl)}</a></dd>` : ''}
</dl>`);
if (r.diffSummary) blocks.push(`<div class="gate-doc-label">DIFF 摘要</div><pre class="doc">${esc(r.diffSummary)}</pre>`);
} else {
blocks.push(`<pre class="doc empty">(无执行结果记录)</pre>`);
}
if (t.operations) doc('OPERATIONS · 执行的操作', t.operations);
else if (t.spec) doc('SPEC · 改动方案', t.spec);
}
return blocks.join('');
}
function renderGates() {
const sec = $('#gateSection');
if (!S.approvals.length) { sec.hidden = true; return; }
sec.hidden = false;
$('#gateList').innerHTML = S.approvals.map((t) => {
const gate = GATE_OF[t.status];
const rejOpen = S.rejectOpen.has(t.id);
return `
<div class="gate-card">
<div class="gate-card-head">
<span class="gate-kind">${GATE_LABEL[gate]}</span>
<span class="gate-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
</div>
<div class="gate-body">${gateDocs(t)}</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">✓ 接受</button>
<button class="btn btn-reject" data-action="gate-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
${gate === 'exec' ? `<span class="proj-meta">接受 = 认可改动并标记完成(合并不自动执行)</span>` : ''}
</div>
${rejOpen ? `
<div class="reject-form">
<textarea id="rej-${esc(t.id)}" placeholder="驳回意见(必填)——说明问题与改进方向"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(t.id)}">确认驳回</button>
<button class="btn" data-action="gate-reject-toggle" data-id="${esc(t.id)}">收起</button>
</div>` : ''}
</div>`;
}).join('');
}
// ── 渲染:任务树 ──
function cplxBadge(c) {
return `<span class="cplx cplx-${esc(c)}">${CPLX_LABEL[c] || esc(c)}</span>`;
}
function statusChip(st) {
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
}
function childrenMap() {
const m = new Map();
for (const t of S.tasks) {
const key = t.parentId || '__root__';
if (!m.has(key)) m.set(key, []);
m.get(key).push(t);
}
return m;
}
function renderTree() {
const root = $('#taskTree');
if (!S.currentProjectId) {
root.innerHTML = `<div class="tree-empty"><b>NO PROJECT</b>先在左侧新建或选择一个项目</div>`;
return;
}
if (!S.tasks.length) {
root.innerHTML = `<div class="tree-empty"><b>EMPTY</b>还没有任务 —— 点上方「+ 新建任务」开始</div>`;
return;
}
const m = childrenMap();
const renderNode = (t) => {
const kids = m.get(t.id) || [];
const collapsed = S.collapsed.has(t.id);
const expanded = S.expanded.has(t.id);
const isGate = !!GATE_OF[t.status];
const caret = kids.length
? `<span class="caret ${collapsed ? '' : 'open'}" data-action="toggle-collapse" data-id="${esc(t.id)}">▶</span>`
: `<span class="caret leaf">·</span>`;
return `
<div class="task-node depth-${t.depth}">
<div class="task-row ${isGate ? 'is-gate' : ''} ${expanded ? 'expanded' : ''}" data-action="toggle-detail" data-id="${esc(t.id)}">
${caret}
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(t.id.slice(-6))}</span></span>
<span class="spacer"></span>
<span class="t-prio ${t.priority > 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
</div>
${expanded ? renderDetail(t) : ''}
${kids.length && !collapsed ? `<div class="task-children">${kids.map(renderNode).join('')}</div>` : ''}
</div>`;
};
root.innerHTML = (m.get('__root__') || []).map(renderNode).join('');
}
// ── 渲染:任务详情 ──
function writableField(t) {
if (t.status === 'analyzing') return { field: 'plan', label: '分析与拆解(plan', next: 'plan_review' };
if (t.status === 'speccing') return { field: 'spec', label: '改动方案(spec', next: 'spec_review' };
if (t.status === 'ready') return { field: 'operations', label: '将执行的操作(operations', next: null };
return null;
}
function renderDetail(t) {
const parts = [];
// 已有产出
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']];
for (const [f, label] of docs) {
if (t[f]) parts.push(`<div><div class="detail-label">${label}</div><pre class="doc">${esc(t[f])}</pre></div>`);
}
// 写产出入口
const w = writableField(t);
if (w) {
parts.push(`
<div class="write-box">
<div class="detail-label">填写 ${w.label}</div>
<textarea id="out-${esc(t.id)}" placeholder="多行文本…">${esc(t[w.field] || '')}</textarea>
<div class="form-actions form-row">
<button class="btn" data-action="save-output" data-id="${esc(t.id)}" data-field="${w.field}">保存${w.field}</button>
${w.next ? `<button class="btn btn-solid" data-action="submit-review" data-id="${esc(t.id)}" data-to="${w.next}">提交评审 →</button>` : ''}
</div>
</div>`);
}
// result
if (t.result) {
const r = t.result;
parts.push(`
<div><div class="detail-label">RESULT · 执行结果</div>
<dl class="result-kv">
<dt>分支</dt><dd>${esc(r.branch || '—')}</dd>
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
${r.prUrl ? `<dt>PR</dt><dd>${esc(r.prUrl)}</dd>` : ''}
${r.commits && r.commits.length ? `<dt>commits</dt><dd>${r.commits.map(esc).join('<br>')}</dd>` : ''}
</dl>
${r.diffSummary ? `<pre class="doc" style="margin-top:6px">${esc(r.diffSummary)}</pre>` : ''}
</div>`);
}
// 审批历史
if (t.approvals && t.approvals.length) {
parts.push(`
<div><div class="detail-label">审批历史</div>
${t.approvals.map((a) => `
<div class="approval-item">
<span class="ap-act-${a.action}">${a.action === 'accept' ? '✓ 通过' : '✗ 驳回'}</span>
<span class="ap-gate">${GATE_LABEL[a.gate] || esc(a.gate)} · ${esc(a.actor)}</span>
<span class="ap-time">${fmtTime(a.at)}</span>
${a.reason ? `<span class="ap-reason">${esc(a.reason)}</span>` : ''}
</div>`).join('')}
</div>`);
}
if (!parts.length) parts.push(`<div class="proj-meta">暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}</div>`);
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}${t.deps && t.deps.length ? ' · 依赖 ' + t.deps.map(esc).join(', ') : ''}</div>`);
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
// ── 渲染:事件流 ──
function eventDetail(e) {
const t = S.tasks.find((x) => x.id === e.taskId);
const name = t ? t.title : (e.taskId ? e.taskId.slice(-6) : '');
const p = e.payload || {};
if (e.type === 'status.changed') {
return `${name}${STATUS_LABEL[p.from] || p.from}${STATUS_LABEL[p.to] || p.to}`;
}
if (e.type === 'approval.granted' || e.type === 'approval.rejected') {
const r = p.reason ? `${p.reason}` : '';
return `${name} · ${GATE_LABEL[p.gate] || p.gate}${r}`;
}
if (e.type === 'task.created') {
if (p.kind === 'project') return `项目「${p.name || ''}」已登记`;
return `${p.title || name}${CPLX_LABEL[p.complexity] || ''}`;
}
if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`;
if (e.type === 'run.started' || e.type === 'run.finished') {
return `${name} · ${p.kind || ''} ${p.status || ''}`;
}
return name;
}
function renderEvents() {
const ul = $('#eventList');
if (!S.events.length) {
ul.innerHTML = `<li class="ev-empty">暂无事件</li>`;
return;
}
ul.innerHTML = S.events.slice(0, 80).map((e) => `
<li class="${EVENT_CLASS[e.type] || ''}">
<div class="ev-head">
<span class="ev-type">${EVENT_LABEL[e.type] || esc(e.type)}</span>
<span class="ev-time">${fmtTime(e.at)}</span>
</div>
<div class="ev-detail">${eventDetail(e)}</div>
</li>`).join('');
}
// ── 渲染:父任务下拉 ──
function renderParentOptions() {
const sel = document.querySelector('#newTaskPanel select[name=parentId]');
const cur = sel.value;
sel.innerHTML = `<option value="">(顶层)</option>` +
S.tasks.map((t) => `<option value="${esc(t.id)}">${esc('· '.repeat(t.depth - 1) + t.title)}</option>`).join('');
sel.value = cur;
}
// ── 动作 ──
async function act(fn, okMsg) {
try {
await fn();
if (okMsg) toast(okMsg, 'ok');
await refresh();
} catch (e) { toast(e.message); }
}
document.addEventListener('click', (ev) => {
const el = ev.target.closest('[data-action]');
if (!el) return;
const action = el.dataset.action;
const id = el.dataset.id;
switch (action) {
case 'select-project':
if (S.currentProjectId !== id) {
S.currentProjectId = id;
S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear();
refresh();
}
break;
case 'open-new-project':
$('#modalRoot').hidden = false;
document.querySelector('#newProjectForm input[name=name]').focus();
break;
case 'close-modal':
$('#modalRoot').hidden = true;
break;
case 'toggle-new-task': {
const p = $('#newTaskPanel');
p.hidden = !p.hidden;
if (!p.hidden) p.querySelector('input[name=title]').focus();
break;
}
case 'toggle-collapse':
ev.stopPropagation();
S.collapsed.has(id) ? S.collapsed.delete(id) : S.collapsed.add(id);
renderTree();
break;
case 'toggle-detail': {
// 点的是 caret 时由上面分支处理(stopPropagation);输入区内点击不折叠
if (ev.target.closest('.task-detail')) break;
S.expanded.has(id) ? S.expanded.delete(id) : S.expanded.add(id);
renderTree();
break;
}
case 'gate-accept':
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'accept' }),
}), '已接受');
break;
case 'gate-reject-toggle':
S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id);
renderGates();
if (S.rejectOpen.has(id)) {
const ta = document.getElementById(`rej-${id}`);
if (ta) ta.focus();
}
break;
case 'gate-reject-confirm': {
const ta = document.getElementById(`rej-${id}`);
const reason = ta ? ta.value.trim() : '';
if (!reason) { toast('驳回意见不能为空'); if (ta) ta.focus(); return; }
S.rejectOpen.delete(id);
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'reject', reason }),
}), '已驳回,任务退回返工');
break;
}
case 'save-output': {
const field = el.dataset.field;
const ta = document.getElementById(`out-${id}`);
const value = ta ? ta.value.trim() : '';
if (!value) { toast(`${field} 内容不能为空`); return; }
act(() => api(`/api/tasks/${id}/${field}`, {
method: 'POST', body: JSON.stringify({ [field]: value }),
}), `${field} 已保存`);
break;
}
case 'submit-review': {
const to = el.dataset.to;
const ta = document.getElementById(`out-${id}`);
const task = S.tasks.find((t) => t.id === id);
const field = task && writableField(task) ? writableField(task).field : null;
const value = ta ? ta.value.trim() : '';
act(async () => {
// 先保存当前草稿(有内容才保存),再流转进评审闸
if (field && value) {
await api(`/api/tasks/${id}/${field}`, {
method: 'POST', body: JSON.stringify({ [field]: value }),
});
}
await api(`/api/tasks/${id}/transition`, {
method: 'POST', body: JSON.stringify({ to }),
});
}, '已提交评审');
break;
}
}
});
// 阻止详情区域内点击冒泡触发折叠
document.addEventListener('click', (ev) => {
if (ev.target.closest('.task-detail') && !ev.target.closest('[data-action]')) {
ev.stopPropagation();
}
}, true);
// ── 表单提交 ──
$('#newProjectForm').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
const name = f.name.value.trim();
const repoPath = f.repoPath.value.trim();
if (!name || !repoPath) { toast('名称与仓库路径必填'); return; }
const body = { name, repoPath };
if (f.defaultBranch.value.trim()) body.defaultBranch = f.defaultBranch.value.trim();
if (f.verifyCmd.value.trim()) body.verifyCmd = f.verifyCmd.value.trim();
act(async () => {
const p = await api('/api/projects', { method: 'POST', body: JSON.stringify(body) });
S.currentProjectId = p.id;
f.reset();
$('#modalRoot').hidden = true;
await loadProjects();
}, `项目「${name}」已创建`);
});
$('#newTaskPanel').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
if (!S.currentProjectId) { toast('请先选择项目'); return; }
const title = f.title.value.trim();
if (!title) { toast('标题必填'); return; }
const body = {
title,
complexity: f.complexity.value,
priority: Number(f.priority.value || 0),
};
if (f.parentId.value) body.parentId = f.parentId.value;
act(async () => {
await api(`/api/projects/${S.currentProjectId}/tasks`, {
method: 'POST', body: JSON.stringify(body),
});
f.title.value = '';
}, '任务已创建');
});
// Esc 关模态
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') $('#modalRoot').hidden = true;
});
// ── WebSocket 实时刷新(指数退避重连) ──
let wsAttempt = 0;
let refreshTimer = null;
function setWsState(on, text) {
$('#wsDot').className = `ws-dot ${on ? 'on' : 'off'}`;
$('#wsText').textContent = text;
}
function scheduleRefresh() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => { refreshTimer = null; refresh(); }, 200);
}
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
wsAttempt = 0;
setWsState(true, '实时连接');
fullRefresh(); // 重连后补齐错过的状态
};
ws.onmessage = (msg) => {
let evt;
try { evt = JSON.parse(msg.data); } catch { return; }
if (evt.projectId === S.currentProjectId) {
scheduleRefresh();
} else if (evt.type === 'task.created' && evt.payload && evt.payload.kind === 'project') {
loadProjects().then(renderSidebar).catch(() => {});
}
};
const retry = () => {
wsAttempt += 1;
const delay = Math.min(30000, 1000 * Math.pow(2, wsAttempt - 1));
setWsState(false, `已断开 · ${Math.round(delay / 1000)}s 后重连`);
setTimeout(connectWs, delay);
};
ws.onclose = retry;
ws.onerror = () => { try { ws.close(); } catch { /* noop */ } };
}
// ── 启动 ──
fullRefresh().then(connectWs);
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MAESTRO · 任务调度台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- ════════ 左栏:项目 ════════ -->
<aside id="sidebar">
<div class="logo">
<div class="logo-word">MAESTRO<span class="cursor"></span></div>
<div class="logo-sub">多项目任务调度台</div>
</div>
<div class="side-head">
<span class="head-mark"></span>项目
<button class="btn btn-ghost btn-xs" data-action="open-new-project">+ 新建</button>
</div>
<ul id="projectList" class="project-list"></ul>
<div class="side-foot">
<span id="wsDot" class="ws-dot off"></span>
<span id="wsText">连接中…</span>
</div>
</aside>
<!-- ════════ 中栏:闸 + 任务树 ════════ -->
<main id="main">
<header id="topbar">
<div>
<div id="projTitle" class="proj-title"></div>
<div id="projMeta" class="proj-meta"></div>
</div>
<div class="spacer"></div>
<div id="gateCount" class="gate-count" hidden></div>
</header>
<section id="gateSection" class="gate-section" hidden>
<div class="gate-stripe"></div>
<div class="sec-head gate-head"><span class="head-mark amber"></span>审批闸 · 等待裁决</div>
<div id="gateList"></div>
</section>
<section id="taskSection">
<div class="sec-head">
<span class="head-mark"></span>任务树
<button class="btn btn-ghost btn-xs" data-action="toggle-new-task">+ 新建任务</button>
</div>
<form id="newTaskPanel" class="panel form-panel" hidden>
<div class="form-row">
<label class="grow">标题 <span class="req">*</span>
<input name="title" type="text" placeholder="要做什么" autocomplete="off">
</label>
</div>
<div class="form-row">
<label>复杂度 <span class="req">*</span>
<span class="seg" id="cplxSeg">
<input type="radio" name="complexity" value="hard" id="cx-h"><label for="cx-h" class="seg-h">HARD</label>
<input type="radio" name="complexity" value="medium" id="cx-m" checked><label for="cx-m" class="seg-m">MEDIUM</label>
<input type="radio" name="complexity" value="easy" id="cx-e"><label for="cx-e" class="seg-e">EASY</label>
</span>
</label>
<label>父任务
<select name="parentId"><option value="">(顶层)</option></select>
</label>
<label class="narrow">优先级
<input name="priority" type="number" value="0" step="1">
</label>
</div>
<div class="form-row form-actions">
<button type="submit" class="btn btn-solid">创建任务</button>
<button type="button" class="btn" data-action="toggle-new-task">取消</button>
</div>
</form>
<div id="taskTree"></div>
</section>
</main>
<!-- ════════ 右栏:事件流 ════════ -->
<aside id="eventPanel">
<div class="sec-head"><span class="head-mark"></span>事件流</div>
<ul id="eventList" class="event-list"></ul>
</aside>
</div>
<!-- 新建项目 模态 -->
<div id="modalRoot" class="modal-root" hidden>
<div class="modal-mask" data-action="close-modal"></div>
<form id="newProjectForm" class="modal panel">
<div class="sec-head"><span class="head-mark"></span>新建项目</div>
<label>名称 <span class="req">*</span>
<input name="name" type="text" placeholder="my-project" autocomplete="off">
</label>
<label>仓库路径 <span class="req">*</span>
<input name="repoPath" type="text" placeholder="/path/to/repo" autocomplete="off">
</label>
<label>默认分支
<input name="defaultBranch" type="text" placeholder="main">
</label>
<label>校验命令
<input name="verifyCmd" type="text" placeholder="npm test(可空)">
</label>
<div class="form-row form-actions">
<button type="submit" class="btn btn-solid">创建</button>
<button type="button" class="btn" data-action="close-modal">取消</button>
</div>
</form>
</div>
<div id="toastRoot" class="toast-root"></div>
<script src="app.js"></script>
</body>
</html>
+443
View File
@@ -0,0 +1,443 @@
/* ════════════════════════════════════════════════════════════════
MAESTRO 调度台 · phosphor console
碳绿底 + 磷光绿/琥珀/信号红,等宽中文混排,扫描线质感
════════════════════════════════════════════════════════════════ */
:root {
--bg: #0a0d0b;
--bg-deep: #070908;
--panel: #10140f;
--panel-2: #151b14;
--line: #232d23;
--line-soft: #1a221a;
--ink: #d8e4d4;
--muted: #76876f;
--faint: #4a5747;
--green: #5fdd7d;
--green-dim: #2e6b3d;
--amber: #f0b429;
--amber-dim: #6b5414;
--red: #ff5d5d;
--red-dim: #6b2424;
--cyan: #59c8d8;
--cyan-dim: #1e4e57;
--mono: 'IBM Plex Mono', 'Noto Sans SC', ui-monospace, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--mono);
font-size: 13px;
line-height: 1.55;
overflow: hidden;
}
/* 扫描线 + 暗角 氛围层 */
body::before {
content: '';
position: fixed; inset: 0; z-index: 999; pointer-events: none;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(0,0,0,.5));
opacity: .5;
}
::selection { background: var(--green-dim); color: #fff; }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--green-dim); }
/* ── 三栏骨架 ─────────────────────────────────────────────── */
#app {
display: grid;
grid-template-columns: 232px minmax(0,1fr) 320px;
height: 100vh;
}
#sidebar {
background: var(--bg-deep);
border-right: 1px solid var(--line);
display: flex; flex-direction: column;
overflow-y: auto;
}
#main { overflow-y: auto; padding: 0 22px 60px; }
#eventPanel {
background: var(--bg-deep);
border-left: 1px solid var(--line);
overflow-y: auto;
padding-bottom: 30px;
}
@media (max-width: 1100px) {
body { overflow: auto; }
#app { grid-template-columns: 1fr; height: auto; }
#sidebar, #eventPanel { border: none; border-bottom: 1px solid var(--line); }
}
/* ── Logo ────────────────────────────────────────────────── */
.logo { padding: 20px 16px 14px; border-bottom: 1px solid var(--line-soft); }
.logo-word {
font-size: 19px; font-weight: 700; letter-spacing: .28em;
color: var(--green);
text-shadow: 0 0 12px rgba(95,221,125,.45);
}
.cursor { animation: blink 1.1s steps(1) infinite; margin-left: 1px; }
@keyframes blink { 50% { opacity: 0; } }
.logo-sub { margin-top: 3px; font-size: 11px; color: var(--muted); letter-spacing: .35em; }
/* ── 区块标题 ─────────────────────────────────────────────── */
.sec-head {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600; letter-spacing: .22em;
color: var(--muted); text-transform: uppercase;
padding: 18px 0 10px;
position: sticky; top: 0; z-index: 5;
background: linear-gradient(var(--bg) 75%, transparent);
}
#sidebar .sec-head, .side-head {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600; letter-spacing: .22em; color: var(--muted);
padding: 16px 16px 8px; background: none; position: static;
}
#eventPanel .sec-head { padding: 18px 16px 10px; background: linear-gradient(var(--bg-deep) 75%, transparent); }
.head-mark { color: var(--green); }
.head-mark.amber { color: var(--amber); }
.side-head .btn, .sec-head .btn { margin-left: auto; letter-spacing: .1em; }
/* ── 项目列表 ─────────────────────────────────────────────── */
.project-list { list-style: none; flex: 1; }
.project-list li {
padding: 9px 16px 9px 14px;
border-left: 2px solid transparent;
cursor: pointer;
display: flex; flex-direction: column; gap: 1px;
}
.project-list li:hover { background: var(--panel); }
.project-list li.active {
background: var(--panel-2);
border-left-color: var(--green);
}
.project-list li.active .p-name { color: var(--green); }
.p-name { font-weight: 600; font-size: 13px; }
.p-path { font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.p-empty { padding: 14px 16px; color: var(--faint); font-size: 12px; }
.side-foot {
border-top: 1px solid var(--line-soft);
padding: 10px 16px; font-size: 11px; color: var(--muted);
display: flex; align-items: center; gap: 7px;
}
.ws-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.ws-dot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
.ws-dot.off { background: var(--amber); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: .25; } }
/* ── 顶栏 ────────────────────────────────────────────────── */
#topbar {
display: flex; align-items: flex-end; gap: 14px;
padding: 22px 0 14px;
border-bottom: 1px solid var(--line);
}
.proj-title { font-size: 20px; font-weight: 700; letter-spacing: .04em; }
.proj-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
.spacer { flex: 1; }
.gate-count {
font-size: 11px; letter-spacing: .12em; color: var(--amber);
border: 1px solid var(--amber-dim); padding: 4px 10px;
animation: pulse 2.2s infinite;
}
/* ── 审批闸 ─────────────────────────────────────────────── */
.gate-section { margin-top: 18px; }
.gate-stripe {
height: 5px;
background: repeating-linear-gradient(-45deg,
var(--amber) 0 9px, transparent 9px 18px);
opacity: .8;
}
.gate-head { padding-top: 12px; position: static; background: none; color: var(--amber); }
.gate-card {
background: var(--panel);
border: 1px solid var(--amber-dim);
margin-bottom: 12px;
animation: rise .25s ease both;
}
@keyframes rise { from { opacity: 0; transform: translateY(5px); } }
.gate-card-head {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 10px 14px;
border-bottom: 1px solid var(--line-soft);
}
.gate-kind {
font-size: 10.5px; font-weight: 700; letter-spacing: .18em;
color: var(--bg); background: var(--amber);
padding: 2px 8px;
}
.gate-title { font-weight: 600; }
.gate-body { padding: 12px 14px; }
.gate-doc-label { font-size: 10.5px; color: var(--muted); letter-spacing: .18em; margin: 8px 0 4px; }
.gate-doc-label:first-child { margin-top: 0; }
pre.doc {
background: var(--bg-deep);
border: 1px solid var(--line-soft);
border-left: 2px solid var(--green-dim);
padding: 10px 12px;
font-family: var(--mono); font-size: 12.5px;
white-space: pre-wrap; word-break: break-word;
max-height: 280px; overflow-y: auto;
color: var(--ink);
}
pre.doc.empty { color: var(--faint); border-left-color: var(--line); }
.gate-actions {
display: flex; gap: 10px; align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--line-soft);
}
.reject-form { padding: 0 14px 12px; display: flex; gap: 10px; align-items: flex-start; }
.reject-form textarea {
flex: 1; min-height: 56px;
border-color: var(--red-dim);
}
.reject-form textarea:focus { border-color: var(--red); }
/* ── 按钮 ────────────────────────────────────────────────── */
.btn {
font-family: var(--mono); font-size: 12px; font-weight: 600;
letter-spacing: .08em;
background: transparent; color: var(--ink);
border: 1px solid var(--line);
padding: 6px 14px; cursor: pointer;
transition: all .12s;
}
.btn:hover { border-color: var(--muted); background: var(--panel-2); }
.btn-xs { padding: 2px 8px; font-size: 11px; }
.btn-ghost { border-color: transparent; color: var(--muted); }
.btn-ghost:hover { color: var(--green); border-color: var(--green-dim); background: transparent; }
.btn-solid {
background: var(--green); color: var(--bg-deep); border-color: var(--green);
}
.btn-solid:hover { background: #79ec94; border-color: #79ec94; box-shadow: 0 0 14px rgba(95,221,125,.35); }
.btn-accept { border-color: var(--green-dim); color: var(--green); }
.btn-accept:hover { background: var(--green); color: var(--bg-deep); border-color: var(--green); box-shadow: 0 0 14px rgba(95,221,125,.3); }
.btn-reject { border-color: var(--red-dim); color: var(--red); }
.btn-reject:hover { background: var(--red); color: var(--bg-deep); border-color: var(--red); box-shadow: 0 0 14px rgba(255,93,93,.3); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
/* ── 表单 ────────────────────────────────────────────────── */
.panel { background: var(--panel); border: 1px solid var(--line); }
.form-panel { padding: 14px; margin-bottom: 14px; }
.form-row { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; }
.form-row + .form-row { margin-top: 12px; }
.form-actions { justify-content: flex-end; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--muted); letter-spacing: .08em; }
label.grow { flex: 1; }
label.narrow input { width: 76px; }
.req { color: var(--red); }
input[type=text], input[type=number], select, textarea {
font-family: var(--mono); font-size: 13px;
background: var(--bg-deep); color: var(--ink);
border: 1px solid var(--line);
padding: 7px 10px;
outline: none;
transition: border-color .12s;
}
input:focus, select:focus, textarea:focus { border-color: var(--green-dim); box-shadow: 0 0 0 1px var(--green-dim); }
textarea { resize: vertical; min-height: 72px; width: 100%; }
/* 复杂度分段选择 */
.seg { display: inline-flex; border: 1px solid var(--line); }
.seg input { position: absolute; opacity: 0; pointer-events: none; }
.seg label {
padding: 7px 13px; cursor: pointer;
font-size: 11px; font-weight: 700; letter-spacing: .12em;
color: var(--faint); flex-direction: row;
}
.seg label + input + label, .seg input + label { border-left: 0; }
.seg label:not(:first-of-type) { border-left: 1px solid var(--line); }
.seg input:checked + label.seg-h { background: var(--red-dim); color: var(--red); }
.seg input:checked + label.seg-m { background: var(--amber-dim); color: var(--amber); }
.seg input:checked + label.seg-e { background: var(--green-dim); color: var(--green); }
/* ── 任务树 ─────────────────────────────────────────────── */
.tree-empty {
padding: 46px 0; text-align: center; color: var(--faint);
border: 1px dashed var(--line);
}
.tree-empty b { display: block; font-size: 15px; color: var(--muted); margin-bottom: 6px; letter-spacing: .2em; }
.task-node { border-left: 1px solid var(--line-soft); }
.task-node.depth-1 { border-left: none; }
.task-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px 8px 6px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background .1s;
}
.task-row:hover { background: var(--panel); }
.task-row.is-gate { background: rgba(240,180,41,.045); }
.task-row.expanded { background: var(--panel-2); }
.caret {
width: 16px; flex: none; text-align: center;
color: var(--muted); font-size: 10px;
transition: transform .12s; user-select: none;
}
.caret.open { transform: rotate(90deg); color: var(--green); }
.caret.leaf { color: var(--faint); }
.t-title { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.t-title .t-id { color: var(--faint); font-size: 10.5px; margin-left: 6px; letter-spacing: 0; }
.task-row .spacer { flex: 1; min-width: 8px; }
.t-prio { font-size: 10.5px; color: var(--faint); flex: none; }
.t-prio.hot { color: var(--amber); }
/* 复杂度徽章 */
.cplx {
flex: none; font-size: 10px; font-weight: 700; letter-spacing: .14em;
padding: 1px 7px; border: 1px solid;
}
.cplx-hard { color: var(--red); border-color: var(--red-dim); background: rgba(255,93,93,.07); }
.cplx-medium { color: var(--amber); border-color: var(--amber-dim); background: rgba(240,180,41,.07); }
.cplx-easy { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.07); }
/* 状态 chip */
.chip {
flex: none; display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; padding: 1px 8px;
border: 1px solid var(--line); color: var(--muted);
white-space: nowrap;
}
.chip::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.chip-idle { color: var(--muted); }
.chip-work { color: var(--cyan); border-color: var(--cyan-dim); }
.chip-gate { color: var(--amber); border-color: var(--amber-dim); }
.chip-gate::before { animation: pulse 1.4s infinite; }
.chip-container { color: #9bb4c8; border-color: #2c3c4a; }
.chip-go { color: var(--green); border-color: var(--green-dim); }
.chip-run { color: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 10px rgba(89,200,216,.25); }
.chip-run::before { animation: pulse .8s infinite; }
.chip-hold { color: var(--faint); }
.chip-bad { color: var(--red); border-color: var(--red-dim); }
.chip-bad::before { animation: pulse 1s infinite; }
.chip-done { color: var(--bg-deep); background: var(--green); border-color: var(--green); font-weight: 700; }
.chip-dead { color: var(--faint); text-decoration: line-through; }
.chip-dead::before { background: var(--faint); }
/* 子节点缩进 */
.task-children { margin-left: 22px; }
/* ── 任务详情 ─────────────────────────────────────────────── */
.task-detail {
background: var(--panel);
border-bottom: 1px solid var(--line);
border-left: 2px solid var(--green-dim);
padding: 14px 16px;
animation: rise .2s ease both;
}
.detail-grid { display: grid; gap: 14px; }
.detail-label { font-size: 10.5px; color: var(--muted); letter-spacing: .18em; margin-bottom: 4px; }
.approval-item {
display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap;
font-size: 12px; padding: 5px 0;
border-bottom: 1px dashed var(--line-soft);
}
.approval-item:last-child { border-bottom: none; }
.ap-act-accept { color: var(--green); font-weight: 700; }
.ap-act-reject { color: var(--red); font-weight: 700; }
.ap-gate { color: var(--muted); }
.ap-time { color: var(--faint); font-size: 11px; margin-left: auto; }
.ap-reason { width: 100%; color: var(--amber); font-size: 12px; padding-left: 14px; }
.ap-reason::before { content: '↳ '; color: var(--faint); }
.result-kv { font-size: 12px; display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; }
.result-kv dt { color: var(--muted); }
.result-kv dd { word-break: break-all; }
.write-box { margin-top: 4px; }
.write-box .form-actions { margin-top: 8px; }
.detail-actions { display: flex; gap: 10px; }
/* ── 事件流 ─────────────────────────────────────────────── */
.event-list { list-style: none; padding: 0 14px; }
.event-list li {
padding: 7px 0 7px 12px;
border-bottom: 1px solid var(--line-soft);
position: relative;
font-size: 11.5px;
animation: rise .25s ease both;
}
.event-list li::before {
content: ''; position: absolute; left: 0; top: 13px;
width: 5px; height: 5px; background: var(--ev, var(--muted));
}
.ev-head { display: flex; gap: 8px; align-items: baseline; }
.ev-type { font-weight: 600; color: var(--ev, var(--ink)); letter-spacing: .04em; }
.ev-time { color: var(--faint); font-size: 10.5px; margin-left: auto; flex: none; }
.ev-detail { color: var(--muted); margin-top: 1px; word-break: break-word; }
.ev-empty { padding: 30px 0; color: var(--faint); text-align: center; border: none !important; }
.ev-empty::before { display: none; }
li.ev-created { --ev: var(--green); }
li.ev-status { --ev: var(--cyan); }
li.ev-approve { --ev: var(--green); }
li.ev-rejected { --ev: var(--red); }
li.ev-gatewait { --ev: var(--amber); }
li.ev-run { --ev: var(--cyan); }
li.ev-updated { --ev: var(--muted); }
/* ── 模态 ────────────────────────────────────────────────── */
.modal-root { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; }
.modal-root[hidden] { display: none; }
.modal-mask { position: absolute; inset: 0; background: rgba(4,6,5,.78); backdrop-filter: blur(2px); }
.modal {
position: relative; width: min(460px, 92vw);
padding: 18px 20px 20px;
display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 0 0 1px var(--green-dim), 0 18px 60px rgba(0,0,0,.6);
animation: rise .18s ease both;
}
.modal .sec-head { padding: 0 0 4px; position: static; background: none; }
/* ── Toast ───────────────────────────────────────────────── */
.toast-root {
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
z-index: 1200; display: flex; flex-direction: column; gap: 8px; align-items: center;
}
.toast {
font-family: var(--mono); font-size: 12.5px;
background: var(--bg-deep);
border: 1px solid var(--line);
padding: 9px 16px;
max-width: 70vw;
animation: toastIn .18s ease both;
box-shadow: 0 8px 30px rgba(0,0,0,.55);
}
.toast.err { border-color: var(--red); color: var(--red); }
.toast.ok { border-color: var(--green-dim); color: var(--green); }
.toast.out { opacity: 0; transition: opacity .3s; }
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } }