@hummer98/cmux-team 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +15 -0
- package/CHANGELOG.md +279 -0
- package/LICENSE +21 -0
- package/README.ja.md +238 -0
- package/README.md +158 -0
- package/bin/cmux-team.js +29 -0
- package/bin/postinstall.js +26 -0
- package/commands/master.md +8 -0
- package/commands/start.md +42 -0
- package/commands/team-archive.md +63 -0
- package/commands/team-design.md +199 -0
- package/commands/team-disband.md +79 -0
- package/commands/team-impl.md +201 -0
- package/commands/team-research.md +182 -0
- package/commands/team-review.md +222 -0
- package/commands/team-spec.md +133 -0
- package/commands/team-status.md +24 -0
- package/commands/team-sync-docs.md +127 -0
- package/commands/team-task.md +158 -0
- package/commands/team-test.md +230 -0
- package/package.json +51 -0
- package/skills/cmux-agent-role/SKILL.md +166 -0
- package/skills/cmux-team/SKILL.md +568 -0
- package/skills/cmux-team/manager/bun.lock +118 -0
- package/skills/cmux-team/manager/cmux.ts +134 -0
- package/skills/cmux-team/manager/conductor.ts +347 -0
- package/skills/cmux-team/manager/daemon.ts +373 -0
- package/skills/cmux-team/manager/e2e.ts +550 -0
- package/skills/cmux-team/manager/logger.ts +13 -0
- package/skills/cmux-team/manager/main.ts +756 -0
- package/skills/cmux-team/manager/master.ts +51 -0
- package/skills/cmux-team/manager/package.json +18 -0
- package/skills/cmux-team/manager/proxy.ts +219 -0
- package/skills/cmux-team/manager/queue.ts +73 -0
- package/skills/cmux-team/manager/schema.ts +84 -0
- package/skills/cmux-team/manager/task.ts +160 -0
- package/skills/cmux-team/manager/template.ts +92 -0
- package/skills/cmux-team/templates/architect.md +25 -0
- package/skills/cmux-team/templates/common-header.md +12 -0
- package/skills/cmux-team/templates/conductor-role.md +244 -0
- package/skills/cmux-team/templates/conductor-task.md +37 -0
- package/skills/cmux-team/templates/conductor.md +275 -0
- package/skills/cmux-team/templates/dockeeper.md +23 -0
- package/skills/cmux-team/templates/implementer.md +24 -0
- package/skills/cmux-team/templates/manager.md +199 -0
- package/skills/cmux-team/templates/master.md +94 -0
- package/skills/cmux-team/templates/researcher.md +24 -0
- package/skills/cmux-team/templates/reviewer.md +28 -0
- package/skills/cmux-team/templates/task-manager.md +22 -0
- package/skills/cmux-team/templates/tester.md +27 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Master surface の作成・管理
|
|
3
|
+
*/
|
|
4
|
+
import * as cmux from "./cmux";
|
|
5
|
+
import { generateMasterPrompt } from "./template";
|
|
6
|
+
import { log } from "./logger";
|
|
7
|
+
|
|
8
|
+
export interface MasterState {
|
|
9
|
+
surface: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function spawnMaster(
|
|
13
|
+
projectRoot: string,
|
|
14
|
+
daemonSurface?: string
|
|
15
|
+
): Promise<MasterState | null> {
|
|
16
|
+
try {
|
|
17
|
+
// プロンプト生成
|
|
18
|
+
await generateMasterPrompt(projectRoot);
|
|
19
|
+
|
|
20
|
+
// ペイン作成(daemon surface を右に split)
|
|
21
|
+
const surface = await cmux.newSplit("right", daemonSurface ? { surface: daemonSurface } : undefined);
|
|
22
|
+
|
|
23
|
+
if (!(await cmux.validateSurface(surface))) {
|
|
24
|
+
await log("error", `Master surface ${surface} validation failed`);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Claude Code 起動
|
|
29
|
+
await cmux.send(
|
|
30
|
+
surface,
|
|
31
|
+
"claude --dangerously-skip-permissions --append-system-prompt-file .team/prompts/master.md 'ユーザーからのタスクを待ってください。'\n"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Trust 承認
|
|
35
|
+
await cmux.waitForTrust(surface);
|
|
36
|
+
|
|
37
|
+
// タブ名設定
|
|
38
|
+
const num = surface.replace("surface:", "");
|
|
39
|
+
await cmux.renameTab(surface, `[${num}] Master`);
|
|
40
|
+
|
|
41
|
+
await log("master_spawned", `surface=${surface}`);
|
|
42
|
+
return { surface };
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
await log("error", `Master spawn failed: ${e.message}`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function isMasterAlive(surface: string): Promise<boolean> {
|
|
50
|
+
return cmux.validateSurface(surface);
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "manager",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"@types/bun": "latest",
|
|
8
|
+
"@types/react": "^19.2.14"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"typescript": "^5"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"ink": "^6.8.0",
|
|
15
|
+
"react": "^19.2.4",
|
|
16
|
+
"zod": "^4.3.6"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ロギングプロキシ — Anthropic API への透過プロキシ + JSONL トレース
|
|
3
|
+
*
|
|
4
|
+
* Agent の ANTHROPIC_BASE_URL をこのプロキシに向けることで、
|
|
5
|
+
* リクエスト/レスポンスを .team/logs/traces/ に記録する。
|
|
6
|
+
* レスポンスは streaming のまま返し、ログは非同期で書き込む。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, appendFile, readFile } from "fs/promises";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_UPSTREAM = "https://api.anthropic.com";
|
|
12
|
+
|
|
13
|
+
interface ProxyHandle {
|
|
14
|
+
port: number;
|
|
15
|
+
stop: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TraceEntry {
|
|
19
|
+
timestamp: string;
|
|
20
|
+
conductor_id?: string;
|
|
21
|
+
task_id?: string;
|
|
22
|
+
role?: string;
|
|
23
|
+
method: string;
|
|
24
|
+
path: string;
|
|
25
|
+
status?: number;
|
|
26
|
+
request_bytes: number;
|
|
27
|
+
response_bytes: number;
|
|
28
|
+
duration_ms: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function start(
|
|
32
|
+
projectRoot: string,
|
|
33
|
+
opts?: { conductorId?: string; taskId?: string; role?: string; getState?: () => any }
|
|
34
|
+
): Promise<ProxyHandle> {
|
|
35
|
+
const upstream = process.env.ANTHROPIC_API_URL || DEFAULT_UPSTREAM;
|
|
36
|
+
const tracesDir = join(projectRoot, ".team/logs/traces");
|
|
37
|
+
await mkdir(tracesDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const traceFile = join(tracesDir, "api-trace.jsonl");
|
|
40
|
+
|
|
41
|
+
// 前回ポートの読み取り(daemon リロード時に同じポートを再利用)
|
|
42
|
+
let preferredPort = 0;
|
|
43
|
+
try {
|
|
44
|
+
const saved = await readFile(join(projectRoot, ".team/proxy-port"), "utf-8");
|
|
45
|
+
const parsed = parseInt(saved.trim(), 10);
|
|
46
|
+
if (parsed > 0) preferredPort = parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
// ファイルなし(初回起動)→ ランダムポート
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fetchHandler = async (req: Request) => {
|
|
52
|
+
const url = new URL(req.url);
|
|
53
|
+
|
|
54
|
+
// デバッグエンドポイント
|
|
55
|
+
if (req.method === "GET") {
|
|
56
|
+
const jsonHeaders = { "Content-Type": "application/json" };
|
|
57
|
+
|
|
58
|
+
if (url.pathname === "/state") {
|
|
59
|
+
if (!opts?.getState) return new Response("Not Found", { status: 404 });
|
|
60
|
+
const state = opts.getState();
|
|
61
|
+
const serialized = {
|
|
62
|
+
...state,
|
|
63
|
+
conductors: Object.fromEntries(state.conductors),
|
|
64
|
+
lastUpdate: state.lastUpdate instanceof Date ? state.lastUpdate.toISOString() : state.lastUpdate,
|
|
65
|
+
};
|
|
66
|
+
return new Response(JSON.stringify(serialized), { headers: jsonHeaders });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (url.pathname === "/tasks") {
|
|
70
|
+
if (!opts?.getState) return new Response("Not Found", { status: 404 });
|
|
71
|
+
const state = opts.getState();
|
|
72
|
+
return new Response(JSON.stringify(state.taskList), { headers: jsonHeaders });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (url.pathname === "/conductors") {
|
|
76
|
+
if (!opts?.getState) return new Response("Not Found", { status: 404 });
|
|
77
|
+
const state = opts.getState();
|
|
78
|
+
return new Response(JSON.stringify(Object.fromEntries(state.conductors)), { headers: jsonHeaders });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const targetUrl = `${upstream}${url.pathname}${url.search}`;
|
|
82
|
+
const startTime = Date.now();
|
|
83
|
+
|
|
84
|
+
// リクエストボディを読み取り(転送用 + サイズ計測用)
|
|
85
|
+
const reqBody = req.body ? await req.arrayBuffer() : null;
|
|
86
|
+
const requestBytes = reqBody?.byteLength ?? 0;
|
|
87
|
+
|
|
88
|
+
// Host ヘッダーを除外して転送(そのまま渡すと Bun が
|
|
89
|
+
// Host の値を接続先に使い、プロキシ自身に接続してしまう)
|
|
90
|
+
const fwdHeaders = new Headers(req.headers);
|
|
91
|
+
fwdHeaders.delete("host");
|
|
92
|
+
fwdHeaders.delete("accept-encoding");
|
|
93
|
+
|
|
94
|
+
const upstreamRes = await fetch(targetUrl, {
|
|
95
|
+
method: req.method,
|
|
96
|
+
headers: fwdHeaders,
|
|
97
|
+
body: reqBody,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Bun の fetch は自動解凍するので Content-Encoding / Content-Length を除去
|
|
101
|
+
const resHeaders = new Headers(upstreamRes.headers);
|
|
102
|
+
resHeaders.delete("content-encoding");
|
|
103
|
+
resHeaders.delete("content-length");
|
|
104
|
+
|
|
105
|
+
// レスポンスが streaming かどうかを判定
|
|
106
|
+
const contentType = upstreamRes.headers.get("content-type") || "";
|
|
107
|
+
const isStreaming = contentType.includes("text/event-stream");
|
|
108
|
+
|
|
109
|
+
if (isStreaming && upstreamRes.body) {
|
|
110
|
+
// streaming: tee して片方をログに使う
|
|
111
|
+
const [clientStream, logStream] = upstreamRes.body.tee();
|
|
112
|
+
|
|
113
|
+
// 非同期でログ書き込み(レスポンスはブロックしない)
|
|
114
|
+
drainAndLog(logStream, {
|
|
115
|
+
tracesDir: traceFile,
|
|
116
|
+
method: req.method,
|
|
117
|
+
path: url.pathname,
|
|
118
|
+
status: upstreamRes.status,
|
|
119
|
+
requestBytes,
|
|
120
|
+
startTime,
|
|
121
|
+
conductorId: opts?.conductorId,
|
|
122
|
+
taskId: opts?.taskId,
|
|
123
|
+
role: opts?.role,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return new Response(clientStream, {
|
|
127
|
+
status: upstreamRes.status,
|
|
128
|
+
statusText: upstreamRes.statusText,
|
|
129
|
+
headers: resHeaders,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 非 streaming: ボディ全体を取得してログ
|
|
134
|
+
const resBody = await upstreamRes.arrayBuffer();
|
|
135
|
+
const duration = Date.now() - startTime;
|
|
136
|
+
|
|
137
|
+
const entry: TraceEntry = {
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
conductor_id: opts?.conductorId,
|
|
140
|
+
task_id: opts?.taskId,
|
|
141
|
+
role: opts?.role,
|
|
142
|
+
method: req.method,
|
|
143
|
+
path: url.pathname,
|
|
144
|
+
status: upstreamRes.status,
|
|
145
|
+
request_bytes: requestBytes,
|
|
146
|
+
response_bytes: resBody.byteLength,
|
|
147
|
+
duration_ms: duration,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// 非同期でログ書き込み
|
|
151
|
+
appendFile(traceFile, JSON.stringify(entry) + "\n").catch(() => {});
|
|
152
|
+
|
|
153
|
+
return new Response(resBody, {
|
|
154
|
+
status: upstreamRes.status,
|
|
155
|
+
statusText: upstreamRes.statusText,
|
|
156
|
+
headers: resHeaders,
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// 前回ポートで起動を試み、失敗時はランダムポートにフォールバック
|
|
161
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
162
|
+
try {
|
|
163
|
+
server = Bun.serve({ port: preferredPort, fetch: fetchHandler });
|
|
164
|
+
} catch {
|
|
165
|
+
if (preferredPort !== 0) {
|
|
166
|
+
server = Bun.serve({ port: 0, fetch: fetchHandler });
|
|
167
|
+
} else {
|
|
168
|
+
throw new Error("Failed to start proxy");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
port: server.port!,
|
|
174
|
+
stop: () => server.stop(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** streaming レスポンスを drain してバイト数をログに記録 */
|
|
179
|
+
async function drainAndLog(
|
|
180
|
+
stream: ReadableStream<Uint8Array>,
|
|
181
|
+
ctx: {
|
|
182
|
+
tracesDir: string;
|
|
183
|
+
method: string;
|
|
184
|
+
path: string;
|
|
185
|
+
status: number;
|
|
186
|
+
requestBytes: number;
|
|
187
|
+
startTime: number;
|
|
188
|
+
conductorId?: string;
|
|
189
|
+
taskId?: string;
|
|
190
|
+
role?: string;
|
|
191
|
+
}
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
let responseBytes = 0;
|
|
194
|
+
try {
|
|
195
|
+
const reader = stream.getReader();
|
|
196
|
+
while (true) {
|
|
197
|
+
const { done, value } = await reader.read();
|
|
198
|
+
if (done) break;
|
|
199
|
+
responseBytes += value.byteLength;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// stream エラーは無視(クライアント切断等)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const entry: TraceEntry = {
|
|
206
|
+
timestamp: new Date().toISOString(),
|
|
207
|
+
conductor_id: ctx.conductorId,
|
|
208
|
+
task_id: ctx.taskId,
|
|
209
|
+
role: ctx.role,
|
|
210
|
+
method: ctx.method,
|
|
211
|
+
path: ctx.path,
|
|
212
|
+
status: ctx.status,
|
|
213
|
+
request_bytes: ctx.requestBytes,
|
|
214
|
+
response_bytes: responseBytes,
|
|
215
|
+
duration_ms: Date.now() - ctx.startTime,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
appendFile(ctx.tracesDir, JSON.stringify(entry) + "\n").catch(() => {});
|
|
219
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readdir, readFile, mkdir, rename, writeFile } from "fs/promises";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, basename } from "path";
|
|
4
|
+
import { QueueMessage } from "./schema";
|
|
5
|
+
|
|
6
|
+
function getQueueDir(): string {
|
|
7
|
+
const root = process.env.PROJECT_ROOT || process.cwd();
|
|
8
|
+
return join(root, ".team/queue");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getProcessedDir(): string {
|
|
12
|
+
return join(getQueueDir(), "processed");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function ensureQueueDirs(): Promise<void> {
|
|
16
|
+
await mkdir(getQueueDir(), { recursive: true });
|
|
17
|
+
await mkdir(getProcessedDir(), { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readQueue(): Promise<
|
|
21
|
+
Array<{ path: string; message: QueueMessage }>
|
|
22
|
+
> {
|
|
23
|
+
const queueDir = getQueueDir();
|
|
24
|
+
if (!existsSync(queueDir)) return [];
|
|
25
|
+
|
|
26
|
+
const files = await readdir(queueDir);
|
|
27
|
+
const jsonFiles = files
|
|
28
|
+
.filter((f) => f.endsWith(".json"))
|
|
29
|
+
.sort();
|
|
30
|
+
|
|
31
|
+
const messages: Array<{ path: string; message: QueueMessage }> = [];
|
|
32
|
+
|
|
33
|
+
for (const file of jsonFiles) {
|
|
34
|
+
const filePath = join(queueDir, file);
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(await readFile(filePath, "utf-8"));
|
|
37
|
+
const message = QueueMessage.parse(raw);
|
|
38
|
+
messages.push({ path: filePath, message });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(`[queue] invalid message: ${file}`, e);
|
|
41
|
+
await rename(filePath, join(getProcessedDir(), file)).catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return messages;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function markProcessed(filePath: string): Promise<void> {
|
|
49
|
+
const file = basename(filePath);
|
|
50
|
+
await rename(filePath, join(getProcessedDir(), file));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let sequence = 0;
|
|
54
|
+
|
|
55
|
+
export async function sendMessage(
|
|
56
|
+
message: QueueMessage
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
await ensureQueueDirs();
|
|
59
|
+
|
|
60
|
+
QueueMessage.parse(message);
|
|
61
|
+
|
|
62
|
+
sequence++;
|
|
63
|
+
const seq = String(sequence).padStart(3, "0");
|
|
64
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
65
|
+
const fileName = `${seq}-${ts}-${message.type.toLowerCase()}.json`;
|
|
66
|
+
const filePath = join(getQueueDir(), fileName);
|
|
67
|
+
const tmpPath = `${filePath}.tmp`;
|
|
68
|
+
|
|
69
|
+
await writeFile(tmpPath, JSON.stringify(message, null, 2) + "\n");
|
|
70
|
+
await rename(tmpPath, filePath);
|
|
71
|
+
|
|
72
|
+
return filePath;
|
|
73
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// --- キューメッセージ ---
|
|
4
|
+
|
|
5
|
+
export const TaskCreatedMessage = z.object({
|
|
6
|
+
type: z.literal("TASK_CREATED"),
|
|
7
|
+
taskId: z.string(),
|
|
8
|
+
taskFile: z.string(),
|
|
9
|
+
timestamp: z.string().datetime(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const ConductorDoneMessage = z.object({
|
|
13
|
+
type: z.literal("CONDUCTOR_DONE"),
|
|
14
|
+
conductorId: z.string(),
|
|
15
|
+
sessionId: z.string().optional(),
|
|
16
|
+
transcriptPath: z.string().optional(),
|
|
17
|
+
surface: z.string(),
|
|
18
|
+
success: z.boolean(),
|
|
19
|
+
reason: z.string().optional(),
|
|
20
|
+
exitCode: z.number().optional(),
|
|
21
|
+
timestamp: z.string().datetime(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const AgentSpawnedMessage = z.object({
|
|
25
|
+
type: z.literal("AGENT_SPAWNED"),
|
|
26
|
+
conductorId: z.string(),
|
|
27
|
+
surface: z.string(),
|
|
28
|
+
role: z.string().optional(),
|
|
29
|
+
taskTitle: z.string().optional(),
|
|
30
|
+
timestamp: z.string().datetime(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const AgentDoneMessage = z.object({
|
|
34
|
+
type: z.literal("AGENT_DONE"),
|
|
35
|
+
conductorId: z.string(),
|
|
36
|
+
surface: z.string(),
|
|
37
|
+
timestamp: z.string().datetime(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const ShutdownMessage = z.object({
|
|
41
|
+
type: z.literal("SHUTDOWN"),
|
|
42
|
+
timestamp: z.string().datetime(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const QueueMessage = z.discriminatedUnion("type", [
|
|
46
|
+
TaskCreatedMessage,
|
|
47
|
+
ConductorDoneMessage,
|
|
48
|
+
AgentSpawnedMessage,
|
|
49
|
+
AgentDoneMessage,
|
|
50
|
+
ShutdownMessage,
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export type QueueMessage = z.infer<typeof QueueMessage>;
|
|
54
|
+
export type TaskCreatedMessage = z.infer<typeof TaskCreatedMessage>;
|
|
55
|
+
export type ConductorDoneMessage = z.infer<typeof ConductorDoneMessage>;
|
|
56
|
+
|
|
57
|
+
// --- Agent 状態 ---
|
|
58
|
+
|
|
59
|
+
export interface AgentState {
|
|
60
|
+
surface: string;
|
|
61
|
+
role?: string;
|
|
62
|
+
taskTitle?: string;
|
|
63
|
+
spawnedAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Conductor 状態 ---
|
|
67
|
+
|
|
68
|
+
export const ConductorState = z.object({
|
|
69
|
+
conductorId: z.string(),
|
|
70
|
+
taskRunId: z.string().optional(),
|
|
71
|
+
taskId: z.string().optional(),
|
|
72
|
+
taskTitle: z.string().optional(),
|
|
73
|
+
surface: z.string(),
|
|
74
|
+
worktreePath: z.string().optional(),
|
|
75
|
+
outputDir: z.string().optional(),
|
|
76
|
+
startedAt: z.string().datetime(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export type ConductorState = z.infer<typeof ConductorState> & {
|
|
80
|
+
agents: AgentState[];
|
|
81
|
+
doneCandidate: boolean;
|
|
82
|
+
status: "idle" | "running" | "done";
|
|
83
|
+
paneId?: string;
|
|
84
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* タスクファイルのパース・依存解決
|
|
3
|
+
*/
|
|
4
|
+
import { readdir, readFile, writeFile } from "fs/promises";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
|
|
8
|
+
export interface TaskMeta {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
status: string;
|
|
12
|
+
priority: string;
|
|
13
|
+
dependsOn: string[];
|
|
14
|
+
filePath: string;
|
|
15
|
+
fileName: string;
|
|
16
|
+
createdAt: string; // ISO 8601 datetime
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskState {
|
|
20
|
+
status: string; // "draft" | "ready" | "in_progress" | "closed"
|
|
21
|
+
closedAt?: string; // ISO 8601
|
|
22
|
+
journal?: string; // 完了時のサマリー
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type TaskStateMap = Record<string, TaskState>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* YAML frontmatter からメタデータを抽出
|
|
29
|
+
*/
|
|
30
|
+
export function parseTaskMeta(content: string, fileName: string, filePath: string): TaskMeta | null {
|
|
31
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
32
|
+
if (!fmMatch?.[1]) return null;
|
|
33
|
+
|
|
34
|
+
const fm = fmMatch[1];
|
|
35
|
+
|
|
36
|
+
const unquote = (s: string) => s.replace(/^["']|["']$/g, "");
|
|
37
|
+
const id = unquote(fm.match(/^id:\s*(.+)$/m)?.[1]?.trim() ?? "");
|
|
38
|
+
const title = unquote(fm.match(/^title:\s*(.+)$/m)?.[1]?.trim() ?? "");
|
|
39
|
+
const status = unquote(fm.match(/^status:\s*(.+)$/m)?.[1]?.trim() ?? "ready");
|
|
40
|
+
const priority = unquote(fm.match(/^priority:\s*(.+)$/m)?.[1]?.trim() ?? "medium");
|
|
41
|
+
const createdAt = unquote(fm.match(/^created_at:\s*(.+)$/m)?.[1]?.trim() ?? "");
|
|
42
|
+
|
|
43
|
+
// depends_on: [033, 034] or depends_on: 033
|
|
44
|
+
let dependsOn: string[] = [];
|
|
45
|
+
const depsMatch = fm.match(/^depends_on:\s*(.+)$/m);
|
|
46
|
+
if (depsMatch?.[1]) {
|
|
47
|
+
const raw = depsMatch[1].trim();
|
|
48
|
+
if (raw.startsWith("[")) {
|
|
49
|
+
// YAML array: [033, 034]
|
|
50
|
+
dependsOn = raw
|
|
51
|
+
.replace(/[\[\]]/g, "")
|
|
52
|
+
.split(",")
|
|
53
|
+
.map((s) => s.trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
} else {
|
|
56
|
+
// single value: 033
|
|
57
|
+
dependsOn = [raw.trim()];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
id: id || fileName.match(/^(\d+)/)?.[1] || "",
|
|
63
|
+
title,
|
|
64
|
+
status,
|
|
65
|
+
priority,
|
|
66
|
+
dependsOn,
|
|
67
|
+
filePath,
|
|
68
|
+
fileName,
|
|
69
|
+
createdAt,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* task-state.json の読み込み
|
|
75
|
+
*/
|
|
76
|
+
export async function loadTaskState(projectRoot: string): Promise<TaskStateMap> {
|
|
77
|
+
const filePath = join(projectRoot, ".team/task-state.json");
|
|
78
|
+
if (!existsSync(filePath)) return {};
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* task-state.json の書き込み
|
|
88
|
+
*/
|
|
89
|
+
export async function saveTaskState(projectRoot: string, state: TaskStateMap): Promise<void> {
|
|
90
|
+
const filePath = join(projectRoot, ".team/task-state.json");
|
|
91
|
+
await writeFile(filePath, JSON.stringify(state, null, 2) + "\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* フラットな tasks/ からタスクを読み込み、task-state.json で状態を上書き
|
|
96
|
+
*/
|
|
97
|
+
export async function loadTasks(projectRoot: string): Promise<{
|
|
98
|
+
tasks: TaskMeta[];
|
|
99
|
+
taskState: TaskStateMap;
|
|
100
|
+
}> {
|
|
101
|
+
const tasksDir = join(projectRoot, ".team/tasks");
|
|
102
|
+
const taskState = await loadTaskState(projectRoot);
|
|
103
|
+
const tasks: TaskMeta[] = [];
|
|
104
|
+
|
|
105
|
+
if (existsSync(tasksDir)) {
|
|
106
|
+
const files = await readdir(tasksDir);
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
if (!f.endsWith(".md")) continue;
|
|
109
|
+
const filePath = join(tasksDir, f);
|
|
110
|
+
const content = await readFile(filePath, "utf-8");
|
|
111
|
+
const meta = parseTaskMeta(content, f, filePath);
|
|
112
|
+
if (meta) {
|
|
113
|
+
// task-state.json の状態で上書き(後方互換: なければファイルの frontmatter 値を使用)
|
|
114
|
+
if (taskState[meta.id]) {
|
|
115
|
+
meta.status = taskState[meta.id]!.status;
|
|
116
|
+
}
|
|
117
|
+
tasks.push(meta);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { tasks, taskState };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 実行可能なタスクをフィルタリング
|
|
127
|
+
* - status: ready であること
|
|
128
|
+
* - depends_on の全タスクが closed に存在すること
|
|
129
|
+
*/
|
|
130
|
+
export function filterExecutableTasks(
|
|
131
|
+
tasks: TaskMeta[],
|
|
132
|
+
closedIds: Set<string>,
|
|
133
|
+
assignedIds: Set<string>
|
|
134
|
+
): TaskMeta[] {
|
|
135
|
+
return tasks.filter((task) => {
|
|
136
|
+
// status チェック
|
|
137
|
+
if (task.status !== "ready") return false;
|
|
138
|
+
|
|
139
|
+
// 既にアサイン済み
|
|
140
|
+
if (assignedIds.has(task.id)) return false;
|
|
141
|
+
|
|
142
|
+
// 依存チェック
|
|
143
|
+
if (task.dependsOn.length > 0) {
|
|
144
|
+
const allDepsResolved = task.dependsOn.every((dep) => closedIds.has(dep));
|
|
145
|
+
if (!allDepsResolved) return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 優先度ソート(high > medium > low)
|
|
154
|
+
*/
|
|
155
|
+
export function sortByPriority(tasks: TaskMeta[]): TaskMeta[] {
|
|
156
|
+
const order: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
|
157
|
+
return [...tasks].sort(
|
|
158
|
+
(a, b) => (order[a.priority] ?? 1) - (order[b.priority] ?? 1)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* テンプレート検索・変数展開
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { readFile, writeFile, mkdir, cp } from "fs/promises";
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
|
|
8
|
+
export function findTemplateDir(): string | null {
|
|
9
|
+
// 1. daemon 自身からの相対パス(manager/ の兄弟 templates/)
|
|
10
|
+
// manager/template.ts → ../templates/
|
|
11
|
+
const fromSelf = join(dirname(import.meta.path), "../templates");
|
|
12
|
+
if (existsSync(join(fromSelf, "master.md"))) return fromSelf;
|
|
13
|
+
|
|
14
|
+
// 2. プロジェクトローカル
|
|
15
|
+
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
|
|
16
|
+
const local = join(projectRoot, "skills/cmux-team/templates");
|
|
17
|
+
if (existsSync(join(local, "master.md"))) return local;
|
|
18
|
+
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function generateMasterPrompt(
|
|
23
|
+
projectRoot: string
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const promptsDir = join(projectRoot, ".team/prompts");
|
|
26
|
+
await mkdir(promptsDir, { recursive: true });
|
|
27
|
+
const dst = join(promptsDir, "master.md");
|
|
28
|
+
|
|
29
|
+
const templateDir = findTemplateDir();
|
|
30
|
+
if (!templateDir) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"Template directory not found. npm install -g cmux-team を実行してください"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await cp(join(templateDir, "master.md"), dst);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function generateConductorRolePrompt(
|
|
40
|
+
projectRoot: string
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const templateDir = findTemplateDir();
|
|
43
|
+
if (!templateDir || !existsSync(join(templateDir, "conductor-role.md"))) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"Conductor role template not found. npm install -g cmux-team を実行してください"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const promptsDir = join(projectRoot, ".team/prompts");
|
|
50
|
+
await mkdir(promptsDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const promptFile = join(promptsDir, "conductor-role.md");
|
|
53
|
+
|
|
54
|
+
let content = await readFile(join(templateDir, "conductor-role.md"), "utf-8");
|
|
55
|
+
content = content.replace(/\{\{PROJECT_ROOT\}\}/g, projectRoot);
|
|
56
|
+
|
|
57
|
+
await writeFile(promptFile, content);
|
|
58
|
+
return promptFile;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function generateConductorTaskPrompt(
|
|
62
|
+
projectRoot: string,
|
|
63
|
+
conductorId: string,
|
|
64
|
+
taskId: string,
|
|
65
|
+
taskContent: string,
|
|
66
|
+
worktreePath: string,
|
|
67
|
+
outputDir: string
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const templateDir = findTemplateDir();
|
|
70
|
+
if (!templateDir || !existsSync(join(templateDir, "conductor-task.md"))) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"Conductor task template not found. npm install -g cmux-team を実行してください"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const promptsDir = join(projectRoot, ".team/prompts");
|
|
77
|
+
await mkdir(promptsDir, { recursive: true });
|
|
78
|
+
|
|
79
|
+
const promptFile = join(promptsDir, `${conductorId}.md`);
|
|
80
|
+
|
|
81
|
+
let content = await readFile(join(templateDir, "conductor-task.md"), "utf-8");
|
|
82
|
+
|
|
83
|
+
content = content
|
|
84
|
+
.replace(/\{\{TASK_CONTENT\}\}/g, taskContent)
|
|
85
|
+
.replace(/\{\{WORKTREE_PATH\}\}/g, worktreePath)
|
|
86
|
+
.replace(/\{\{OUTPUT_DIR\}\}/g, join(projectRoot, outputDir))
|
|
87
|
+
.replace(/\{\{PROJECT_ROOT\}\}/g, projectRoot)
|
|
88
|
+
.replace(/\{\{CONDUCTOR_ID\}\}/g, conductorId);
|
|
89
|
+
|
|
90
|
+
await writeFile(promptFile, content);
|
|
91
|
+
return promptFile;
|
|
92
|
+
}
|