create-claude-kanban 1.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/dist/index.js +640 -0
- package/package.json +37 -0
- package/templates/agents/_base-sections.md +22 -0
- package/templates/kanban.cjs +1837 -0
- package/templates/kanban.html +2462 -0
- package/templates/orchestrator.md +24 -0
|
@@ -0,0 +1,1837 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* claude-kanban — Real-time agent kanban dashboard
|
|
4
|
+
* Run: claude-kanban or node bin/server.cjs
|
|
5
|
+
*
|
|
6
|
+
* ~/.claude/tasks/ watch → SSE browser auto-update
|
|
7
|
+
* Server-side CRUD — POST/PUT/DELETE /api/tasks
|
|
8
|
+
* Manual tasks → ~/.claude/tasks/kanban/
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ── Config Loading ──
|
|
12
|
+
// Load kanban.config.json from project root, fallback to env vars
|
|
13
|
+
var config;
|
|
14
|
+
var _configPath = require("path").join(__dirname, "..", "kanban.config.json");
|
|
15
|
+
try {
|
|
16
|
+
var _rawConfig = require("fs").readFileSync(_configPath, "utf-8");
|
|
17
|
+
var _fileConfig = JSON.parse(_rawConfig);
|
|
18
|
+
try { require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") }); } catch {}
|
|
19
|
+
config = {
|
|
20
|
+
port: parseInt(process.env.PORT) || _fileConfig.port || 4040,
|
|
21
|
+
projectName: _fileConfig.projectName || "Project",
|
|
22
|
+
slack: {
|
|
23
|
+
webhookUrl: process.env.SLACK_AGENT_WEBHOOK || "",
|
|
24
|
+
botToken: process.env.SLACK_BOT_TOKEN || "",
|
|
25
|
+
appToken: process.env.SLACK_APP_TOKEN || "",
|
|
26
|
+
channelId: process.env.SLACK_CHANNEL_ID || "",
|
|
27
|
+
adminUsers: (process.env.SLACK_ADMIN_USERS || "").split(",").filter(Boolean),
|
|
28
|
+
command: process.env.SLACK_COMMAND || (_fileConfig.slack && _fileConfig.slack.command) || "/kanban",
|
|
29
|
+
},
|
|
30
|
+
agents: _fileConfig.agents || {},
|
|
31
|
+
paths: _fileConfig.paths || { agents: ".claude/agents", tasks: ".claude/tasks", logs: ".claude/logs" },
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
try { require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") }); } catch {}
|
|
35
|
+
config = {
|
|
36
|
+
port: parseInt(process.env.PORT) || 4040,
|
|
37
|
+
projectName: "Project",
|
|
38
|
+
slack: {
|
|
39
|
+
webhookUrl: process.env.SLACK_AGENT_WEBHOOK || "",
|
|
40
|
+
botToken: process.env.SLACK_BOT_TOKEN || "",
|
|
41
|
+
appToken: process.env.SLACK_APP_TOKEN || "",
|
|
42
|
+
channelId: process.env.SLACK_CHANNEL_ID || "",
|
|
43
|
+
adminUsers: (process.env.SLACK_ADMIN_USERS || "").split(",").filter(Boolean),
|
|
44
|
+
command: process.env.SLACK_COMMAND || "/kanban",
|
|
45
|
+
},
|
|
46
|
+
agents: {},
|
|
47
|
+
paths: { agents: ".claude/agents", tasks: ".claude/tasks", logs: ".claude/logs" },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const http = require("http");
|
|
52
|
+
const fs = require("fs");
|
|
53
|
+
const path = require("path");
|
|
54
|
+
const os = require("os");
|
|
55
|
+
const { execSync, spawn } = require("child_process");
|
|
56
|
+
|
|
57
|
+
const PORT = config.port;
|
|
58
|
+
const PROJECT_NAME = config.projectName;
|
|
59
|
+
const TASKS_DIR = path.join(os.homedir(), ".claude", "tasks");
|
|
60
|
+
const KANBAN_DIR = path.join(TASKS_DIR, "kanban");
|
|
61
|
+
const ACTIVITY_FILE = path.join(TASKS_DIR, "activity.jsonl");
|
|
62
|
+
|
|
63
|
+
if (!fs.existsSync(KANBAN_DIR)) {
|
|
64
|
+
fs.mkdirSync(KANBAN_DIR, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SLACK_WEBHOOK = config.slack.webhookUrl;
|
|
68
|
+
const SLACK_BOT_TOKEN = config.slack.botToken;
|
|
69
|
+
const SLACK_APP_TOKEN = config.slack.appToken;
|
|
70
|
+
const SLACK_CHANNEL_ID = config.slack.channelId;
|
|
71
|
+
const SLACK_ADMIN_USERS = config.slack.adminUsers;
|
|
72
|
+
const SLACK_COMMAND = config.slack.command;
|
|
73
|
+
let slackApp = null;
|
|
74
|
+
let slackAskActive = false;
|
|
75
|
+
|
|
76
|
+
// ── Slack Thread Management ──
|
|
77
|
+
const threadMap = new Map(); // taskId → slackThreadTs
|
|
78
|
+
|
|
79
|
+
function loadThreadMap() {
|
|
80
|
+
// Load slackThreadTs from all existing task JSON files
|
|
81
|
+
var tasks = [];
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(TASKS_DIR)) {
|
|
84
|
+
var sessions = fs.readdirSync(TASKS_DIR, { withFileTypes: true });
|
|
85
|
+
for (var s = 0; s < sessions.length; s++) {
|
|
86
|
+
if (!sessions[s].isDirectory()) continue;
|
|
87
|
+
var sp = path.join(TASKS_DIR, sessions[s].name);
|
|
88
|
+
var files = fs.readdirSync(sp).filter(function (f) { return f.endsWith(".json"); });
|
|
89
|
+
for (var fi = 0; fi < files.length; fi++) {
|
|
90
|
+
try {
|
|
91
|
+
var raw = fs.readFileSync(path.join(sp, files[fi]), "utf-8");
|
|
92
|
+
var t = JSON.parse(raw);
|
|
93
|
+
if (t.id && t.slackThreadTs) {
|
|
94
|
+
threadMap.set(String(t.id), t.slackThreadTs);
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
if (threadMap.size > 0) {
|
|
102
|
+
console.log(" Slack threads: loaded " + threadMap.size + " thread mappings");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function setThreadTs(taskId, ts) {
|
|
107
|
+
if (!ts) return;
|
|
108
|
+
threadMap.set(String(taskId), ts);
|
|
109
|
+
// Persist to task JSON
|
|
110
|
+
var filePath = findTaskFile(String(taskId));
|
|
111
|
+
if (filePath) {
|
|
112
|
+
try {
|
|
113
|
+
var task = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
114
|
+
task.slackThreadTs = ts;
|
|
115
|
+
fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getThreadTs(taskId) {
|
|
121
|
+
return threadMap.get(String(taskId)) || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Chat Session ──
|
|
125
|
+
let chatSessionId = null;
|
|
126
|
+
let chatSessionModel = null;
|
|
127
|
+
|
|
128
|
+
// ── Orchestrator Prompt System ──
|
|
129
|
+
const ORCHESTRATOR_FILE = path.join(os.homedir(), ".claude", "orchestrator.md");
|
|
130
|
+
const ORCHESTRATOR_LOG = path.join(os.homedir(), ".claude", "orchestrator-history.jsonl");
|
|
131
|
+
|
|
132
|
+
function readOrchestratorPrompt() {
|
|
133
|
+
try { return fs.readFileSync(ORCHESTRATOR_FILE, "utf-8"); } catch { return ""; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readOrchestratorHistory(limit) {
|
|
137
|
+
if (!fs.existsSync(ORCHESTRATOR_LOG)) return "";
|
|
138
|
+
try {
|
|
139
|
+
var lines = fs.readFileSync(ORCHESTRATOR_LOG, "utf-8").trim().split("\n").filter(Boolean);
|
|
140
|
+
var recent = lines.slice(-1 * (limit || 10));
|
|
141
|
+
return recent.map(function (line) {
|
|
142
|
+
try {
|
|
143
|
+
var e = JSON.parse(line);
|
|
144
|
+
return "[" + e.ts + "] " + (e.role || "system") + ": " + e.content;
|
|
145
|
+
} catch { return ""; }
|
|
146
|
+
}).filter(Boolean).join("\n");
|
|
147
|
+
} catch { return ""; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function appendOrchestratorHistory(role, content) {
|
|
151
|
+
var entry = { ts: new Date().toISOString(), role: role, content: content.slice(0, 500) };
|
|
152
|
+
try { fs.appendFileSync(ORCHESTRATOR_LOG, JSON.stringify(entry) + "\n"); } catch {}
|
|
153
|
+
// Trim to 500 entries
|
|
154
|
+
try {
|
|
155
|
+
var lines = fs.readFileSync(ORCHESTRATOR_LOG, "utf-8").trim().split("\n");
|
|
156
|
+
if (lines.length > 600) {
|
|
157
|
+
fs.writeFileSync(ORCHESTRATOR_LOG, lines.slice(-500).join("\n") + "\n");
|
|
158
|
+
}
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function extractAndSaveLearnings(text) {
|
|
163
|
+
var regex = /<!--\s*LEARN:\s*(.*?)\s*-->/g;
|
|
164
|
+
var match;
|
|
165
|
+
var learnings = [];
|
|
166
|
+
while ((match = regex.exec(text)) !== null) {
|
|
167
|
+
learnings.push(match[1].trim());
|
|
168
|
+
}
|
|
169
|
+
if (learnings.length === 0) return;
|
|
170
|
+
try {
|
|
171
|
+
var content = fs.readFileSync(ORCHESTRATOR_FILE, "utf-8");
|
|
172
|
+
var marker = "## Learnings";
|
|
173
|
+
var idx = content.indexOf(marker);
|
|
174
|
+
if (idx >= 0) {
|
|
175
|
+
var ts = new Date().toISOString().slice(0, 10);
|
|
176
|
+
var additions = learnings.map(function (l) { return "- [" + ts + "] " + l; }).join("\n");
|
|
177
|
+
var insertPos = content.indexOf("\n", idx + marker.length);
|
|
178
|
+
if (insertPos < 0) insertPos = content.length;
|
|
179
|
+
// Find end of the comment line after marker
|
|
180
|
+
var afterMarker = content.indexOf("\n", insertPos + 1);
|
|
181
|
+
if (afterMarker < 0) afterMarker = content.length;
|
|
182
|
+
content = content.slice(0, afterMarker) + "\n" + additions + content.slice(afterMarker);
|
|
183
|
+
fs.writeFileSync(ORCHESTRATOR_FILE, content);
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Action Block Parser ──
|
|
189
|
+
function parseActionBlocks(text) {
|
|
190
|
+
var actions = [];
|
|
191
|
+
var cleaned = text.replace(/:::action\n([\s\S]*?)\n:::/g, function (match, json) {
|
|
192
|
+
try {
|
|
193
|
+
actions.push(JSON.parse(json.trim()));
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return match; // malformed — leave as-is
|
|
196
|
+
}
|
|
197
|
+
return "";
|
|
198
|
+
});
|
|
199
|
+
return { cleaned: cleaned.trim(), actions: actions };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function executeAction(action) {
|
|
203
|
+
var results = [];
|
|
204
|
+
switch (action.type) {
|
|
205
|
+
case "task_create": {
|
|
206
|
+
var task = createTask(action.data || {});
|
|
207
|
+
results.push({ action: "task_create", ok: true, task: { id: task.id, subject: task.subject } });
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "task_update": {
|
|
211
|
+
var task = updateTask(String(action.id), action.data || {});
|
|
212
|
+
results.push({ action: "task_update", ok: !!task, task: task ? { id: task.id, subject: task.subject } : null });
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "task_delete": {
|
|
216
|
+
var ok = deleteTask(String(action.id));
|
|
217
|
+
results.push({ action: "task_delete", ok: ok, id: String(action.id) });
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "task_execute": {
|
|
221
|
+
var t = readAllTasks().find(function (t) { return String(t.id) === String(action.id); });
|
|
222
|
+
if (t) {
|
|
223
|
+
if (t.status === "pending") updateTask(String(action.id), { status: "in_progress" });
|
|
224
|
+
else enqueueExec(t);
|
|
225
|
+
results.push({ action: "task_execute", ok: true, id: String(action.id) });
|
|
226
|
+
} else {
|
|
227
|
+
results.push({ action: "task_execute", ok: false, id: String(action.id) });
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "task_execute_batch": {
|
|
232
|
+
var ids = action.ids || [];
|
|
233
|
+
for (var i = 0; i < ids.length; i++) {
|
|
234
|
+
var bt = readAllTasks().find(function (t) { return String(t.id) === String(ids[i]); });
|
|
235
|
+
if (bt) {
|
|
236
|
+
if (bt.status === "pending") updateTask(String(ids[i]), { status: "in_progress" });
|
|
237
|
+
else enqueueExec(bt);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
results.push({ action: "task_execute_batch", ok: true, ids: ids });
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildChatSystemPrompt(tasks, projectName) {
|
|
248
|
+
var orchestratorPrompt = readOrchestratorPrompt();
|
|
249
|
+
var history = readOrchestratorHistory(5);
|
|
250
|
+
var taskDetail = tasks.map(function (t) {
|
|
251
|
+
var line = "#" + t.id + " [" + t.status + "] " + t.subject;
|
|
252
|
+
if (t.agent) line += " (agent:" + t.agent + ")";
|
|
253
|
+
if (t.owner) line += " (owner:" + t.owner + ")";
|
|
254
|
+
if (t.priority === "high" || t.priority === "critical") line += " [" + t.priority.toUpperCase() + "]";
|
|
255
|
+
if (t.blockedBy && t.blockedBy.length) line += " blocked-by:" + t.blockedBy.join(",");
|
|
256
|
+
if (t.activeForm) line += " — " + t.activeForm;
|
|
257
|
+
return line;
|
|
258
|
+
}).join("\n");
|
|
259
|
+
|
|
260
|
+
var prompt = "";
|
|
261
|
+
if (orchestratorPrompt) {
|
|
262
|
+
prompt += orchestratorPrompt + "\n\n";
|
|
263
|
+
} else {
|
|
264
|
+
prompt += "You are the Orchestrator for the " + projectName + " Kanban board.\n\n";
|
|
265
|
+
}
|
|
266
|
+
prompt += "## Current Board State\n";
|
|
267
|
+
prompt += "Project: " + projectName + "\n";
|
|
268
|
+
prompt += "Working directory: " + path.join(__dirname, "..") + "\n\n";
|
|
269
|
+
prompt += (taskDetail || "(보드 비어있음)") + "\n\n";
|
|
270
|
+
if (history) {
|
|
271
|
+
prompt += "## Recent Orchestrator Decisions\n" + history + "\n\n";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Action block instructions
|
|
275
|
+
prompt += "## Task Actions\n";
|
|
276
|
+
prompt += "You can create, update, delete, and execute tasks directly using action blocks.\n";
|
|
277
|
+
prompt += "Include action blocks in your response to manipulate the board:\n\n";
|
|
278
|
+
prompt += "Create task:\n:::action\n";
|
|
279
|
+
prompt += '{"type":"task_create","data":{"subject":"제목","description":"상세 설명","priority":"medium","agent":"frontend"}}\n';
|
|
280
|
+
prompt += ":::\n\n";
|
|
281
|
+
prompt += "Update task:\n:::action\n";
|
|
282
|
+
prompt += '{"type":"task_update","id":"15","data":{"status":"in_progress","activeForm":"진행 중 메시지"}}\n';
|
|
283
|
+
prompt += ":::\n\n";
|
|
284
|
+
prompt += "Delete task:\n:::action\n";
|
|
285
|
+
prompt += '{"type":"task_delete","id":"15"}\n';
|
|
286
|
+
prompt += ":::\n\n";
|
|
287
|
+
prompt += "Execute task (starts agent):\n:::action\n";
|
|
288
|
+
prompt += '{"type":"task_execute","id":"15"}\n';
|
|
289
|
+
prompt += ":::\n\n";
|
|
290
|
+
prompt += "Batch execute:\n:::action\n";
|
|
291
|
+
prompt += '{"type":"task_execute_batch","ids":["15","16","17"]}\n';
|
|
292
|
+
prompt += ":::\n\n";
|
|
293
|
+
prompt += "After each action block, write a natural language confirmation.\n";
|
|
294
|
+
prompt += "When the user says '실행해', '시작해', 'execute' — create tasks and execute immediately.\n";
|
|
295
|
+
prompt += "For broad/ambiguous requests, present a plan and wait for confirmation.\n\n";
|
|
296
|
+
|
|
297
|
+
prompt += "## Agent Roles & Models\n";
|
|
298
|
+
prompt += "| Agent | Model |\n";
|
|
299
|
+
prompt += "|-------|-------|\n";
|
|
300
|
+
if (config.agents) {
|
|
301
|
+
Object.keys(config.agents).forEach(function (name) {
|
|
302
|
+
prompt += "| " + name + " | " + (config.agents[name].model || "sonnet") + " |\n";
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
prompt += "\n";
|
|
306
|
+
|
|
307
|
+
prompt += "## Chat Formatting Rules\n";
|
|
308
|
+
prompt += "- Do NOT wrap plain words in backticks. Only use backticks for actual code snippets or CLI commands.\n";
|
|
309
|
+
prompt += "- Keep responses concise. Use plain text for normal conversation.\n";
|
|
310
|
+
prompt += "- Tables are OK for structured data. Avoid excessive bold/headers.\n\n";
|
|
311
|
+
return prompt;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function boardSummary(tasks) {
|
|
315
|
+
const pending = tasks.filter(t => t.status === "pending");
|
|
316
|
+
const inProgress = tasks.filter(t => t.status === "in_progress");
|
|
317
|
+
const completed = tasks.filter(t => t.status === "completed");
|
|
318
|
+
const total = tasks.length;
|
|
319
|
+
const pct = total ? Math.round((completed.length / total) * 100) : 0;
|
|
320
|
+
let summary = `📊 Board: ${completed.length}✅ ${inProgress.length}🔄 ${pending.length}⏳ (${pct}% done)`;
|
|
321
|
+
if (inProgress.length > 0) {
|
|
322
|
+
summary += "\n🔄 *In progress:* " + inProgress.map(t => `#${t.id} ${t.subject}${t.activeForm ? " — _" + t.activeForm + "_" : ""}`).join(", ");
|
|
323
|
+
}
|
|
324
|
+
if (pending.length > 0 && pending.length <= 5) {
|
|
325
|
+
summary += "\n⏳ *Up next:* " + pending.map(t => `#${t.id} ${t.subject}`).join(", ");
|
|
326
|
+
} else if (pending.length > 5) {
|
|
327
|
+
summary += "\n⏳ *Up next:* " + pending.slice(0, 3).map(t => `#${t.id} ${t.subject}`).join(", ") + ` +${pending.length - 3} more`;
|
|
328
|
+
}
|
|
329
|
+
return summary;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function slackPost(text, threadTs) {
|
|
333
|
+
// Prefer Slack Bot API (supports threads)
|
|
334
|
+
if (slackApp && SLACK_CHANNEL_ID) {
|
|
335
|
+
try {
|
|
336
|
+
var opts = { channel: SLACK_CHANNEL_ID, text: text };
|
|
337
|
+
if (threadTs) opts.thread_ts = threadTs;
|
|
338
|
+
var result = await slackApp.client.chat.postMessage(opts);
|
|
339
|
+
if (result.ts) console.log(" [slack-thread] posted ts=" + result.ts + (threadTs ? " (reply to " + threadTs + ")" : " (parent)"));
|
|
340
|
+
return result.ts || null;
|
|
341
|
+
} catch (e) {
|
|
342
|
+
// Auto-join channel on not_in_channel error
|
|
343
|
+
var errCode = (e.data && e.data.error) || "";
|
|
344
|
+
if (errCode === "not_in_channel") {
|
|
345
|
+
try {
|
|
346
|
+
await slackApp.client.conversations.join({ channel: SLACK_CHANNEL_ID });
|
|
347
|
+
console.log(" [slack-thread] joined channel " + SLACK_CHANNEL_ID);
|
|
348
|
+
var opts2 = { channel: SLACK_CHANNEL_ID, text: text };
|
|
349
|
+
if (threadTs) opts2.thread_ts = threadTs;
|
|
350
|
+
var result2 = await slackApp.client.chat.postMessage(opts2);
|
|
351
|
+
if (result2.ts) console.log(" [slack-thread] posted ts=" + result2.ts + (threadTs ? " (reply to " + threadTs + ")" : " (parent)"));
|
|
352
|
+
return result2.ts || null;
|
|
353
|
+
} catch (e2) {
|
|
354
|
+
console.log(" [slack-thread] join failed, trying invite hint: " + e2.message);
|
|
355
|
+
console.log(" [slack-thread] Please invite the bot to the channel: /invite @bot-name");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
console.log(" [slack-thread] Bot API error: " + (errCode || e.message) + " — falling back to webhook");
|
|
359
|
+
// Fall through to webhook fallback below
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Fallback to webhook (no thread support)
|
|
363
|
+
if (!SLACK_WEBHOOK) return null;
|
|
364
|
+
const payload = JSON.stringify({ text: text });
|
|
365
|
+
const url = new URL(SLACK_WEBHOOK);
|
|
366
|
+
const options = {
|
|
367
|
+
hostname: url.hostname,
|
|
368
|
+
path: url.pathname,
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
|
371
|
+
};
|
|
372
|
+
const req = require("https").request(options);
|
|
373
|
+
req.on("error", () => {});
|
|
374
|
+
req.write(payload);
|
|
375
|
+
req.end();
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Legacy sync wrapper for places that don't need thread support
|
|
380
|
+
function slackNotify(text) {
|
|
381
|
+
slackPost(text, null);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Activity Log ──
|
|
385
|
+
function logActivity(evt) {
|
|
386
|
+
const ts = new Date().toISOString();
|
|
387
|
+
const record = { ts, ...evt };
|
|
388
|
+
|
|
389
|
+
// 1. Append to JSONL
|
|
390
|
+
try {
|
|
391
|
+
fs.appendFileSync(ACTIVITY_FILE, JSON.stringify(record) + "\n");
|
|
392
|
+
} catch {
|
|
393
|
+
try { fs.mkdirSync(path.dirname(ACTIVITY_FILE), { recursive: true }); } catch {}
|
|
394
|
+
try { fs.appendFileSync(ACTIVITY_FILE, JSON.stringify(record) + "\n"); } catch {}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 2. Trim to 1000 lines
|
|
398
|
+
try {
|
|
399
|
+
const content = fs.readFileSync(ACTIVITY_FILE, "utf-8");
|
|
400
|
+
const lines = content.trim().split("\n");
|
|
401
|
+
if (lines.length > 1200) {
|
|
402
|
+
fs.writeFileSync(ACTIVITY_FILE, lines.slice(lines.length - 1000).join("\n") + "\n");
|
|
403
|
+
}
|
|
404
|
+
} catch {}
|
|
405
|
+
|
|
406
|
+
// 3. SSE broadcast (activity event — no dedup)
|
|
407
|
+
const msg = "data: " + JSON.stringify({ type: "activity", event: record }) + "\n\n";
|
|
408
|
+
for (const res of sseClients) {
|
|
409
|
+
try { res.write(msg); } catch { sseClients.delete(res); }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 4. Slack notification (threaded)
|
|
413
|
+
if (evt._skipSlack) return; // Skip if already sent by agent report API
|
|
414
|
+
|
|
415
|
+
const allTasks = readAllTasks();
|
|
416
|
+
let slackMsg = "";
|
|
417
|
+
let isParent = false; // true = new parent message (created), false = thread reply
|
|
418
|
+
if (evt.type === "created") {
|
|
419
|
+
isParent = true;
|
|
420
|
+
const summary = boardSummary(allTasks);
|
|
421
|
+
slackMsg = `🆕 *New Task #${evt.taskId}*: ${evt.subject}`;
|
|
422
|
+
if (evt.description) slackMsg += "\n> " + evt.description.split("\n")[0].slice(0, 120);
|
|
423
|
+
if (evt.priority === "high") slackMsg += "\n🔴 Priority: HIGH";
|
|
424
|
+
if (evt.owner) slackMsg += "\n👤 Assigned: " + evt.owner;
|
|
425
|
+
if (evt.parentId) slackMsg += "\n↳ Subtask of #" + evt.parentId;
|
|
426
|
+
slackMsg += "\n" + summary;
|
|
427
|
+
} else if (evt.type === "started") {
|
|
428
|
+
slackMsg = `▶️ *Task #${evt.taskId} Started*: ${evt.subject}`;
|
|
429
|
+
if (evt.owner) slackMsg += "\n👤 " + evt.owner;
|
|
430
|
+
if (evt.activeForm) slackMsg += "\n💬 _" + evt.activeForm + "_";
|
|
431
|
+
if (evt.description) slackMsg += "\n> " + evt.description.split("\n")[0].slice(0, 120);
|
|
432
|
+
} else if (evt.type === "completed") {
|
|
433
|
+
slackMsg = `✅ *Task #${evt.taskId} Done*: ${evt.subject}`;
|
|
434
|
+
if (evt.reportSummary) slackMsg += "\n> " + evt.reportSummary.split("\n")[0].slice(0, 200);
|
|
435
|
+
if (evt.reportPath) slackMsg += "\n📄 Report: \`" + evt.reportPath + "\`";
|
|
436
|
+
if (evt.parentId) slackMsg += "\n↳ Subtask of #" + evt.parentId;
|
|
437
|
+
} else if (evt.type === "deleted") {
|
|
438
|
+
slackMsg = `🗑️ *Task #${evt.taskId} Deleted*: ${evt.subject}`;
|
|
439
|
+
} else if (evt.type === "updated") {
|
|
440
|
+
slackMsg = `📝 *Task #${evt.taskId} Updated*: ${evt.subject}`;
|
|
441
|
+
if (evt.detail) slackMsg += "\n> " + evt.detail;
|
|
442
|
+
}
|
|
443
|
+
if (slackMsg) {
|
|
444
|
+
if (isParent) {
|
|
445
|
+
// New parent message — save thread ts
|
|
446
|
+
slackPost(slackMsg, null).then(function (ts) {
|
|
447
|
+
if (ts && evt.taskId) setThreadTs(evt.taskId, ts);
|
|
448
|
+
});
|
|
449
|
+
} else {
|
|
450
|
+
// Thread reply
|
|
451
|
+
var threadTs = evt.taskId ? getThreadTs(evt.taskId) : null;
|
|
452
|
+
if (threadTs) {
|
|
453
|
+
slackPost(slackMsg, threadTs);
|
|
454
|
+
} else {
|
|
455
|
+
// Legacy task without thread — create parent, save ts
|
|
456
|
+
slackPost(slackMsg, null).then(function (ts) {
|
|
457
|
+
if (ts && evt.taskId) setThreadTs(evt.taskId, ts);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function readActivity(since, limit) {
|
|
465
|
+
if (!fs.existsSync(ACTIVITY_FILE)) return [];
|
|
466
|
+
try {
|
|
467
|
+
const lines = fs.readFileSync(ACTIVITY_FILE, "utf-8").trim().split("\n").filter(Boolean);
|
|
468
|
+
let events = lines.map(line => {
|
|
469
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
470
|
+
}).filter(Boolean);
|
|
471
|
+
if (since) {
|
|
472
|
+
events = events.filter(e => e.ts > since);
|
|
473
|
+
}
|
|
474
|
+
events.reverse();
|
|
475
|
+
if (limit) events = events.slice(0, limit);
|
|
476
|
+
return events;
|
|
477
|
+
} catch { return []; }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Task 파일 읽기 ──
|
|
481
|
+
function readAllTasks() {
|
|
482
|
+
const tasks = [];
|
|
483
|
+
if (!fs.existsSync(TASKS_DIR)) return tasks;
|
|
484
|
+
try {
|
|
485
|
+
const sessions = fs.readdirSync(TASKS_DIR, { withFileTypes: true });
|
|
486
|
+
for (const session of sessions) {
|
|
487
|
+
if (!session.isDirectory()) continue;
|
|
488
|
+
const sessionPath = path.join(TASKS_DIR, session.name);
|
|
489
|
+
const files = fs.readdirSync(sessionPath).filter(
|
|
490
|
+
(f) => f.endsWith(".json") && f !== ".lock"
|
|
491
|
+
);
|
|
492
|
+
for (const file of files) {
|
|
493
|
+
try {
|
|
494
|
+
const filePath = path.join(sessionPath, file);
|
|
495
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
496
|
+
const task = JSON.parse(raw);
|
|
497
|
+
task._session = session.name;
|
|
498
|
+
task._file = file;
|
|
499
|
+
task._mtime = fs.statSync(filePath).mtimeMs;
|
|
500
|
+
task._editable = session.name === "kanban";
|
|
501
|
+
tasks.push(task);
|
|
502
|
+
} catch {}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
} catch {}
|
|
506
|
+
return tasks.sort((a, b) => (a.id || 0) - (b.id || 0));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Kanban 태스크 CRUD ──
|
|
510
|
+
function getNextId() {
|
|
511
|
+
const files = fs.existsSync(KANBAN_DIR)
|
|
512
|
+
? fs.readdirSync(KANBAN_DIR).filter((f) => f.endsWith(".json"))
|
|
513
|
+
: [];
|
|
514
|
+
let max = 0;
|
|
515
|
+
for (const f of files) {
|
|
516
|
+
const n = parseInt(f.replace(".json", ""), 10);
|
|
517
|
+
if (n > max) max = n;
|
|
518
|
+
}
|
|
519
|
+
return max + 1;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function createTask(data) {
|
|
523
|
+
const id = getNextId();
|
|
524
|
+
const now = new Date().toISOString();
|
|
525
|
+
const task = {
|
|
526
|
+
id: String(id),
|
|
527
|
+
subject: data.subject || "Untitled",
|
|
528
|
+
description: data.description || "",
|
|
529
|
+
status: data.status || "pending",
|
|
530
|
+
priority: data.priority || "medium",
|
|
531
|
+
agent: data.agent || "",
|
|
532
|
+
owner: data.owner || "",
|
|
533
|
+
activeForm: data.activeForm || "",
|
|
534
|
+
blockedBy: data.blockedBy || [],
|
|
535
|
+
blocks: data.blocks || [],
|
|
536
|
+
parentId: data.parentId || null,
|
|
537
|
+
reportPath: data.reportPath || null,
|
|
538
|
+
reportSummary: data.reportSummary || null,
|
|
539
|
+
slackThreadTs: null,
|
|
540
|
+
createdAt: now,
|
|
541
|
+
updatedAt: now,
|
|
542
|
+
};
|
|
543
|
+
const filePath = path.join(KANBAN_DIR, id + ".json");
|
|
544
|
+
fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
|
|
545
|
+
// Update snapshot so file watcher won't double-notify
|
|
546
|
+
taskSnapshot.set(String(id), { status: task.status, subject: task.subject, owner: task.owner || "", reportSummary: "" });
|
|
547
|
+
logActivity({
|
|
548
|
+
type: "created", taskId: String(id), subject: task.subject,
|
|
549
|
+
agent: task.agent || task.owner || "", detail: task.priority === "high" ? "Priority: high" : "",
|
|
550
|
+
description: task.description, priority: task.priority, owner: task.owner, parentId: task.parentId,
|
|
551
|
+
});
|
|
552
|
+
return task;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function findTaskFile(id) {
|
|
556
|
+
// Search kanban dir first, then all session dirs
|
|
557
|
+
const kanbanPath = path.join(KANBAN_DIR, id + ".json");
|
|
558
|
+
if (fs.existsSync(kanbanPath)) return kanbanPath;
|
|
559
|
+
try {
|
|
560
|
+
const sessions = fs.readdirSync(TASKS_DIR, { withFileTypes: true });
|
|
561
|
+
for (const session of sessions) {
|
|
562
|
+
if (!session.isDirectory()) continue;
|
|
563
|
+
const fp = path.join(TASKS_DIR, session.name, id + ".json");
|
|
564
|
+
if (fs.existsSync(fp)) return fp;
|
|
565
|
+
}
|
|
566
|
+
} catch {}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function updateTask(id, data) {
|
|
571
|
+
const filePath = findTaskFile(id);
|
|
572
|
+
if (!filePath) return null;
|
|
573
|
+
const task = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
574
|
+
const now = new Date().toISOString();
|
|
575
|
+
var prevStatus = task.status;
|
|
576
|
+
var statusChanged = false;
|
|
577
|
+
if (data.status !== undefined && data.status !== task.status) {
|
|
578
|
+
statusChanged = true;
|
|
579
|
+
task.status = data.status;
|
|
580
|
+
if (data.status === "in_progress" && !task.startedAt) task.startedAt = now;
|
|
581
|
+
if (data.status === "completed") task.completedAt = now;
|
|
582
|
+
}
|
|
583
|
+
if (data.subject !== undefined) task.subject = data.subject;
|
|
584
|
+
if (data.description !== undefined) task.description = data.description;
|
|
585
|
+
if (data.priority !== undefined) task.priority = data.priority;
|
|
586
|
+
if (data.agent !== undefined) task.agent = data.agent;
|
|
587
|
+
if (data.owner !== undefined) task.owner = data.owner;
|
|
588
|
+
if (data.activeForm !== undefined) task.activeForm = data.activeForm;
|
|
589
|
+
if (data.blockedBy !== undefined) task.blockedBy = data.blockedBy;
|
|
590
|
+
if (data.blocks !== undefined) task.blocks = data.blocks;
|
|
591
|
+
if (data.reportPath !== undefined) task.reportPath = data.reportPath;
|
|
592
|
+
if (data.reportSummary !== undefined) task.reportSummary = data.reportSummary;
|
|
593
|
+
if (data.parentId !== undefined) task.parentId = data.parentId;
|
|
594
|
+
task.updatedAt = now;
|
|
595
|
+
fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
|
|
596
|
+
|
|
597
|
+
// Update snapshot so file watcher won't double-notify
|
|
598
|
+
taskSnapshot.set(String(id), { status: task.status, subject: task.subject, owner: task.owner || "", reportSummary: task.reportSummary || "" });
|
|
599
|
+
|
|
600
|
+
// Activity log (replaces direct Slack calls)
|
|
601
|
+
if (statusChanged) {
|
|
602
|
+
if (data.status === "in_progress" && prevStatus === "pending") {
|
|
603
|
+
logActivity({
|
|
604
|
+
type: "started", taskId: String(id), subject: task.subject,
|
|
605
|
+
agent: task.agent || task.owner || "", detail: task.activeForm || "",
|
|
606
|
+
owner: task.owner, activeForm: task.activeForm, description: task.description,
|
|
607
|
+
});
|
|
608
|
+
} else if (data.status === "completed") {
|
|
609
|
+
logActivity({
|
|
610
|
+
type: "completed", taskId: String(id), subject: task.subject,
|
|
611
|
+
agent: task.agent || task.owner || "", detail: task.reportSummary || "",
|
|
612
|
+
reportSummary: task.reportSummary, reportPath: task.reportPath, parentId: task.parentId,
|
|
613
|
+
});
|
|
614
|
+
} else {
|
|
615
|
+
logActivity({
|
|
616
|
+
type: "updated", taskId: String(id), subject: task.subject,
|
|
617
|
+
agent: task.agent || task.owner || "", detail: prevStatus + " → " + data.status,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
} else if (data.subject !== undefined || data.description !== undefined || data.owner !== undefined) {
|
|
621
|
+
logActivity({
|
|
622
|
+
type: "updated", taskId: String(id), subject: task.subject,
|
|
623
|
+
agent: task.agent || task.owner || "", detail: "Fields updated",
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Auto-execute: pending → in_progress with description
|
|
628
|
+
if (statusChanged && data.status === "in_progress" && prevStatus === "pending" && task.description && cliAvailable) {
|
|
629
|
+
setTimeout(function () { enqueueExec(task); }, 100);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return task;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function deleteTask(id) {
|
|
636
|
+
const filePath = findTaskFile(id);
|
|
637
|
+
if (!filePath) return false;
|
|
638
|
+
// Read task data before deletion for logging
|
|
639
|
+
let taskData = {};
|
|
640
|
+
try { taskData = JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch {}
|
|
641
|
+
fs.unlinkSync(filePath);
|
|
642
|
+
taskSnapshot.delete(String(id));
|
|
643
|
+
logActivity({
|
|
644
|
+
type: "deleted", taskId: String(id), subject: taskData.subject || "Unknown",
|
|
645
|
+
agent: taskData.agent || taskData.owner || "", detail: "",
|
|
646
|
+
});
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── SSE ──
|
|
651
|
+
const sseClients = new Set();
|
|
652
|
+
let lastHash = "";
|
|
653
|
+
|
|
654
|
+
function broadcast(data) {
|
|
655
|
+
const hash = JSON.stringify(
|
|
656
|
+
data.tasks?.map((t) => t.status + t.id + (t.updatedAt || ""))
|
|
657
|
+
);
|
|
658
|
+
if (hash === lastHash) return;
|
|
659
|
+
lastHash = hash;
|
|
660
|
+
const msg = "data: " + JSON.stringify(data) + "\n\n";
|
|
661
|
+
for (const res of sseClients) {
|
|
662
|
+
try { res.write(msg); } catch { sseClients.delete(res); }
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── Raw broadcast (no dedup, for streaming events) ──
|
|
667
|
+
function broadcastRaw(data) {
|
|
668
|
+
const msg = "data: " + JSON.stringify(data) + "\n\n";
|
|
669
|
+
for (const res of sseClients) {
|
|
670
|
+
try { res.write(msg); } catch { sseClients.delete(res); }
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── Claude CLI Detection ──
|
|
675
|
+
let cliAvailable = false;
|
|
676
|
+
|
|
677
|
+
function checkClaudeCLI() {
|
|
678
|
+
if (process.env.CLAUDECODE) {
|
|
679
|
+
console.log(" CLI: unavailable (nested session)");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
execSync("which claude", { encoding: "utf-8", timeout: 5000 });
|
|
684
|
+
cliAvailable = true;
|
|
685
|
+
} catch {
|
|
686
|
+
cliAvailable = false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── Task Executor (Queue-based) ──
|
|
691
|
+
let activeExec = null; // { process, taskId, output }
|
|
692
|
+
let execQueue = [];
|
|
693
|
+
const EXEC_QUEUE_MAX = 5;
|
|
694
|
+
|
|
695
|
+
function enqueueExec(task) {
|
|
696
|
+
if (!cliAvailable) return;
|
|
697
|
+
var id = String(task.id);
|
|
698
|
+
if (activeExec && activeExec.taskId === id) return;
|
|
699
|
+
if (execQueue.some(function (t) { return String(t.id) === id; })) return;
|
|
700
|
+
if (execQueue.length >= EXEC_QUEUE_MAX) return;
|
|
701
|
+
execQueue.push(task);
|
|
702
|
+
broadcastExecQueue();
|
|
703
|
+
if (!activeExec) drainExecQueue();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function drainExecQueue() {
|
|
707
|
+
if (activeExec || execQueue.length === 0) return;
|
|
708
|
+
var next = execQueue.shift();
|
|
709
|
+
broadcastExecQueue();
|
|
710
|
+
spawnExecutor(next);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function broadcastExecQueue() {
|
|
714
|
+
broadcastRaw({
|
|
715
|
+
type: "exec_queue",
|
|
716
|
+
active: activeExec ? { taskId: activeExec.taskId } : null,
|
|
717
|
+
queued: execQueue.map(function (t) { return { id: t.id, subject: t.subject }; }),
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── Agent Prompt & Model Loading ──
|
|
722
|
+
|
|
723
|
+
// Build agent model map from config (fallback to sensible defaults)
|
|
724
|
+
var AGENT_MODEL_MAP = {};
|
|
725
|
+
if (config.agents) {
|
|
726
|
+
Object.keys(config.agents).forEach(function (name) {
|
|
727
|
+
if (config.agents[name].model) AGENT_MODEL_MAP[name] = config.agents[name].model;
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function getModelForAgent(agent) {
|
|
732
|
+
if (!agent) return "sonnet";
|
|
733
|
+
return AGENT_MODEL_MAP[agent] || "sonnet";
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Build agent aliases from config + common patterns
|
|
737
|
+
var AGENT_ALIASES = {};
|
|
738
|
+
if (config.agents) {
|
|
739
|
+
Object.keys(config.agents).forEach(function (name) {
|
|
740
|
+
var aliases = (config.agents[name].aliases || []);
|
|
741
|
+
aliases.forEach(function (a) { AGENT_ALIASES[a] = name; });
|
|
742
|
+
// Auto-generate common alias patterns for hyphenated names
|
|
743
|
+
if (name.indexOf("-") >= 0) {
|
|
744
|
+
AGENT_ALIASES[name.replace(/-/g, "")] = name;
|
|
745
|
+
AGENT_ALIASES[name.replace(/-/g, "_")] = name;
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function resolveAgentName(agent) {
|
|
751
|
+
if (!agent) return null;
|
|
752
|
+
return AGENT_ALIASES[agent] || agent;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function loadAgentPrompt(agent) {
|
|
756
|
+
if (!agent) return "";
|
|
757
|
+
var resolved = resolveAgentName(agent);
|
|
758
|
+
var filePath = path.join(__dirname, "..", ".claude", "agents", resolved + ".md");
|
|
759
|
+
try {
|
|
760
|
+
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, "utf8");
|
|
761
|
+
} catch (e) {}
|
|
762
|
+
// Try original name if alias didn't match
|
|
763
|
+
if (resolved !== agent) {
|
|
764
|
+
var fallback = path.join(__dirname, "..", ".claude", "agents", agent + ".md");
|
|
765
|
+
try {
|
|
766
|
+
if (fs.existsSync(fallback)) return fs.readFileSync(fallback, "utf8");
|
|
767
|
+
} catch (e) {}
|
|
768
|
+
}
|
|
769
|
+
return "";
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function loadProjectContext() {
|
|
773
|
+
var contextPath = path.join(__dirname, "..", ".claude", "agents", "_project-context.md");
|
|
774
|
+
try {
|
|
775
|
+
if (fs.existsSync(contextPath)) return fs.readFileSync(contextPath, "utf8");
|
|
776
|
+
} catch (e) {}
|
|
777
|
+
return "";
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function buildExecutorPrompt(task) {
|
|
781
|
+
var parts = [];
|
|
782
|
+
var projectContext = loadProjectContext();
|
|
783
|
+
var agentPrompt = loadAgentPrompt(task.agent);
|
|
784
|
+
|
|
785
|
+
if (projectContext) parts.push(projectContext);
|
|
786
|
+
if (agentPrompt) parts.push(agentPrompt);
|
|
787
|
+
|
|
788
|
+
parts.push("## 태스크\n\n- **Task ID**: " + task.id + "\n- **Subject**: " + task.subject + "\n\n" + (task.description || "(설명 없음)"));
|
|
789
|
+
parts.push("작업 디렉토리: " + path.join(__dirname, ".."));
|
|
790
|
+
parts.push("## Slack Progress Reporting\n\n진행 상황을 Slack에 보고할 때 반드시 아래 API를 사용한다:\n\n```bash\ncurl -s -X POST http://localhost:4040/api/tasks/" + task.id + "/slack \\\n -H \"Content-Type: application/json\" \\\n -d '{\"text\":\"progress message\"}'\n```\n\n`$SLACK_AGENT_WEBHOOK` 직접 사용 금지. 반드시 위 API를 통해 보고.");
|
|
791
|
+
|
|
792
|
+
return parts.join("\n\n---\n\n");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function spawnExecutor(task) {
|
|
796
|
+
if (!cliAvailable || activeExec) { enqueueExec(task); return; }
|
|
797
|
+
const taskId = String(task.id);
|
|
798
|
+
const prompt = buildExecutorPrompt(task);
|
|
799
|
+
const model = getModelForAgent(resolveAgentName(task.agent));
|
|
800
|
+
|
|
801
|
+
var execEnv = Object.assign({}, process.env);
|
|
802
|
+
delete execEnv.ANTHROPIC_API_KEY; // Max 구독 OAuth 사용
|
|
803
|
+
const proc = spawn("claude", ["-p", "--verbose", "--output-format", "stream-json", "--model", model, "--no-session-persistence", "--dangerously-skip-permissions"], {
|
|
804
|
+
cwd: path.join(__dirname, ".."),
|
|
805
|
+
env: execEnv,
|
|
806
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
activeExec = { process: proc, taskId: taskId, output: "" };
|
|
810
|
+
broadcastRaw({ type: "exec_start", taskId: taskId, subject: task.subject, agent: task.agent || "", model: model });
|
|
811
|
+
logActivity({ type: "started", taskId: taskId, subject: task.subject, detail: "Agent: " + (task.agent || "none") + " | Model: " + model });
|
|
812
|
+
|
|
813
|
+
proc.stdin.write(prompt);
|
|
814
|
+
proc.stdin.end();
|
|
815
|
+
|
|
816
|
+
let buffer = "";
|
|
817
|
+
proc.stdout.on("data", function (data) {
|
|
818
|
+
buffer += data.toString();
|
|
819
|
+
var lines = buffer.split("\n");
|
|
820
|
+
buffer = lines.pop();
|
|
821
|
+
for (const line of lines) {
|
|
822
|
+
if (!line.trim()) continue;
|
|
823
|
+
try {
|
|
824
|
+
var json = JSON.parse(line);
|
|
825
|
+
var text = "";
|
|
826
|
+
if (json.type === "assistant" && json.subtype === "text") text = json.text || "";
|
|
827
|
+
else if (json.type === "assistant" && json.message && json.message.content) {
|
|
828
|
+
json.message.content.forEach(function (c) { if (c.type === "text") text += c.text; });
|
|
829
|
+
}
|
|
830
|
+
else if (json.type === "content_block_delta" && json.delta) text = json.delta.text || "";
|
|
831
|
+
if (text && activeExec) {
|
|
832
|
+
activeExec.output += text;
|
|
833
|
+
broadcastRaw({ type: "exec", taskId: taskId, chunk: text });
|
|
834
|
+
}
|
|
835
|
+
} catch (e) {}
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
proc.stderr.on("data", function (data) {
|
|
840
|
+
broadcastRaw({ type: "exec_error", taskId: taskId, chunk: data.toString() });
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
proc.on("close", function (code) {
|
|
844
|
+
var output = activeExec ? activeExec.output : "";
|
|
845
|
+
activeExec = null;
|
|
846
|
+
if (code === 0) {
|
|
847
|
+
updateTask(taskId, { status: "in_review", reportSummary: output.slice(0, 500) });
|
|
848
|
+
broadcastRaw({ type: "exec_done", taskId: taskId, exitCode: 0 });
|
|
849
|
+
} else {
|
|
850
|
+
updateTask(taskId, { status: "pending" });
|
|
851
|
+
broadcastRaw({ type: "exec_done", taskId: taskId, exitCode: code });
|
|
852
|
+
logActivity({ type: "updated", taskId: taskId, subject: task.subject || "", detail: "Exec failed (exit " + code + ")" });
|
|
853
|
+
}
|
|
854
|
+
broadcastExecQueue();
|
|
855
|
+
drainExecQueue();
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function stopExec() {
|
|
860
|
+
if (!activeExec) return null;
|
|
861
|
+
var taskId = activeExec.taskId;
|
|
862
|
+
try { activeExec.process.kill(); } catch (e) {}
|
|
863
|
+
activeExec = null;
|
|
864
|
+
updateTask(taskId, { status: "pending" });
|
|
865
|
+
broadcastRaw({ type: "exec_done", taskId: taskId, exitCode: -1, stopped: true });
|
|
866
|
+
logActivity({ type: "updated", taskId: taskId, subject: "", detail: "Execution stopped by user" });
|
|
867
|
+
broadcastExecQueue();
|
|
868
|
+
drainExecQueue();
|
|
869
|
+
return taskId;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── Slack Bot (Socket Mode) ──
|
|
873
|
+
|
|
874
|
+
async function initSlackBot() {
|
|
875
|
+
if (!SLACK_BOT_TOKEN || !SLACK_APP_TOKEN) {
|
|
876
|
+
console.log(" Slack Bot: disabled (no tokens)");
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const { App } = require("@slack/bolt");
|
|
881
|
+
slackApp = new App({
|
|
882
|
+
token: SLACK_BOT_TOKEN,
|
|
883
|
+
appToken: SLACK_APP_TOKEN,
|
|
884
|
+
socketMode: true,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// slash command
|
|
888
|
+
slackApp.command(SLACK_COMMAND, async ({ command, ack, respond, client }) => {
|
|
889
|
+
await ack();
|
|
890
|
+
const args = (command.text || "").trim();
|
|
891
|
+
const parts = args.split(/\s+/);
|
|
892
|
+
const sub = (parts[0] || "").toLowerCase();
|
|
893
|
+
const restText = parts.slice(1).join(" ");
|
|
894
|
+
|
|
895
|
+
switch (sub) {
|
|
896
|
+
case "board":
|
|
897
|
+
case "": {
|
|
898
|
+
const tasks = readAllTasks();
|
|
899
|
+
const blocks = buildBoardBlocks(tasks);
|
|
900
|
+
await respond({ blocks, text: PROJECT_NAME + " Kanban", response_type: "ephemeral" });
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
case "list": {
|
|
904
|
+
const tasks = readAllTasks();
|
|
905
|
+
const blocks = buildTaskListBlocks(tasks);
|
|
906
|
+
await respond({ blocks, response_type: "ephemeral" });
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
case "add": {
|
|
910
|
+
if (!restText) {
|
|
911
|
+
await client.views.open({
|
|
912
|
+
trigger_id: command.trigger_id,
|
|
913
|
+
view: buildAddTaskModal(),
|
|
914
|
+
});
|
|
915
|
+
} else {
|
|
916
|
+
const task = createTask({ subject: restText });
|
|
917
|
+
await respond({ text: `Task #${task.id} created: ${task.subject}`, response_type: "in_channel" });
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
case "ask": {
|
|
922
|
+
if (!restText) {
|
|
923
|
+
await respond({ text: "Usage: `" + SLACK_COMMAND + " ask <question>`", response_type: "ephemeral" });
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
if (slackAskActive) {
|
|
927
|
+
await respond({ text: "Another ask is in progress. Please wait.", response_type: "ephemeral" });
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
if (!cliAvailable) {
|
|
931
|
+
await respond({ text: "Claude CLI not available.", response_type: "ephemeral" });
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
await respond({ text: `Asking Claude: _${restText}_`, response_type: "ephemeral" });
|
|
935
|
+
handleSlackAsk(restText, command.channel_id, client);
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
case "exec": {
|
|
939
|
+
if (SLACK_ADMIN_USERS.length > 0 && !SLACK_ADMIN_USERS.includes(command.user_id)) {
|
|
940
|
+
await respond({ text: "Permission denied. You are not in SLACK_ADMIN_USERS.", response_type: "ephemeral" });
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
const execId = restText.replace("#", "");
|
|
944
|
+
if (!execId) {
|
|
945
|
+
await respond({ text: "Usage: `" + SLACK_COMMAND + " exec <task_id>`", response_type: "ephemeral" });
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
if (!cliAvailable) {
|
|
949
|
+
await respond({ text: "Claude CLI not available.", response_type: "ephemeral" });
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
if (activeExec) {
|
|
953
|
+
await respond({ text: "Already executing task #" + activeExec.taskId + ". Use `" + SLACK_COMMAND + " stop` first.", response_type: "ephemeral" });
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
const taskToExec = readAllTasks().find(function (t) { return String(t.id) === execId; });
|
|
957
|
+
if (!taskToExec) {
|
|
958
|
+
await respond({ text: `Task #${execId} not found.`, response_type: "ephemeral" });
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
if (taskToExec.status === "pending") {
|
|
962
|
+
updateTask(execId, { status: "in_progress" });
|
|
963
|
+
}
|
|
964
|
+
if (!activeExec) {
|
|
965
|
+
spawnExecutor(taskToExec);
|
|
966
|
+
}
|
|
967
|
+
await respond({ text: `Executing task #${execId}: ${taskToExec.subject}`, response_type: "in_channel" });
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
case "stop": {
|
|
971
|
+
const stoppedId = stopExec();
|
|
972
|
+
if (stoppedId) {
|
|
973
|
+
await respond({ text: `Stopped execution of task #${stoppedId}.`, response_type: "in_channel" });
|
|
974
|
+
} else {
|
|
975
|
+
await respond({ text: "No active execution.", response_type: "ephemeral" });
|
|
976
|
+
}
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
default:
|
|
980
|
+
await respond({
|
|
981
|
+
text: "Unknown: `" + sub + "`\nAvailable: `board` `list` `add` `ask` `exec` `stop`",
|
|
982
|
+
response_type: "ephemeral",
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// Interactive button handlers
|
|
988
|
+
slackApp.action(/^task_(start|complete|delete)_/, async ({ action, ack, respond, body }) => {
|
|
989
|
+
await ack();
|
|
990
|
+
const match = action.action_id.match(/^task_(start|complete|delete)_(.+)$/);
|
|
991
|
+
if (!match) return;
|
|
992
|
+
const actionType = match[1];
|
|
993
|
+
const taskId = match[2];
|
|
994
|
+
const userName = (body.user && body.user.name) || (body.user && body.user.id) || "someone";
|
|
995
|
+
|
|
996
|
+
switch (actionType) {
|
|
997
|
+
case "start": {
|
|
998
|
+
const task = updateTask(taskId, { status: "in_progress" });
|
|
999
|
+
if (task) {
|
|
1000
|
+
await respond({ text: "▶️ Task #" + taskId + " started by " + userName + ": " + task.subject, response_type: "in_channel", replace_original: false });
|
|
1001
|
+
} else {
|
|
1002
|
+
await respond({ text: "Task #" + taskId + " not found.", response_type: "ephemeral" });
|
|
1003
|
+
}
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
case "complete": {
|
|
1007
|
+
const task = updateTask(taskId, { status: "completed" });
|
|
1008
|
+
if (task) {
|
|
1009
|
+
await respond({ text: "✅ Task #" + taskId + " completed by " + userName + ": " + task.subject, response_type: "in_channel", replace_original: false });
|
|
1010
|
+
} else {
|
|
1011
|
+
await respond({ text: "Task #" + taskId + " not found.", response_type: "ephemeral" });
|
|
1012
|
+
}
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
case "delete": {
|
|
1016
|
+
const tasks = readAllTasks();
|
|
1017
|
+
const task = tasks.find(function (t) { return String(t.id) === taskId; });
|
|
1018
|
+
const ok = deleteTask(taskId);
|
|
1019
|
+
if (ok) {
|
|
1020
|
+
await respond({ text: "🗑️ Task #" + taskId + " deleted by " + userName + (task ? ": " + task.subject : ""), response_type: "in_channel", replace_original: false });
|
|
1021
|
+
} else {
|
|
1022
|
+
await respond({ text: "Task #" + taskId + " not found.", response_type: "ephemeral" });
|
|
1023
|
+
}
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// Modal submission for add command
|
|
1030
|
+
slackApp.view("add_task_modal", async ({ ack, view }) => {
|
|
1031
|
+
await ack();
|
|
1032
|
+
const vals = view.state.values;
|
|
1033
|
+
const subject = vals.subject_block.subject_input.value || "";
|
|
1034
|
+
const description = (vals.desc_block && vals.desc_block.desc_input && vals.desc_block.desc_input.value) || "";
|
|
1035
|
+
const priority = (vals.priority_block && vals.priority_block.priority_select && vals.priority_block.priority_select.selected_option && vals.priority_block.priority_select.selected_option.value) || "medium";
|
|
1036
|
+
if (subject) {
|
|
1037
|
+
createTask({ subject, description, priority });
|
|
1038
|
+
// slackPost는 createTask → logActivity에서 자동 호출 (스레드 부모 메시지)
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
await slackApp.start();
|
|
1043
|
+
console.log(" Slack Bot: connected (Socket Mode)");
|
|
1044
|
+
} catch (e) {
|
|
1045
|
+
console.log(" Slack Bot: failed — " + e.message);
|
|
1046
|
+
slackApp = null;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function buildBoardBlocks(tasks) {
|
|
1051
|
+
var pending = tasks.filter(function (t) { return t.status === "pending"; });
|
|
1052
|
+
var inProgress = tasks.filter(function (t) { return t.status === "in_progress"; });
|
|
1053
|
+
var completed = tasks.filter(function (t) { return t.status === "completed"; });
|
|
1054
|
+
var total = tasks.length;
|
|
1055
|
+
var pct = total ? Math.round((completed.length / total) * 100) : 0;
|
|
1056
|
+
|
|
1057
|
+
// Progress bar: 20 chars
|
|
1058
|
+
var filled = Math.round(pct / 5);
|
|
1059
|
+
var bar = "";
|
|
1060
|
+
for (var i = 0; i < 20; i++) bar += (i < filled ? "\u2588" : "\u2591");
|
|
1061
|
+
|
|
1062
|
+
var blocks = [
|
|
1063
|
+
{ type: "header", text: { type: "plain_text", text: PROJECT_NAME + " Kanban" } },
|
|
1064
|
+
{ type: "section", text: { type: "mrkdwn", text:
|
|
1065
|
+
"*" + completed.length + "* done \u00b7 *" + inProgress.length + "* in progress \u00b7 *" + pending.length + "* pending \u00b7 *" + total + "* total\n`" + bar + "` " + pct + "%"
|
|
1066
|
+
}},
|
|
1067
|
+
];
|
|
1068
|
+
|
|
1069
|
+
// In Progress
|
|
1070
|
+
if (inProgress.length > 0) {
|
|
1071
|
+
blocks.push({ type: "divider" });
|
|
1072
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: ":arrows_counterclockwise: *In Progress* (" + inProgress.length + ")" } });
|
|
1073
|
+
for (var ip = 0; ip < inProgress.length; ip++) {
|
|
1074
|
+
var t = inProgress[ip];
|
|
1075
|
+
var line = "> *#" + t.id + "* " + t.subject;
|
|
1076
|
+
if (t.owner) line += " \u2014 " + t.owner;
|
|
1077
|
+
if (t.activeForm) line += "\n> _" + t.activeForm + "_";
|
|
1078
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: line } });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Pending
|
|
1083
|
+
if (pending.length > 0) {
|
|
1084
|
+
blocks.push({ type: "divider" });
|
|
1085
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: ":hourglass_flowing_sand: *Pending* (" + pending.length + ")" } });
|
|
1086
|
+
var showPending = pending.length > 8 ? pending.slice(0, 6) : pending;
|
|
1087
|
+
var lines = [];
|
|
1088
|
+
for (var pp = 0; pp < showPending.length; pp++) {
|
|
1089
|
+
var p = showPending[pp];
|
|
1090
|
+
var pl = "*#" + p.id + "* " + p.subject;
|
|
1091
|
+
if (p.priority === "high") pl += " :red_circle:";
|
|
1092
|
+
if (p.owner) pl += " \u2014 " + p.owner;
|
|
1093
|
+
lines.push(pl);
|
|
1094
|
+
}
|
|
1095
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } });
|
|
1096
|
+
if (pending.length > 8) {
|
|
1097
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: "+" + (pending.length - 6) + " more \u2014 use `" + SLACK_COMMAND + " list` to see all with actions" }] });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Recently completed
|
|
1102
|
+
var recentDone = completed.slice(-3);
|
|
1103
|
+
if (recentDone.length > 0) {
|
|
1104
|
+
blocks.push({ type: "divider" });
|
|
1105
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: ":white_check_mark: *Recently Done*" } });
|
|
1106
|
+
var doneLines = [];
|
|
1107
|
+
for (var dd = 0; dd < recentDone.length; dd++) {
|
|
1108
|
+
var d = recentDone[dd];
|
|
1109
|
+
doneLines.push("~#" + d.id + " " + d.subject + "~");
|
|
1110
|
+
}
|
|
1111
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: doneLines.join("\n") }] });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Footer
|
|
1115
|
+
blocks.push({ type: "divider" });
|
|
1116
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: ":keyboard: `" + SLACK_COMMAND + " list` \u2014 interactive \u00b7 `" + SLACK_COMMAND + " add` \u2014 new task \u00b7 `" + SLACK_COMMAND + " ask` \u2014 Claude" }] });
|
|
1117
|
+
|
|
1118
|
+
return blocks;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function buildTaskListBlocks(tasks) {
|
|
1122
|
+
var blocks = [
|
|
1123
|
+
{ type: "header", text: { type: "plain_text", text: PROJECT_NAME + " Kanban" } },
|
|
1124
|
+
];
|
|
1125
|
+
var groups = [
|
|
1126
|
+
{ key: "in_progress", label: "🔄 In Progress" },
|
|
1127
|
+
{ key: "pending", label: "⏳ Pending" },
|
|
1128
|
+
{ key: "completed", label: "✅ Completed (recent)" },
|
|
1129
|
+
];
|
|
1130
|
+
for (var gi = 0; gi < groups.length; gi++) {
|
|
1131
|
+
var g = groups[gi];
|
|
1132
|
+
var items = tasks.filter(function (t) { return t.status === g.key; });
|
|
1133
|
+
if (g.key === "completed") items = items.slice(-5);
|
|
1134
|
+
if (items.length === 0) continue;
|
|
1135
|
+
|
|
1136
|
+
blocks.push({ type: "divider" });
|
|
1137
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: "*" + g.label + "* (" + items.length + ")" } });
|
|
1138
|
+
|
|
1139
|
+
for (var ti = 0; ti < items.length; ti++) {
|
|
1140
|
+
var t = items[ti];
|
|
1141
|
+
var desc = "*#" + t.id + "* " + t.subject;
|
|
1142
|
+
if (t.owner) desc += " 👤 " + t.owner;
|
|
1143
|
+
if (t.activeForm) desc += "\n_" + t.activeForm + "_";
|
|
1144
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: desc } });
|
|
1145
|
+
|
|
1146
|
+
var buttons = [];
|
|
1147
|
+
if (t.status === "pending") {
|
|
1148
|
+
buttons.push({ type: "button", text: { type: "plain_text", text: "Start" }, action_id: "task_start_" + t.id, style: "primary" });
|
|
1149
|
+
}
|
|
1150
|
+
if (t.status === "in_progress") {
|
|
1151
|
+
buttons.push({ type: "button", text: { type: "plain_text", text: "Complete" }, action_id: "task_complete_" + t.id, style: "primary" });
|
|
1152
|
+
}
|
|
1153
|
+
if (t.status !== "completed") {
|
|
1154
|
+
buttons.push({ type: "button", text: { type: "plain_text", text: "Delete" }, action_id: "task_delete_" + t.id, style: "danger" });
|
|
1155
|
+
}
|
|
1156
|
+
if (buttons.length > 0) {
|
|
1157
|
+
blocks.push({ type: "actions", elements: buttons });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (blocks.length <= 1) {
|
|
1162
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: "_No tasks found._" } });
|
|
1163
|
+
}
|
|
1164
|
+
return blocks;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function buildAddTaskModal() {
|
|
1168
|
+
return {
|
|
1169
|
+
type: "modal",
|
|
1170
|
+
callback_id: "add_task_modal",
|
|
1171
|
+
title: { type: "plain_text", text: "New Task" },
|
|
1172
|
+
submit: { type: "plain_text", text: "Create" },
|
|
1173
|
+
blocks: [
|
|
1174
|
+
{
|
|
1175
|
+
type: "input",
|
|
1176
|
+
block_id: "subject_block",
|
|
1177
|
+
element: { type: "plain_text_input", action_id: "subject_input", placeholder: { type: "plain_text", text: "Task title" } },
|
|
1178
|
+
label: { type: "plain_text", text: "Subject" },
|
|
1179
|
+
},
|
|
1180
|
+
{
|
|
1181
|
+
type: "input",
|
|
1182
|
+
block_id: "desc_block",
|
|
1183
|
+
optional: true,
|
|
1184
|
+
element: { type: "plain_text_input", action_id: "desc_input", multiline: true, placeholder: { type: "plain_text", text: "Task description" } },
|
|
1185
|
+
label: { type: "plain_text", text: "Description" },
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
type: "input",
|
|
1189
|
+
block_id: "priority_block",
|
|
1190
|
+
optional: true,
|
|
1191
|
+
element: {
|
|
1192
|
+
type: "static_select",
|
|
1193
|
+
action_id: "priority_select",
|
|
1194
|
+
initial_option: { text: { type: "plain_text", text: "Medium" }, value: "medium" },
|
|
1195
|
+
options: [
|
|
1196
|
+
{ text: { type: "plain_text", text: "Low" }, value: "low" },
|
|
1197
|
+
{ text: { type: "plain_text", text: "Medium" }, value: "medium" },
|
|
1198
|
+
{ text: { type: "plain_text", text: "High" }, value: "high" },
|
|
1199
|
+
],
|
|
1200
|
+
},
|
|
1201
|
+
label: { type: "plain_text", text: "Priority" },
|
|
1202
|
+
},
|
|
1203
|
+
],
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function handleSlackAsk(question, channelId, client) {
|
|
1208
|
+
slackAskActive = true;
|
|
1209
|
+
var tasks = readAllTasks();
|
|
1210
|
+
var prompt = buildChatSystemPrompt(tasks, PROJECT_NAME);
|
|
1211
|
+
prompt += "\n\n[User via Slack]: " + question;
|
|
1212
|
+
appendOrchestratorHistory("user-slack", question);
|
|
1213
|
+
|
|
1214
|
+
var askEnv = Object.assign({}, process.env);
|
|
1215
|
+
delete askEnv.ANTHROPIC_API_KEY;
|
|
1216
|
+
var proc = spawn("claude", ["-p", "--output-format", "text", "--model", "sonnet", "--no-session-persistence", "--dangerously-skip-permissions"], {
|
|
1217
|
+
cwd: path.join(__dirname, ".."),
|
|
1218
|
+
env: askEnv,
|
|
1219
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
proc.stdin.write(prompt);
|
|
1223
|
+
proc.stdin.end();
|
|
1224
|
+
|
|
1225
|
+
var output = "";
|
|
1226
|
+
proc.stdout.on("data", function (data) { output += data.toString(); });
|
|
1227
|
+
proc.stderr.on("data", function () {});
|
|
1228
|
+
|
|
1229
|
+
proc.on("close", function (code) {
|
|
1230
|
+
slackAskActive = false;
|
|
1231
|
+
var targetChannel = channelId || SLACK_CHANNEL_ID;
|
|
1232
|
+
if (!targetChannel || !client) return;
|
|
1233
|
+
var text = (code === 0 && output.trim())
|
|
1234
|
+
? output.trim().slice(0, 3000)
|
|
1235
|
+
: "(Claude returned exit code " + code + ")";
|
|
1236
|
+
appendOrchestratorHistory("orchestrator", text);
|
|
1237
|
+
extractAndSaveLearnings(output);
|
|
1238
|
+
client.chat.postMessage({
|
|
1239
|
+
channel: targetChannel,
|
|
1240
|
+
text: "💬 *Claude says:*\n" + text,
|
|
1241
|
+
}).catch(function () {});
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// ── 파일 감시 + Slack diff ──
|
|
1246
|
+
const taskSnapshot = new Map(); // id → {status, subject, owner}
|
|
1247
|
+
|
|
1248
|
+
function snapshotTasks(tasks) {
|
|
1249
|
+
for (const t of tasks) {
|
|
1250
|
+
taskSnapshot.set(String(t.id), { status: t.status, subject: t.subject, owner: t.owner || "", reportSummary: t.reportSummary || "" });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function detectAndNotifyChanges(tasks) {
|
|
1255
|
+
for (const t of tasks) {
|
|
1256
|
+
const id = String(t.id);
|
|
1257
|
+
const prev = taskSnapshot.get(id);
|
|
1258
|
+
if (!prev) {
|
|
1259
|
+
logActivity({
|
|
1260
|
+
type: "created", taskId: id, subject: t.subject,
|
|
1261
|
+
agent: t.agent || t.owner || "", detail: t.priority === "high" ? "Priority: high" : "",
|
|
1262
|
+
description: t.description, priority: t.priority, owner: t.owner, parentId: t.parentId,
|
|
1263
|
+
});
|
|
1264
|
+
} else if (prev.status !== t.status) {
|
|
1265
|
+
if (t.status === "in_progress" && prev.status === "pending") {
|
|
1266
|
+
logActivity({
|
|
1267
|
+
type: "started", taskId: id, subject: t.subject,
|
|
1268
|
+
agent: t.agent || t.owner || "", detail: t.activeForm || "",
|
|
1269
|
+
owner: t.owner, activeForm: t.activeForm, description: t.description,
|
|
1270
|
+
});
|
|
1271
|
+
} else if (t.status === "completed") {
|
|
1272
|
+
logActivity({
|
|
1273
|
+
type: "completed", taskId: id, subject: t.subject,
|
|
1274
|
+
agent: t.agent || t.owner || "", detail: t.reportSummary || "",
|
|
1275
|
+
reportSummary: t.reportSummary, reportPath: t.reportPath, parentId: t.parentId,
|
|
1276
|
+
});
|
|
1277
|
+
} else {
|
|
1278
|
+
logActivity({
|
|
1279
|
+
type: "updated", taskId: id, subject: t.subject,
|
|
1280
|
+
agent: t.agent || t.owner || "", detail: prev.status + " → " + t.status,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
// Detect deleted tasks (in snapshot but not in current tasks)
|
|
1286
|
+
const currentIds = new Set(tasks.map(t => String(t.id)));
|
|
1287
|
+
for (const [id, prev] of taskSnapshot) {
|
|
1288
|
+
if (!currentIds.has(id)) {
|
|
1289
|
+
logActivity({
|
|
1290
|
+
type: "deleted", taskId: id, subject: prev.subject || "Unknown",
|
|
1291
|
+
agent: "", detail: "",
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
snapshotTasks(tasks);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function watchTasks() {
|
|
1299
|
+
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR, { recursive: true });
|
|
1300
|
+
const watched = new Set();
|
|
1301
|
+
|
|
1302
|
+
// 초기 스냅샷 (서버 시작 시 현재 상태 기록 → 기존 태스크에 대해 알림 안 보냄)
|
|
1303
|
+
snapshotTasks(readAllTasks());
|
|
1304
|
+
|
|
1305
|
+
function onFileChange() {
|
|
1306
|
+
const tasks = readAllTasks();
|
|
1307
|
+
detectAndNotifyChanges(tasks);
|
|
1308
|
+
broadcast({ type: "update", tasks });
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function scanAndWatch() {
|
|
1312
|
+
if (!fs.existsSync(TASKS_DIR)) return;
|
|
1313
|
+
try {
|
|
1314
|
+
const sessions = fs.readdirSync(TASKS_DIR, { withFileTypes: true });
|
|
1315
|
+
for (const session of sessions) {
|
|
1316
|
+
if (!session.isDirectory()) continue;
|
|
1317
|
+
const sp = path.join(TASKS_DIR, session.name);
|
|
1318
|
+
if (!watched.has(sp)) {
|
|
1319
|
+
watched.add(sp);
|
|
1320
|
+
try {
|
|
1321
|
+
fs.watch(sp, { persistent: false }, () => onFileChange());
|
|
1322
|
+
} catch {}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
} catch {}
|
|
1326
|
+
}
|
|
1327
|
+
try {
|
|
1328
|
+
fs.watch(TASKS_DIR, { persistent: false }, () => {
|
|
1329
|
+
scanAndWatch();
|
|
1330
|
+
onFileChange();
|
|
1331
|
+
});
|
|
1332
|
+
} catch {}
|
|
1333
|
+
scanAndWatch();
|
|
1334
|
+
setInterval(() => broadcast({ type: "update", tasks: readAllTasks() }), 2000);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// ── Request body parser ──
|
|
1338
|
+
function parseBody(req) {
|
|
1339
|
+
return new Promise((resolve, reject) => {
|
|
1340
|
+
let body = "";
|
|
1341
|
+
req.on("data", (chunk) => (body += chunk));
|
|
1342
|
+
req.on("end", () => {
|
|
1343
|
+
try { resolve(JSON.parse(body)); }
|
|
1344
|
+
catch { reject(new Error("Invalid JSON")); }
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ── HTML (loaded from separate file) ──
|
|
1350
|
+
const HTML_PATH = fs.existsSync(path.join(__dirname, "kanban.html"))
|
|
1351
|
+
? path.join(__dirname, "kanban.html")
|
|
1352
|
+
: path.join(__dirname, "..", "assets", "kanban.html");
|
|
1353
|
+
function getHTML() {
|
|
1354
|
+
return fs.readFileSync(HTML_PATH, "utf-8")
|
|
1355
|
+
.replace(/\{\{PORT\}\}/g, String(PORT))
|
|
1356
|
+
.replace(/\{\{PROJECT_NAME\}\}/g, PROJECT_NAME);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// ── Server ──
|
|
1360
|
+
const server = http.createServer(async (req, res) => {
|
|
1361
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1362
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
1363
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1364
|
+
|
|
1365
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
1366
|
+
|
|
1367
|
+
// SSE
|
|
1368
|
+
if (req.url === "/events") {
|
|
1369
|
+
res.writeHead(200, {
|
|
1370
|
+
"Content-Type": "text/event-stream",
|
|
1371
|
+
"Cache-Control": "no-cache",
|
|
1372
|
+
Connection: "keep-alive",
|
|
1373
|
+
});
|
|
1374
|
+
sseClients.add(res);
|
|
1375
|
+
res.write("data: " + JSON.stringify({ type: "update", tasks: readAllTasks() }) + "\n\n");
|
|
1376
|
+
req.on("close", () => sseClients.delete(res));
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// GET /api/tasks
|
|
1381
|
+
if (req.url === "/api/tasks" && req.method === "GET") {
|
|
1382
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1383
|
+
res.end(JSON.stringify(readAllTasks()));
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// POST /api/tasks
|
|
1388
|
+
if (req.url === "/api/tasks" && req.method === "POST") {
|
|
1389
|
+
try {
|
|
1390
|
+
const data = await parseBody(req);
|
|
1391
|
+
const task = createTask(data);
|
|
1392
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
1393
|
+
res.end(JSON.stringify(task));
|
|
1394
|
+
} catch (e) {
|
|
1395
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1396
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1397
|
+
}
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// PUT /api/tasks/:id
|
|
1402
|
+
const putMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)$/);
|
|
1403
|
+
if (putMatch && req.method === "PUT") {
|
|
1404
|
+
try {
|
|
1405
|
+
const data = await parseBody(req);
|
|
1406
|
+
const task = updateTask(putMatch[1], data);
|
|
1407
|
+
if (!task) { res.writeHead(404); res.end('{"error":"Not found"}'); return; }
|
|
1408
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1409
|
+
res.end(JSON.stringify(task));
|
|
1410
|
+
} catch (e) {
|
|
1411
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1412
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1413
|
+
}
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// GET /api/activity
|
|
1418
|
+
if (req.url.startsWith("/api/activity") && req.method === "GET") {
|
|
1419
|
+
const params = new URL(req.url, "http://localhost").searchParams;
|
|
1420
|
+
const since = params.get("since") || null;
|
|
1421
|
+
const limit = parseInt(params.get("limit")) || 200;
|
|
1422
|
+
const events = readActivity(since, limit);
|
|
1423
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1424
|
+
res.end(JSON.stringify(events));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// GET /api/report?path=<filepath>
|
|
1429
|
+
if (req.url.startsWith("/api/report?") && req.method === "GET") {
|
|
1430
|
+
const params = new URL(req.url, "http://localhost").searchParams;
|
|
1431
|
+
const filePath = params.get("path");
|
|
1432
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
1433
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1434
|
+
res.end('{"error":"File not found"}');
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
// Security: only allow reading from .claude/logs/ or project docs
|
|
1438
|
+
const allowed = [
|
|
1439
|
+
path.join(os.homedir(), ".claude"),
|
|
1440
|
+
path.join(__dirname, ".."),
|
|
1441
|
+
];
|
|
1442
|
+
const resolved = path.resolve(filePath);
|
|
1443
|
+
if (!allowed.some((a) => resolved.startsWith(a))) {
|
|
1444
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1445
|
+
res.end('{"error":"Access denied"}');
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
try {
|
|
1449
|
+
const content = fs.readFileSync(resolved, "utf-8");
|
|
1450
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1451
|
+
res.end(JSON.stringify({ path: resolved, content: content }));
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1454
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1455
|
+
}
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// POST /api/tasks/:id/slack — Agent progress reporting via thread
|
|
1460
|
+
const slackMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)\/slack$/);
|
|
1461
|
+
if (slackMatch && req.method === "POST") {
|
|
1462
|
+
try {
|
|
1463
|
+
const taskId = slackMatch[1];
|
|
1464
|
+
const allTasks = readAllTasks();
|
|
1465
|
+
const task = allTasks.find(function (t) { return String(t.id) === taskId; });
|
|
1466
|
+
if (!task) {
|
|
1467
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1468
|
+
res.end('{"error":"Task not found"}');
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const body = await parseBody(req);
|
|
1472
|
+
const text = body.text || "";
|
|
1473
|
+
if (!text) {
|
|
1474
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1475
|
+
res.end('{"error":"text is required"}');
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
const formatted = `[${task.agent || "agent"}] #${taskId}: ${text}`;
|
|
1479
|
+
const threadTs = getThreadTs(taskId);
|
|
1480
|
+
const msgTs = await slackPost(formatted, threadTs);
|
|
1481
|
+
// If legacy task (no thread), save the returned ts as parent
|
|
1482
|
+
if (!threadTs && msgTs) {
|
|
1483
|
+
setThreadTs(taskId, msgTs);
|
|
1484
|
+
}
|
|
1485
|
+
// Log to activity (skip Slack to avoid duplicate)
|
|
1486
|
+
logActivity({
|
|
1487
|
+
type: "updated", taskId: taskId, subject: task.subject,
|
|
1488
|
+
agent: task.agent || "", detail: "Agent report: " + text.slice(0, 100),
|
|
1489
|
+
_skipSlack: true,
|
|
1490
|
+
});
|
|
1491
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1492
|
+
res.end(JSON.stringify({ ok: true, threadTs: threadTs || msgTs }));
|
|
1493
|
+
} catch (e) {
|
|
1494
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1495
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1496
|
+
}
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// DELETE /api/tasks/:id
|
|
1501
|
+
const delMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)$/);
|
|
1502
|
+
if (delMatch && req.method === "DELETE") {
|
|
1503
|
+
const ok = deleteTask(delMatch[1]);
|
|
1504
|
+
res.writeHead(ok ? 204 : 404);
|
|
1505
|
+
res.end();
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// GET /api/cli-status
|
|
1510
|
+
if (req.url === "/api/cli-status" && req.method === "GET") {
|
|
1511
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1512
|
+
res.end(JSON.stringify({
|
|
1513
|
+
available: cliAvailable,
|
|
1514
|
+
executing: activeExec ? activeExec.taskId : null,
|
|
1515
|
+
nested: !!process.env.CLAUDECODE,
|
|
1516
|
+
queue: execQueue.map(function (t) { return { id: t.id, subject: t.subject }; }),
|
|
1517
|
+
}));
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// GET /api/chat/session
|
|
1522
|
+
if (req.url === "/api/chat/session" && req.method === "GET") {
|
|
1523
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1524
|
+
res.end(JSON.stringify({ sessionId: chatSessionId, model: chatSessionModel, hasSession: !!chatSessionId }));
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// DELETE /api/chat/session
|
|
1529
|
+
if (req.url === "/api/chat/session" && req.method === "DELETE") {
|
|
1530
|
+
chatSessionId = null;
|
|
1531
|
+
chatSessionModel = null;
|
|
1532
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1533
|
+
res.end('{"ok":true}');
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// GET /api/agents — list all agents with prompts
|
|
1538
|
+
if (req.url === "/api/agents" && req.method === "GET") {
|
|
1539
|
+
var agentsDir = path.join(__dirname, "..", ".claude", "agents");
|
|
1540
|
+
var agentList = [];
|
|
1541
|
+
try {
|
|
1542
|
+
var files = fs.readdirSync(agentsDir).filter(function (f) { return f.endsWith(".md") && !f.startsWith("_"); });
|
|
1543
|
+
files.forEach(function (f) {
|
|
1544
|
+
var name = f.replace(".md", "");
|
|
1545
|
+
var content = "";
|
|
1546
|
+
try { content = fs.readFileSync(path.join(agentsDir, f), "utf8"); } catch (e) {}
|
|
1547
|
+
var model = getModelForAgent(resolveAgentName(name) || name);
|
|
1548
|
+
agentList.push({ name: name, model: model, prompt: content });
|
|
1549
|
+
});
|
|
1550
|
+
} catch (e) {}
|
|
1551
|
+
// Also include project context
|
|
1552
|
+
var projectCtx = loadProjectContext();
|
|
1553
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1554
|
+
res.end(JSON.stringify({ agents: agentList, projectContext: projectCtx }));
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// GET /api/agents/:name — single agent prompt
|
|
1559
|
+
if (req.url.startsWith("/api/agents/") && req.method === "GET") {
|
|
1560
|
+
var agentName = decodeURIComponent(req.url.split("/api/agents/")[1]);
|
|
1561
|
+
var prompt = loadAgentPrompt(agentName);
|
|
1562
|
+
var model = getModelForAgent(resolveAgentName(agentName) || agentName);
|
|
1563
|
+
var ctx = loadProjectContext();
|
|
1564
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1565
|
+
res.end(JSON.stringify({ name: agentName, model: model, prompt: prompt, projectContext: ctx }));
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// GET /api/exec/queue
|
|
1570
|
+
if (req.url === "/api/exec/queue" && req.method === "GET") {
|
|
1571
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1572
|
+
res.end(JSON.stringify({
|
|
1573
|
+
active: activeExec ? { taskId: activeExec.taskId } : null,
|
|
1574
|
+
queue: execQueue.map(function (t) { return { id: t.id, subject: t.subject }; }),
|
|
1575
|
+
}));
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// GET /api/chat/history
|
|
1580
|
+
var CHAT_FILE = path.join(KANBAN_DIR, "chat-history.json");
|
|
1581
|
+
if (req.url === "/api/chat/history" && req.method === "GET") {
|
|
1582
|
+
try {
|
|
1583
|
+
var hist = fs.existsSync(CHAT_FILE) ? JSON.parse(fs.readFileSync(CHAT_FILE, "utf-8")) : [];
|
|
1584
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1585
|
+
res.end(JSON.stringify(hist));
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1588
|
+
res.end("[]");
|
|
1589
|
+
}
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// PUT /api/chat/history
|
|
1594
|
+
if (req.url === "/api/chat/history" && req.method === "PUT") {
|
|
1595
|
+
try {
|
|
1596
|
+
var body = await parseBody(req);
|
|
1597
|
+
fs.writeFileSync(CHAT_FILE, JSON.stringify(body.messages || [], null, 2));
|
|
1598
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1599
|
+
res.end('{"ok":true}');
|
|
1600
|
+
} catch (e) {
|
|
1601
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1602
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1603
|
+
}
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// DELETE /api/chat/history
|
|
1608
|
+
if (req.url === "/api/chat/history" && req.method === "DELETE") {
|
|
1609
|
+
try { fs.unlinkSync(CHAT_FILE); } catch (e) {}
|
|
1610
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1611
|
+
res.end('{"ok":true}');
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// GET /api/orchestrator
|
|
1616
|
+
if (req.url === "/api/orchestrator" && req.method === "GET") {
|
|
1617
|
+
var orchPrompt = readOrchestratorPrompt();
|
|
1618
|
+
var orchHistory = readOrchestratorHistory(20);
|
|
1619
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1620
|
+
res.end(JSON.stringify({ prompt: orchPrompt, history: orchHistory, path: ORCHESTRATOR_FILE }));
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// PUT /api/orchestrator
|
|
1625
|
+
if (req.url === "/api/orchestrator" && req.method === "PUT") {
|
|
1626
|
+
try {
|
|
1627
|
+
var orchBody = await parseBody(req);
|
|
1628
|
+
if (orchBody.prompt) {
|
|
1629
|
+
fs.writeFileSync(ORCHESTRATOR_FILE, orchBody.prompt);
|
|
1630
|
+
}
|
|
1631
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1632
|
+
res.end('{"ok":true}');
|
|
1633
|
+
} catch (e) {
|
|
1634
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1635
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1636
|
+
}
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// POST /api/chat — enhanced with tool_use capture + action execution + session
|
|
1641
|
+
if (req.url === "/api/chat" && req.method === "POST") {
|
|
1642
|
+
if (!cliAvailable) {
|
|
1643
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1644
|
+
res.end(JSON.stringify({ error: "Claude CLI not available" }));
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
var chatBody = await parseBody(req);
|
|
1649
|
+
var chatMessage = chatBody.message || "";
|
|
1650
|
+
var chatModel = chatBody.model || "sonnet";
|
|
1651
|
+
|
|
1652
|
+
var tasks = readAllTasks();
|
|
1653
|
+
var chatPrompt = buildChatSystemPrompt(tasks, PROJECT_NAME);
|
|
1654
|
+
|
|
1655
|
+
// Build CLI args
|
|
1656
|
+
var chatArgs = ["-p", "--verbose", "--output-format", "stream-json",
|
|
1657
|
+
"--model", chatModel, "--dangerously-skip-permissions"];
|
|
1658
|
+
|
|
1659
|
+
// Session continuity: resume if same model
|
|
1660
|
+
if (chatSessionId && chatSessionModel === chatModel) {
|
|
1661
|
+
chatArgs.push("--resume", chatSessionId);
|
|
1662
|
+
} else {
|
|
1663
|
+
chatArgs.push("--no-session-persistence");
|
|
1664
|
+
chatSessionId = null;
|
|
1665
|
+
chatSessionModel = chatModel;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// Build input — include system context only for new sessions
|
|
1669
|
+
var chatInput = "";
|
|
1670
|
+
if (!chatSessionId) {
|
|
1671
|
+
chatInput = chatPrompt + "\n\n[User]: " + chatMessage;
|
|
1672
|
+
} else {
|
|
1673
|
+
chatInput = chatMessage;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
res.writeHead(200, {
|
|
1677
|
+
"Content-Type": "text/event-stream",
|
|
1678
|
+
"Cache-Control": "no-cache",
|
|
1679
|
+
"Connection": "keep-alive",
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
var chatEnv = Object.assign({}, process.env);
|
|
1683
|
+
delete chatEnv.ANTHROPIC_API_KEY; // Max 구독 OAuth 사용
|
|
1684
|
+
var chatProc = spawn("claude", chatArgs, {
|
|
1685
|
+
cwd: path.join(__dirname, ".."),
|
|
1686
|
+
env: chatEnv,
|
|
1687
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
chatProc.stdin.write(chatInput);
|
|
1691
|
+
chatProc.stdin.end();
|
|
1692
|
+
|
|
1693
|
+
var chatBuf = "";
|
|
1694
|
+
var rawOut = "";
|
|
1695
|
+
var fullResponse = "";
|
|
1696
|
+
appendOrchestratorHistory("user", chatMessage);
|
|
1697
|
+
|
|
1698
|
+
chatProc.stdout.on("data", function (data) {
|
|
1699
|
+
var chunk = data.toString();
|
|
1700
|
+
rawOut += chunk;
|
|
1701
|
+
chatBuf += chunk;
|
|
1702
|
+
var lines = chatBuf.split("\n");
|
|
1703
|
+
chatBuf = lines.pop();
|
|
1704
|
+
for (var li = 0; li < lines.length; li++) {
|
|
1705
|
+
var ln = lines[li];
|
|
1706
|
+
if (!ln.trim()) continue;
|
|
1707
|
+
try {
|
|
1708
|
+
var json = JSON.parse(ln);
|
|
1709
|
+
|
|
1710
|
+
// Capture session ID from init event
|
|
1711
|
+
if (json.type === "system" && json.session_id) {
|
|
1712
|
+
chatSessionId = json.session_id;
|
|
1713
|
+
chatSessionModel = chatModel;
|
|
1714
|
+
res.write("data: " + JSON.stringify({ type: "chat_session", sessionId: chatSessionId }) + "\n\n");
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Text content
|
|
1718
|
+
var text = "";
|
|
1719
|
+
if (json.type === "assistant" && json.subtype === "text") {
|
|
1720
|
+
text = json.text || "";
|
|
1721
|
+
} else if (json.type === "assistant" && json.message && json.message.content) {
|
|
1722
|
+
json.message.content.forEach(function (c) { if (c.type === "text") text += c.text; });
|
|
1723
|
+
} else if (json.type === "content_block_delta" && json.delta && json.delta.text) {
|
|
1724
|
+
text = json.delta.text;
|
|
1725
|
+
}
|
|
1726
|
+
if (text) {
|
|
1727
|
+
fullResponse += text;
|
|
1728
|
+
res.write("data: " + JSON.stringify({ type: "chat", chunk: text }) + "\n\n");
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Tool use start
|
|
1732
|
+
if (json.type === "assistant" && json.subtype === "tool_use") {
|
|
1733
|
+
var toolName = json.tool || json.name || "Tool";
|
|
1734
|
+
var toolInput = "";
|
|
1735
|
+
if (json.input) {
|
|
1736
|
+
if (typeof json.input === "string") toolInput = json.input;
|
|
1737
|
+
else if (json.input.command) toolInput = json.input.command;
|
|
1738
|
+
else if (json.input.file_path) toolInput = json.input.file_path;
|
|
1739
|
+
else if (json.input.pattern) toolInput = json.input.pattern;
|
|
1740
|
+
else if (json.input.query) toolInput = json.input.query;
|
|
1741
|
+
else toolInput = JSON.stringify(json.input).slice(0, 200);
|
|
1742
|
+
}
|
|
1743
|
+
res.write("data: " + JSON.stringify({ type: "chat_tool_use", tool: toolName, input: toolInput }) + "\n\n");
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Tool result
|
|
1747
|
+
if (json.type === "tool_result" || (json.type === "tool" && json.subtype === "result")) {
|
|
1748
|
+
var output = json.output || json.text || json.content || "";
|
|
1749
|
+
if (typeof output !== "string") output = JSON.stringify(output).slice(0, 2000);
|
|
1750
|
+
res.write("data: " + JSON.stringify({ type: "chat_tool_result", output: String(output).slice(0, 2000) }) + "\n\n");
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
} catch (e) {}
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
var chatErr = "";
|
|
1758
|
+
chatProc.stderr.on("data", function (data) { chatErr += data.toString(); });
|
|
1759
|
+
|
|
1760
|
+
chatProc.on("close", function (code) {
|
|
1761
|
+
try {
|
|
1762
|
+
// Debug info if no output
|
|
1763
|
+
if (!rawOut.trim() || code !== 0) {
|
|
1764
|
+
res.write("data: " + JSON.stringify({ type: "chat_debug", rawOut: rawOut.slice(0, 2000), stderr: chatErr.slice(0, 2000), exitCode: code }) + "\n\n");
|
|
1765
|
+
}
|
|
1766
|
+
if (chatErr && code !== 0) {
|
|
1767
|
+
res.write("data: " + JSON.stringify({ type: "chat_error", error: chatErr.trim(), exitCode: code }) + "\n\n");
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Parse and execute action blocks
|
|
1771
|
+
var parsed = parseActionBlocks(fullResponse);
|
|
1772
|
+
if (parsed.actions.length > 0) {
|
|
1773
|
+
var actionResults = [];
|
|
1774
|
+
for (var ai = 0; ai < parsed.actions.length; ai++) {
|
|
1775
|
+
var results = executeAction(parsed.actions[ai]);
|
|
1776
|
+
actionResults = actionResults.concat(results);
|
|
1777
|
+
}
|
|
1778
|
+
res.write("data: " + JSON.stringify({ type: "chat_actions", results: actionResults }) + "\n\n");
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
res.write("data: " + JSON.stringify({ type: "chat_done" }) + "\n\n");
|
|
1782
|
+
res.end();
|
|
1783
|
+
|
|
1784
|
+
// Save orchestrator history
|
|
1785
|
+
if (parsed.cleaned || fullResponse) {
|
|
1786
|
+
appendOrchestratorHistory("orchestrator", (parsed.cleaned || fullResponse));
|
|
1787
|
+
extractAndSaveLearnings(fullResponse);
|
|
1788
|
+
}
|
|
1789
|
+
} catch (e) {}
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
req.on("close", function () {
|
|
1793
|
+
try { chatProc.kill(); } catch (e) {}
|
|
1794
|
+
});
|
|
1795
|
+
} catch (e) {
|
|
1796
|
+
if (!res.headersSent) {
|
|
1797
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1798
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// POST /api/exec/stop
|
|
1805
|
+
if (req.url === "/api/exec/stop" && req.method === "POST") {
|
|
1806
|
+
var stoppedId = stopExec();
|
|
1807
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1808
|
+
res.end(JSON.stringify({ stopped: !!stoppedId, taskId: stoppedId }));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// HTML
|
|
1813
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1814
|
+
res.end(getHTML());
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
checkClaudeCLI();
|
|
1818
|
+
loadThreadMap();
|
|
1819
|
+
watchTasks();
|
|
1820
|
+
server.listen(PORT, () => {
|
|
1821
|
+
console.log("");
|
|
1822
|
+
console.log(" " + PROJECT_NAME + " Control Center");
|
|
1823
|
+
console.log(" ─────────────────────────");
|
|
1824
|
+
console.log(" http://localhost:" + PORT);
|
|
1825
|
+
console.log(" " + TASKS_DIR);
|
|
1826
|
+
console.log(" Manual tasks: " + KANBAN_DIR);
|
|
1827
|
+
console.log(" CLI: " + (cliAvailable ? "ready" : process.env.CLAUDECODE ? "unavailable (nested session)" : "not found"));
|
|
1828
|
+
console.log("");
|
|
1829
|
+
console.log(" N : new task");
|
|
1830
|
+
console.log(" C : chat panel");
|
|
1831
|
+
console.log(" A : activity panel");
|
|
1832
|
+
console.log(" Drag : move between columns");
|
|
1833
|
+
console.log(" Ctrl+Shift+K : stop execution");
|
|
1834
|
+
console.log(" Ctrl+C : quit");
|
|
1835
|
+
console.log("");
|
|
1836
|
+
initSlackBot();
|
|
1837
|
+
});
|