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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"maestro": {
|
||||
"command": "node",
|
||||
"args": ["/绝对路径/maestro/dist/mcp/index.js"],
|
||||
"env": {
|
||||
"MAESTRO_URL": "http://127.0.0.1:4517"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,22 @@ npm run dev # 启动 daemon(开发)
|
||||
```
|
||||
|
||||
> 依赖版本为初始值;MCP SDK 与 Claude Agent SDK 在 Phase 2 接入时按官方文档核定后再加。
|
||||
|
||||
## 接入 Claude Code(MCP)
|
||||
|
||||
先 `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 看板做)。
|
||||
|
||||
Generated
+1027
File diff suppressed because it is too large
Load Diff
+14
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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['*'] ?? '');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* maestro MCP server(stdio 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-easy(Easy 自动执行)| 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('项目 id(prj_ 开头)') },
|
||||
},
|
||||
async ({ projectId }) => run(() => api('GET', `/api/projects/${encodeURIComponent(projectId)}/tasks`)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_task',
|
||||
{
|
||||
title: '查看任务详情',
|
||||
description: '取单个任务的完整详情:字段(plan/spec/operations/result)、审批历史(approvals)以及全部子任务(children)。',
|
||||
inputSchema: { taskId: z.string().min(1).describe('任务 id(tsk_ 开头)') },
|
||||
},
|
||||
async ({ taskId }) =>
|
||||
run(async () => {
|
||||
const tid = encodeURIComponent(taskId);
|
||||
const task = await api('GET', `/api/tasks/${tid}`);
|
||||
const children = await api('GET', `/api/tasks/${tid}/children`);
|
||||
return { task, children };
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_task',
|
||||
{
|
||||
title: '创建任务',
|
||||
description:
|
||||
'在项目下创建任务。初始状态由复杂度决定:hard→analyzing(须先分析拆解)、medium→speccing(须先写方案)、easy→ready(写明 operations 后即可执行)。',
|
||||
inputSchema: {
|
||||
projectId: z.string().min(1).describe('项目 id'),
|
||||
title: z.string().min(1).describe('任务标题'),
|
||||
complexity: complexitySchema,
|
||||
parentId: z.string().optional().describe('父任务 id(建子任务时填,最多 3-4 层)'),
|
||||
deps: z.array(z.string()).optional().describe('依赖的任务 id 列表'),
|
||||
priority: z.number().int().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
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
@@ -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
@@ -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); } }
|
||||
Reference in New Issue
Block a user