create-claude-kanban 3.1.0 → 3.2.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/package.json +1 -1
- package/templates/kanban.cjs +531 -242
- package/templates/kanban.html +805 -636
package/templates/kanban.cjs
CHANGED
|
@@ -12,35 +12,18 @@ delete process.env.CLAUDECODE;
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
var config;
|
|
15
|
-
try {
|
|
16
|
-
var configPath = require("path").join(__dirname, "..", "kanban.config.json");
|
|
17
|
-
if (require("fs").existsSync(configPath)) {
|
|
18
|
-
var raw = JSON.parse(require("fs").readFileSync(configPath, "utf8"));
|
|
19
|
-
config = {
|
|
20
|
-
port: raw.port || 4040,
|
|
21
|
-
projectName: (raw.projects && raw.projects[0] && raw.projects[0].name) || "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: (raw.slack && raw.slack.command) || "/kanban",
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
} else { throw new Error("no config"); }
|
|
32
|
-
} catch {
|
|
15
|
+
try { config = require("claude-kanban/lib/config.cjs"); } catch {
|
|
33
16
|
try { require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") }); } catch {}
|
|
34
17
|
config = {
|
|
35
18
|
port: parseInt(process.env.PORT) || 4040,
|
|
36
|
-
projectName: "
|
|
19
|
+
projectName: "APEX",
|
|
37
20
|
slack: {
|
|
38
21
|
webhookUrl: process.env.SLACK_AGENT_WEBHOOK || "",
|
|
39
22
|
botToken: process.env.SLACK_BOT_TOKEN || "",
|
|
40
23
|
appToken: process.env.SLACK_APP_TOKEN || "",
|
|
41
24
|
channelId: process.env.SLACK_CHANNEL_ID || "",
|
|
42
25
|
adminUsers: (process.env.SLACK_ADMIN_USERS || "").split(",").filter(Boolean),
|
|
43
|
-
command: process.env.SLACK_COMMAND || "/
|
|
26
|
+
command: process.env.SLACK_COMMAND || "/apex",
|
|
44
27
|
},
|
|
45
28
|
};
|
|
46
29
|
}
|
|
@@ -56,6 +39,7 @@ const PROJECT_NAME = config.projectName;
|
|
|
56
39
|
const TASKS_DIR = path.join(os.homedir(), ".claude", "tasks");
|
|
57
40
|
const KANBAN_DIR = path.join(TASKS_DIR, "kanban");
|
|
58
41
|
const ACTIVITY_FILE = path.join(TASKS_DIR, "activity.jsonl");
|
|
42
|
+
const ARCHIVES_DIR = path.join(TASKS_DIR, "_archives");
|
|
59
43
|
|
|
60
44
|
if (!fs.existsSync(KANBAN_DIR)) {
|
|
61
45
|
fs.mkdirSync(KANBAN_DIR, { recursive: true });
|
|
@@ -70,7 +54,7 @@ function readProjects() {
|
|
|
70
54
|
return JSON.parse(fs.readFileSync(PROJECTS_FILE, "utf-8"));
|
|
71
55
|
}
|
|
72
56
|
} catch {}
|
|
73
|
-
var defaults = [{ id: "
|
|
57
|
+
var defaults = [{ id: "apex", name: "APEX", dir: "kanban", color: "#3B82F6" }];
|
|
74
58
|
writeProjects(defaults);
|
|
75
59
|
return defaults;
|
|
76
60
|
}
|
|
@@ -150,60 +134,7 @@ function getThreadTs(taskId) {
|
|
|
150
134
|
// ── Chat Session ──
|
|
151
135
|
let chatSessionId = null;
|
|
152
136
|
let chatSessionModel = null;
|
|
153
|
-
|
|
154
|
-
// ── Agent Activity Tracker (v3: zombie detection) ──
|
|
155
|
-
var agentTracker = {}; // taskId -> { pid, startedAt, lastActivity, agent, subject }
|
|
156
|
-
var STALE_THRESHOLD = 30 * 60 * 1000; // 30 min
|
|
157
|
-
|
|
158
|
-
function trackAgent(taskId, pid, agent, subject) {
|
|
159
|
-
agentTracker[String(taskId)] = { pid: pid, startedAt: Date.now(), lastActivity: Date.now(), agent: agent || "", subject: subject || "" };
|
|
160
|
-
}
|
|
161
|
-
function touchAgent(taskId) {
|
|
162
|
-
if (agentTracker[String(taskId)]) agentTracker[String(taskId)].lastActivity = Date.now();
|
|
163
|
-
}
|
|
164
|
-
function untrackAgent(taskId) {
|
|
165
|
-
delete agentTracker[String(taskId)];
|
|
166
|
-
}
|
|
167
|
-
function isProcessAlive(pid) {
|
|
168
|
-
try { process.kill(pid, 0); return true; } catch (e) { return false; }
|
|
169
|
-
}
|
|
170
|
-
function checkZombies() {
|
|
171
|
-
var now = Date.now();
|
|
172
|
-
var zombies = [];
|
|
173
|
-
Object.keys(agentTracker).forEach(function (tid) {
|
|
174
|
-
var info = agentTracker[tid];
|
|
175
|
-
var alive = isProcessAlive(info.pid);
|
|
176
|
-
var stale = (now - info.lastActivity) > STALE_THRESHOLD;
|
|
177
|
-
if (!alive || stale) {
|
|
178
|
-
zombies.push({ taskId: tid, reason: !alive ? "process_dead" : "stale", agent: info.agent, subject: info.subject, lastActivity: info.lastActivity, startedAt: info.startedAt });
|
|
179
|
-
if (!alive) {
|
|
180
|
-
untrackAgent(tid);
|
|
181
|
-
// Auto-mark stale in task file
|
|
182
|
-
try {
|
|
183
|
-
var t = readTask(tid);
|
|
184
|
-
if (t && t.status === "in_progress") {
|
|
185
|
-
updateTask(tid, { _stale: true, _staleReason: "process_dead", _staleAt: new Date().toISOString() });
|
|
186
|
-
logActivity({ type: "updated", taskId: tid, subject: info.subject, detail: "Zombie detected: agent process dead" });
|
|
187
|
-
broadcastRaw({ type: "zombie", taskId: tid, reason: "process_dead" });
|
|
188
|
-
}
|
|
189
|
-
} catch (e) {}
|
|
190
|
-
} else if (stale) {
|
|
191
|
-
try {
|
|
192
|
-
var t = readTask(tid);
|
|
193
|
-
if (t && t.status === "in_progress" && !t._stale) {
|
|
194
|
-
updateTask(tid, { _stale: true, _staleReason: "timeout", _staleAt: new Date().toISOString() });
|
|
195
|
-
logActivity({ type: "updated", taskId: tid, subject: info.subject, detail: "Zombie detected: no activity for 30min" });
|
|
196
|
-
broadcastRaw({ type: "zombie", taskId: tid, reason: "stale" });
|
|
197
|
-
}
|
|
198
|
-
} catch (e) {}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
return zombies;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Run zombie check every 60 seconds
|
|
206
|
-
setInterval(checkZombies, 60000);
|
|
137
|
+
let chatSessionProject = null;
|
|
207
138
|
|
|
208
139
|
// ── Orchestrator Prompt System ──
|
|
209
140
|
const ORCHESTRATOR_FILE = path.join(os.homedir(), ".claude", "orchestrator.md");
|
|
@@ -324,6 +255,19 @@ function executeAction(action) {
|
|
|
324
255
|
return results;
|
|
325
256
|
}
|
|
326
257
|
|
|
258
|
+
function getProjectSourceDir(projectId) {
|
|
259
|
+
var projects = readProjects();
|
|
260
|
+
var proj = projects.find(function(p) { return p.id === projectId; });
|
|
261
|
+
return proj ? (proj.sourceDir || "") : "";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readProjectClaudeMd(projectId) {
|
|
265
|
+
var srcDir = getProjectSourceDir(projectId);
|
|
266
|
+
if (!srcDir) return "";
|
|
267
|
+
var claudeMdPath = path.join(srcDir, "CLAUDE.md");
|
|
268
|
+
try { return fs.readFileSync(claudeMdPath, "utf-8"); } catch { return ""; }
|
|
269
|
+
}
|
|
270
|
+
|
|
327
271
|
function buildChatSystemPrompt(tasks, projectName, currentProjectId) {
|
|
328
272
|
var orchestratorPrompt = readOrchestratorPrompt();
|
|
329
273
|
var history = readOrchestratorHistory(5);
|
|
@@ -374,9 +318,17 @@ function buildChatSystemPrompt(tasks, projectName, currentProjectId) {
|
|
|
374
318
|
prompt += "You are the Orchestrator for the " + projectName + " Kanban board.\n\n";
|
|
375
319
|
}
|
|
376
320
|
prompt += "## Current Board State\n";
|
|
321
|
+
var srcDir = getProjectSourceDir(currentProjectId);
|
|
377
322
|
prompt += "Active Project: " + projectName + " (" + (currentProjectId || "") + ")\n";
|
|
378
|
-
prompt += "Working directory: " + path.join(__dirname, "..") + "\n\n";
|
|
323
|
+
prompt += "Working directory: " + (srcDir || path.join(__dirname, "..")) + "\n\n";
|
|
379
324
|
prompt += (taskDetail || "(보드 비어있음)") + "\n\n";
|
|
325
|
+
|
|
326
|
+
// Inject project-specific CLAUDE.md
|
|
327
|
+
var claudeMd = readProjectClaudeMd(currentProjectId);
|
|
328
|
+
if (claudeMd) {
|
|
329
|
+
prompt += "## Project Specification (CLAUDE.md)\n" + claudeMd + "\n\n";
|
|
330
|
+
}
|
|
331
|
+
|
|
380
332
|
if (crossProjectInfo) {
|
|
381
333
|
prompt += crossProjectInfo;
|
|
382
334
|
}
|
|
@@ -600,11 +552,6 @@ function readActivity(since, limit) {
|
|
|
600
552
|
}
|
|
601
553
|
|
|
602
554
|
// ── Task 파일 읽기 ──
|
|
603
|
-
function readTask(id) {
|
|
604
|
-
var all = readAllTasks();
|
|
605
|
-
return all.find(function(t) { return String(t.id) === String(id); }) || null;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
555
|
function readAllTasks(projectFilter) {
|
|
609
556
|
const tasks = [];
|
|
610
557
|
if (!fs.existsSync(TASKS_DIR)) return tasks;
|
|
@@ -628,7 +575,7 @@ function readAllTasks(projectFilter) {
|
|
|
628
575
|
}
|
|
629
576
|
const sessionPath = path.join(TASKS_DIR, session.name);
|
|
630
577
|
const files = fs.readdirSync(sessionPath).filter(
|
|
631
|
-
(f) => f.endsWith(".json") && f !== ".lock"
|
|
578
|
+
(f) => f.endsWith(".json") && f !== ".lock" && f !== "chat-history.json"
|
|
632
579
|
);
|
|
633
580
|
for (const file of files) {
|
|
634
581
|
try {
|
|
@@ -670,7 +617,7 @@ function getNextId() {
|
|
|
670
617
|
function createTask(data) {
|
|
671
618
|
const id = getNextId();
|
|
672
619
|
const now = new Date().toISOString();
|
|
673
|
-
var projectId = data.project || readProjects()[0].id || "
|
|
620
|
+
var projectId = data.project || readProjects()[0].id || "apex";
|
|
674
621
|
var projectDir = getProjectDir(projectId);
|
|
675
622
|
if (!fs.existsSync(projectDir)) fs.mkdirSync(projectDir, { recursive: true });
|
|
676
623
|
const task = {
|
|
@@ -743,9 +690,6 @@ function updateTask(id, data) {
|
|
|
743
690
|
if (data.reportPath !== undefined) task.reportPath = data.reportPath;
|
|
744
691
|
if (data.reportSummary !== undefined) task.reportSummary = data.reportSummary;
|
|
745
692
|
if (data.parentId !== undefined) task.parentId = data.parentId;
|
|
746
|
-
if (data._stale !== undefined) task._stale = data._stale;
|
|
747
|
-
if (data._staleReason !== undefined) task._staleReason = data._staleReason;
|
|
748
|
-
if (data._staleAt !== undefined) task._staleAt = data._staleAt;
|
|
749
693
|
task.updatedAt = now;
|
|
750
694
|
fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
|
|
751
695
|
|
|
@@ -935,18 +879,8 @@ function buildExecutorPrompt(task) {
|
|
|
935
879
|
if (agentPrompt) parts.push(agentPrompt);
|
|
936
880
|
|
|
937
881
|
parts.push("## 태스크\n\n- **Task ID**: " + task.id + "\n- **Subject**: " + task.subject + "\n\n" + (task.description || "(설명 없음)"));
|
|
938
|
-
|
|
939
|
-
// v3: Include review feedback from comments
|
|
940
|
-
if (task.comments && task.comments.length > 0) {
|
|
941
|
-
var reviewComments = task.comments.filter(function (c) { return c.author === "reviewer"; });
|
|
942
|
-
if (reviewComments.length > 0) {
|
|
943
|
-
parts.push("## 리뷰 피드백\n\n이전 실행에서 리뷰어가 남긴 피드백입니다. 반드시 반영하세요:\n\n" +
|
|
944
|
-
reviewComments.map(function (c) { return "- " + c.text + " (" + c.createdAt + ")"; }).join("\n"));
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
882
|
parts.push("작업 디렉토리: " + path.join(__dirname, ".."));
|
|
949
|
-
parts.push("## Slack Progress Reporting\n\n진행 상황을 Slack에 보고할 때 반드시 아래 API를 사용한다:\n\n```bash\ncurl -s -X POST http://localhost:
|
|
883
|
+
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를 통해 보고.");
|
|
950
884
|
|
|
951
885
|
return parts.join("\n\n---\n\n");
|
|
952
886
|
}
|
|
@@ -960,14 +894,14 @@ function spawnExecutor(task) {
|
|
|
960
894
|
var execEnv = Object.assign({}, process.env);
|
|
961
895
|
delete execEnv.ANTHROPIC_API_KEY; // Max 구독 OAuth 사용
|
|
962
896
|
delete execEnv.CLAUDECODE; // executor가 nested로 오인하지 않도록
|
|
897
|
+
var execCwd = getProjectSourceDir(task.project) || path.join(__dirname, "..");
|
|
963
898
|
const proc = spawn("claude", ["-p", "--verbose", "--output-format", "stream-json", "--model", model, "--no-session-persistence", "--dangerously-skip-permissions"], {
|
|
964
|
-
cwd:
|
|
899
|
+
cwd: execCwd,
|
|
965
900
|
env: execEnv,
|
|
966
901
|
stdio: ["pipe", "pipe", "pipe"],
|
|
967
902
|
});
|
|
968
903
|
|
|
969
904
|
activeExec = { process: proc, taskId: taskId, output: "" };
|
|
970
|
-
trackAgent(taskId, proc.pid, task.agent, task.subject);
|
|
971
905
|
broadcastRaw({ type: "exec_start", taskId: taskId, subject: task.subject, agent: task.agent || "", model: model });
|
|
972
906
|
logActivity({ type: "started", taskId: taskId, subject: task.subject, detail: "Agent: " + (task.agent || "none") + " | Model: " + model });
|
|
973
907
|
|
|
@@ -991,7 +925,6 @@ function spawnExecutor(task) {
|
|
|
991
925
|
else if (json.type === "content_block_delta" && json.delta) text = json.delta.text || "";
|
|
992
926
|
if (text && activeExec) {
|
|
993
927
|
activeExec.output += text;
|
|
994
|
-
touchAgent(taskId);
|
|
995
928
|
broadcastRaw({ type: "exec", taskId: taskId, chunk: text });
|
|
996
929
|
}
|
|
997
930
|
} catch (e) {}
|
|
@@ -1005,7 +938,6 @@ function spawnExecutor(task) {
|
|
|
1005
938
|
proc.on("close", function (code) {
|
|
1006
939
|
var output = activeExec ? activeExec.output : "";
|
|
1007
940
|
activeExec = null;
|
|
1008
|
-
untrackAgent(taskId);
|
|
1009
941
|
if (code === 0) {
|
|
1010
942
|
updateTask(taskId, { status: "in_review", reportSummary: output.slice(0, 500) });
|
|
1011
943
|
broadcastRaw({ type: "exec_done", taskId: taskId, exitCode: 0 });
|
|
@@ -1386,8 +1318,9 @@ function handleSlackAsk(question, channelId, client) {
|
|
|
1386
1318
|
|
|
1387
1319
|
var askEnv = Object.assign({}, process.env);
|
|
1388
1320
|
delete askEnv.ANTHROPIC_API_KEY;
|
|
1321
|
+
var askCwd = getProjectSourceDir(defaultProjId) || path.join(__dirname, "..");
|
|
1389
1322
|
var proc = spawn("claude", ["-p", "--output-format", "text", "--model", "sonnet", "--no-session-persistence", "--dangerously-skip-permissions"], {
|
|
1390
|
-
cwd:
|
|
1323
|
+
cwd: askCwd,
|
|
1391
1324
|
env: askEnv,
|
|
1392
1325
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1393
1326
|
});
|
|
@@ -1481,6 +1414,13 @@ function watchTasks() {
|
|
|
1481
1414
|
broadcast({ type: "update", tasks });
|
|
1482
1415
|
}
|
|
1483
1416
|
|
|
1417
|
+
// macOS fs.watch fires multiple events per single file change — debounce
|
|
1418
|
+
let _changeTimer = null;
|
|
1419
|
+
function debouncedOnFileChange() {
|
|
1420
|
+
if (_changeTimer) clearTimeout(_changeTimer);
|
|
1421
|
+
_changeTimer = setTimeout(() => { _changeTimer = null; onFileChange(); }, 300);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1484
1424
|
function scanAndWatch() {
|
|
1485
1425
|
if (!fs.existsSync(TASKS_DIR)) return;
|
|
1486
1426
|
try {
|
|
@@ -1491,7 +1431,7 @@ function watchTasks() {
|
|
|
1491
1431
|
if (!watched.has(sp)) {
|
|
1492
1432
|
watched.add(sp);
|
|
1493
1433
|
try {
|
|
1494
|
-
fs.watch(sp, { persistent: false }, () =>
|
|
1434
|
+
fs.watch(sp, { persistent: false }, () => debouncedOnFileChange());
|
|
1495
1435
|
} catch {}
|
|
1496
1436
|
}
|
|
1497
1437
|
}
|
|
@@ -1500,13 +1440,376 @@ function watchTasks() {
|
|
|
1500
1440
|
try {
|
|
1501
1441
|
fs.watch(TASKS_DIR, { persistent: false }, () => {
|
|
1502
1442
|
scanAndWatch();
|
|
1503
|
-
|
|
1443
|
+
debouncedOnFileChange();
|
|
1504
1444
|
});
|
|
1505
1445
|
} catch {}
|
|
1506
1446
|
scanAndWatch();
|
|
1507
1447
|
setInterval(() => broadcast({ type: "update", tasks: readAllTasks() }), 2000);
|
|
1508
1448
|
}
|
|
1509
1449
|
|
|
1450
|
+
// ── Archive ──
|
|
1451
|
+
function archiveTasks(projectId, taskIds) {
|
|
1452
|
+
var projects = readProjects();
|
|
1453
|
+
var proj = projects.find(function(p) { return p.id === projectId; });
|
|
1454
|
+
if (!proj) throw new Error("Project not found: " + projectId);
|
|
1455
|
+
var dirName = proj.dir || proj.id;
|
|
1456
|
+
|
|
1457
|
+
// Collect completed tasks
|
|
1458
|
+
var allTasks = readAllTasks(projectId);
|
|
1459
|
+
var completedTasks = allTasks.filter(function(t) {
|
|
1460
|
+
return t.status === "completed" && t._editable;
|
|
1461
|
+
});
|
|
1462
|
+
if (taskIds && taskIds.length > 0) {
|
|
1463
|
+
completedTasks = completedTasks.filter(function(t) {
|
|
1464
|
+
return taskIds.indexOf(String(t.id)) >= 0;
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
if (completedTasks.length === 0) return { ok: true, date: null, count: 0 };
|
|
1468
|
+
|
|
1469
|
+
// Build children lookup
|
|
1470
|
+
var childrenMap = {};
|
|
1471
|
+
allTasks.forEach(function(t) {
|
|
1472
|
+
if (t.parentId) {
|
|
1473
|
+
if (!childrenMap[t.parentId]) childrenMap[t.parentId] = [];
|
|
1474
|
+
childrenMap[t.parentId].push(String(t.id));
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
// Build archive task records
|
|
1479
|
+
var now = new Date();
|
|
1480
|
+
var dateStr = now.toISOString().slice(0, 10);
|
|
1481
|
+
var archiveTasks = completedTasks.map(function(t) {
|
|
1482
|
+
var elapsed = null;
|
|
1483
|
+
if (t.startedAt && t.completedAt) {
|
|
1484
|
+
elapsed = Math.round((new Date(t.completedAt) - new Date(t.startedAt)) / 1000);
|
|
1485
|
+
}
|
|
1486
|
+
return {
|
|
1487
|
+
id: String(t.id),
|
|
1488
|
+
subject: t.subject || "",
|
|
1489
|
+
description: t.description || "",
|
|
1490
|
+
status: t.status,
|
|
1491
|
+
priority: t.priority || "medium",
|
|
1492
|
+
agent: t.agent || "",
|
|
1493
|
+
owner: t.owner || "",
|
|
1494
|
+
parentId: t.parentId || null,
|
|
1495
|
+
children: childrenMap[String(t.id)] || [],
|
|
1496
|
+
createdAt: t.createdAt || null,
|
|
1497
|
+
startedAt: t.startedAt || null,
|
|
1498
|
+
completedAt: t.completedAt || null,
|
|
1499
|
+
elapsedSec: elapsed,
|
|
1500
|
+
reportSummary: t.reportSummary || null,
|
|
1501
|
+
reportPath: t.reportPath || null,
|
|
1502
|
+
};
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
// Merge into date file
|
|
1506
|
+
var archiveDir = path.join(ARCHIVES_DIR, dirName);
|
|
1507
|
+
if (!fs.existsSync(archiveDir)) fs.mkdirSync(archiveDir, { recursive: true });
|
|
1508
|
+
var archiveFile = path.join(archiveDir, dateStr + ".json");
|
|
1509
|
+
|
|
1510
|
+
var existing = null;
|
|
1511
|
+
if (fs.existsSync(archiveFile)) {
|
|
1512
|
+
try { existing = JSON.parse(fs.readFileSync(archiveFile, "utf-8")); } catch {}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
var agents = new Set();
|
|
1516
|
+
archiveTasks.forEach(function(t) { if (t.agent) agents.add(t.agent); });
|
|
1517
|
+
|
|
1518
|
+
if (existing && existing.tasks) {
|
|
1519
|
+
// Merge: deduplicate by ID
|
|
1520
|
+
var existingIds = new Set(existing.tasks.map(function(t) { return t.id; }));
|
|
1521
|
+
archiveTasks.forEach(function(t) {
|
|
1522
|
+
if (!existingIds.has(t.id)) {
|
|
1523
|
+
existing.tasks.push(t);
|
|
1524
|
+
existingIds.add(t.id);
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
existing.tasks.forEach(function(t) { if (t.agent) agents.add(t.agent); });
|
|
1528
|
+
existing.archivedAt = now.toISOString();
|
|
1529
|
+
existing.stats = { total: existing.tasks.length, agents: Array.from(agents) };
|
|
1530
|
+
fs.writeFileSync(archiveFile, JSON.stringify(existing, null, 2));
|
|
1531
|
+
} else {
|
|
1532
|
+
var archive = {
|
|
1533
|
+
date: dateStr,
|
|
1534
|
+
project: projectId,
|
|
1535
|
+
projectDir: dirName,
|
|
1536
|
+
archivedAt: now.toISOString(),
|
|
1537
|
+
tasks: archiveTasks,
|
|
1538
|
+
stats: { total: archiveTasks.length, agents: Array.from(agents) },
|
|
1539
|
+
};
|
|
1540
|
+
fs.writeFileSync(archiveFile, JSON.stringify(archive, null, 2));
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Delete task files
|
|
1544
|
+
completedTasks.forEach(function(t) {
|
|
1545
|
+
var fp = findTaskFile(String(t.id));
|
|
1546
|
+
if (fp) {
|
|
1547
|
+
try { fs.unlinkSync(fp); } catch {}
|
|
1548
|
+
taskSnapshot.delete(String(t.id));
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
// Activity log
|
|
1553
|
+
logActivity({
|
|
1554
|
+
type: "archived",
|
|
1555
|
+
taskId: completedTasks.map(function(t) { return t.id; }).join(","),
|
|
1556
|
+
subject: "Archived " + completedTasks.length + " tasks",
|
|
1557
|
+
agent: "",
|
|
1558
|
+
detail: dateStr + " → " + archiveFile,
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Generate HTML report
|
|
1562
|
+
var finalData = existing || archive;
|
|
1563
|
+
generateArchiveHTML(finalData, archiveDir);
|
|
1564
|
+
generateArchiveIndex(archiveDir, dirName, projectId);
|
|
1565
|
+
|
|
1566
|
+
return { ok: true, date: dateStr, count: completedTasks.length };
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function generateArchiveHTML(data, archiveDir) {
|
|
1570
|
+
var tasks = data.tasks || [];
|
|
1571
|
+
var dateStr = data.date;
|
|
1572
|
+
var taskMap = {};
|
|
1573
|
+
tasks.forEach(function(t) { taskMap[t.id] = t; });
|
|
1574
|
+
|
|
1575
|
+
var parents = [], childrenMap = {}, orphans = [], standalone = [];
|
|
1576
|
+
tasks.forEach(function(t) {
|
|
1577
|
+
if (t.children && t.children.length > 0) {
|
|
1578
|
+
parents.push(t);
|
|
1579
|
+
childrenMap[t.id] = t.children.map(function(cid) { return taskMap[cid]; }).filter(Boolean);
|
|
1580
|
+
} else if (t.parentId) {
|
|
1581
|
+
if (!taskMap[t.parentId]) orphans.push(t);
|
|
1582
|
+
} else {
|
|
1583
|
+
standalone.push(t);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
function esc(s) { return String(s || "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
|
|
1588
|
+
function nl2br(s) { return esc(s).replace(/\n/g, "<br>"); }
|
|
1589
|
+
function fmtDur(sec) {
|
|
1590
|
+
if (sec == null) return "-";
|
|
1591
|
+
if (sec < 60) return sec + "s";
|
|
1592
|
+
if (sec < 3600) return Math.floor(sec/60) + "m " + (sec%60) + "s";
|
|
1593
|
+
return Math.floor(sec/3600) + "h " + Math.floor((sec%3600)/60) + "m";
|
|
1594
|
+
}
|
|
1595
|
+
function fmtDateTime(iso) {
|
|
1596
|
+
if (!iso) return "-";
|
|
1597
|
+
var d = new Date(iso);
|
|
1598
|
+
return d.toLocaleDateString("ko-KR") + " " + d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" });
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function renderTask(t, indent) {
|
|
1602
|
+
var cls = indent ? "child" : (t.children && t.children.length > 0 ? "parent" : "");
|
|
1603
|
+
var h = '<div class="task ' + cls + '">';
|
|
1604
|
+
h += '<div class="task-head">';
|
|
1605
|
+
h += '<span class="tid">#' + esc(t.id) + '</span>';
|
|
1606
|
+
h += '<span class="subj">' + esc(t.subject) + '</span>';
|
|
1607
|
+
if (t.priority === "high" || t.priority === "critical") h += '<span class="pri pri-' + t.priority + '">' + esc(t.priority) + '</span>';
|
|
1608
|
+
h += '</div>';
|
|
1609
|
+
h += '<div class="task-meta">';
|
|
1610
|
+
if (t.agent) h += '<span class="meta-tag agent">' + esc(t.agent) + '</span>';
|
|
1611
|
+
if (t.owner && t.owner !== t.agent) h += '<span class="meta-tag">owner: ' + esc(t.owner) + '</span>';
|
|
1612
|
+
if (t.createdAt) h += '<span>created: ' + fmtDateTime(t.createdAt) + '</span>';
|
|
1613
|
+
if (t.completedAt) h += '<span>done: ' + fmtDateTime(t.completedAt) + '</span>';
|
|
1614
|
+
if (t.elapsedSec != null) h += '<span>' + fmtDur(t.elapsedSec) + '</span>';
|
|
1615
|
+
if (indent && t.parentId) h += '<span class="pref">← #' + esc(t.parentId) + '</span>';
|
|
1616
|
+
h += '</div>';
|
|
1617
|
+
if (t.reportSummary) h += '<div class="task-report"><div class="report-label">Report</div>' + nl2br(t.reportSummary) + '</div>';
|
|
1618
|
+
if (t.description) h += '<div class="task-detail"><div class="detail-label">Description</div>' + nl2br(t.description) + '</div>';
|
|
1619
|
+
if (t.children && t.children.length > 0) h += '<div class="task-children">' + t.children.length + ' subtasks</div>';
|
|
1620
|
+
h += '</div>';
|
|
1621
|
+
return h;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
var body = '';
|
|
1625
|
+
parents.forEach(function(p) {
|
|
1626
|
+
body += renderTask(p, false);
|
|
1627
|
+
(childrenMap[p.id] || []).forEach(function(c) { body += renderTask(c, true); });
|
|
1628
|
+
});
|
|
1629
|
+
if (standalone.length > 0) {
|
|
1630
|
+
if (parents.length > 0) body += '<div class="section-label">Standalone</div>';
|
|
1631
|
+
standalone.forEach(function(t) { body += renderTask(t, false); });
|
|
1632
|
+
}
|
|
1633
|
+
if (orphans.length > 0) {
|
|
1634
|
+
body += '<div class="section-label">Orphan subtasks</div>';
|
|
1635
|
+
orphans.forEach(function(t) { body += renderTask(t, true); });
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
var agentList = (data.stats && data.stats.agents) ? data.stats.agents.join(", ") : "";
|
|
1639
|
+
var css = '*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}\n';
|
|
1640
|
+
css += 'body{font-family:-apple-system,BlinkMacSystemFont,"Pretendard Variable",sans-serif;background:#050A12;color:#DDE5F0;font-size:13px;line-height:1.6}\n';
|
|
1641
|
+
css += '.wrap{max-width:900px;margin:0 auto;padding:32px 24px 60px}\n';
|
|
1642
|
+
css += 'h1{font-size:22px;font-weight:700;margin-bottom:4px;color:#fff}\n';
|
|
1643
|
+
css += '.subtitle{font-size:12px;color:#607088;margin-bottom:24px}\n';
|
|
1644
|
+
css += '.stats-bar{display:flex;gap:16px;padding:12px 16px;background:#0B1220;border:1px solid #1E2D45;border-radius:6px;margin-bottom:28px;font-size:12px;color:#8899B0;flex-wrap:wrap}\n';
|
|
1645
|
+
css += '.stats-bar strong{color:#DDE5F0}\n';
|
|
1646
|
+
css += '.section-label{font-size:11px;font-weight:600;color:#607088;text-transform:uppercase;letter-spacing:.5px;margin:28px 0 10px;padding-bottom:6px;border-bottom:1px solid #1E2D45}\n';
|
|
1647
|
+
css += '.task{padding:14px 16px;border:1px solid #1E2D45;border-radius:6px;margin-bottom:8px;background:#0B1220;transition:background .15s}\n';
|
|
1648
|
+
css += '.task:hover{background:#101828}\n';
|
|
1649
|
+
css += '.task.parent{border-left:3px solid #1A6FEF}\n';
|
|
1650
|
+
css += '.task.child{margin-left:28px;border-left:2px dashed #2A3F5F}\n';
|
|
1651
|
+
css += '.task-head{display:flex;align-items:center;gap:8px;margin-bottom:6px}\n';
|
|
1652
|
+
css += '.tid{font-size:11px;font-family:monospace;color:#1A6FEF;font-weight:700;flex-shrink:0}\n';
|
|
1653
|
+
css += '.subj{font-size:14px;font-weight:600;color:#fff;flex:1}\n';
|
|
1654
|
+
css += '.pri{font-size:9px;font-weight:700;padding:2px 8px;border-radius:3px;text-transform:uppercase}\n';
|
|
1655
|
+
css += '.pri-high{background:rgba(239,68,68,.15);color:#EF4444}\n';
|
|
1656
|
+
css += '.pri-critical{background:rgba(239,68,68,.25);color:#EF4444}\n';
|
|
1657
|
+
css += '.task-meta{display:flex;gap:10px;font-size:10px;color:#607088;flex-wrap:wrap;margin-bottom:6px;align-items:center}\n';
|
|
1658
|
+
css += '.meta-tag{padding:1px 7px;background:#162035;border-radius:3px;color:#8899B0;font-weight:500}\n';
|
|
1659
|
+
css += '.meta-tag.agent{color:#1A6FEF;background:rgba(26,111,239,.1)}\n';
|
|
1660
|
+
css += '.task-meta .pref{font-family:monospace;color:#C8A24A}\n';
|
|
1661
|
+
css += '.task-report{margin-top:8px;padding:10px 12px;background:#0F1B30;border:1px solid #1E2D45;border-left:3px solid #22C55E;border-radius:4px;font-size:12px;color:#8899B0;line-height:1.7}\n';
|
|
1662
|
+
css += '.report-label{font-size:9px;font-weight:700;color:#22C55E;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n';
|
|
1663
|
+
css += '.task-detail{margin-top:6px;padding:10px 12px;background:#0A0F1A;border:1px solid #1E2D45;border-radius:4px;font-size:12px;color:#607088;line-height:1.7}\n';
|
|
1664
|
+
css += '.detail-label{font-size:9px;font-weight:700;color:#455570;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}\n';
|
|
1665
|
+
css += '.task-children{margin-top:6px;font-size:10px;color:#1A6FEF;font-weight:600}\n';
|
|
1666
|
+
css += '.nav{margin-top:32px;padding-top:16px;border-top:1px solid #1E2D45;font-size:11px;color:#607088}\n';
|
|
1667
|
+
css += '.nav a{color:#1A6FEF;text-decoration:none}\n';
|
|
1668
|
+
css += '.nav a:hover{text-decoration:underline}\n';
|
|
1669
|
+
|
|
1670
|
+
var html = '<!DOCTYPE html>\n<html lang="ko"><head><meta charset="UTF-8">\n';
|
|
1671
|
+
html += '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n';
|
|
1672
|
+
html += '<title>Archive ' + esc(dateStr) + ' — ' + esc(data.project || "") + '</title>\n';
|
|
1673
|
+
html += '<style>' + css + '</style></head><body>\n';
|
|
1674
|
+
html += '<div class="wrap">\n';
|
|
1675
|
+
html += '<h1>' + esc(dateStr) + '</h1>\n';
|
|
1676
|
+
html += '<div class="subtitle">' + esc(data.project || "") + ' archive · ' + fmtDateTime(data.archivedAt) + '</div>\n';
|
|
1677
|
+
html += '<div class="stats-bar"><span><strong>' + tasks.length + '</strong> tasks</span>';
|
|
1678
|
+
if (agentList) html += '<span>agents: ' + esc(agentList) + '</span>';
|
|
1679
|
+
html += '</div>\n';
|
|
1680
|
+
html += body;
|
|
1681
|
+
html += '<div class="nav"><a href="../index.html">← All archives</a></div>\n';
|
|
1682
|
+
html += '</div></body></html>';
|
|
1683
|
+
|
|
1684
|
+
fs.writeFileSync(path.join(archiveDir, dateStr + ".html"), html);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function generateArchiveIndex(archiveDir, dirName, projectId) {
|
|
1688
|
+
// Ensure per-date HTML exists for all JSONs in this project
|
|
1689
|
+
try {
|
|
1690
|
+
var jsonFiles = fs.readdirSync(archiveDir).filter(function(f) { return f.endsWith(".json"); });
|
|
1691
|
+
jsonFiles.forEach(function(f) {
|
|
1692
|
+
var htmlF = f.replace(".json", ".html");
|
|
1693
|
+
if (!fs.existsSync(path.join(archiveDir, htmlF))) {
|
|
1694
|
+
try {
|
|
1695
|
+
var d = JSON.parse(fs.readFileSync(path.join(archiveDir, f), "utf-8"));
|
|
1696
|
+
generateArchiveHTML(d, archiveDir);
|
|
1697
|
+
} catch {}
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
} catch {}
|
|
1701
|
+
generateUnifiedArchiveIndex();
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function generateUnifiedArchiveIndex() {
|
|
1705
|
+
var projects = readProjects();
|
|
1706
|
+
function esc(s) { return String(s || "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
|
|
1707
|
+
|
|
1708
|
+
var projectData = [];
|
|
1709
|
+
projects.forEach(function(p) {
|
|
1710
|
+
var dirName = p.dir || p.id;
|
|
1711
|
+
var archDir = path.join(ARCHIVES_DIR, dirName);
|
|
1712
|
+
var entries = [];
|
|
1713
|
+
if (fs.existsSync(archDir)) {
|
|
1714
|
+
try {
|
|
1715
|
+
var files = fs.readdirSync(archDir).filter(function(f) { return f.endsWith(".json"); }).sort().reverse();
|
|
1716
|
+
entries = files.map(function(f) {
|
|
1717
|
+
try {
|
|
1718
|
+
var data = JSON.parse(fs.readFileSync(path.join(archDir, f), "utf-8"));
|
|
1719
|
+
return { date: data.date || f.replace(".json",""), count: (data.tasks||[]).length, agents: (data.stats&&data.stats.agents)||[], dir: dirName };
|
|
1720
|
+
} catch { return { date: f.replace(".json",""), count: 0, agents: [], dir: dirName }; }
|
|
1721
|
+
});
|
|
1722
|
+
} catch {}
|
|
1723
|
+
}
|
|
1724
|
+
projectData.push({ id: p.id, name: p.name, color: p.color || "#3B82F6", dir: dirName, entries: entries });
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
var totalDays = projectData.reduce(function(s, p) { return s + p.entries.length; }, 0);
|
|
1728
|
+
|
|
1729
|
+
var css = '*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}\n';
|
|
1730
|
+
css += 'body{font-family:-apple-system,BlinkMacSystemFont,"Pretendard Variable",sans-serif;background:#050A12;color:#DDE5F0;font-size:13px;line-height:1.6}\n';
|
|
1731
|
+
css += '.wrap{max-width:900px;margin:0 auto;padding:32px 24px 60px}\n';
|
|
1732
|
+
css += 'h1{font-size:22px;font-weight:700;margin-bottom:4px;color:#fff}\n';
|
|
1733
|
+
css += '.subtitle{font-size:12px;color:#607088;margin-bottom:20px}\n';
|
|
1734
|
+
css += '.tabs{display:flex;gap:0;border-bottom:1px solid #1E2D45;margin-bottom:24px}\n';
|
|
1735
|
+
css += '.tab{padding:10px 20px;cursor:pointer;font-size:13px;font-weight:600;color:#607088;border:none;background:none;font-family:inherit;border-bottom:2px solid transparent;transition:all .15s}\n';
|
|
1736
|
+
css += '.tab:hover{color:#8899B0}\n';
|
|
1737
|
+
css += '.tab.active{color:#DDE5F0;border-bottom-color:var(--tc,#1A6FEF)}\n';
|
|
1738
|
+
css += '.tab .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}\n';
|
|
1739
|
+
css += '.tab .cnt{font-size:10px;color:#455570;margin-left:4px;font-weight:400;font-family:monospace}\n';
|
|
1740
|
+
css += '.panel{display:none}\n';
|
|
1741
|
+
css += '.panel.active{display:block}\n';
|
|
1742
|
+
css += '.empty-msg{text-align:center;padding:40px;color:#455570;font-size:12px}\n';
|
|
1743
|
+
css += 'table{width:100%;border-collapse:collapse}\n';
|
|
1744
|
+
css += 'th{text-align:left;padding:10px 14px;background:#0B1220;border:1px solid #1E2D45;font-size:10px;font-weight:600;color:#607088;text-transform:uppercase;letter-spacing:.5px}\n';
|
|
1745
|
+
css += 'td{padding:10px 14px;border:1px solid #1E2D45;font-size:13px}\n';
|
|
1746
|
+
css += 'tr:hover td{background:#0B1220}\n';
|
|
1747
|
+
css += 'a{color:#1A6FEF;text-decoration:none}\n';
|
|
1748
|
+
css += 'a:hover{text-decoration:underline}\n';
|
|
1749
|
+
css += '.agent-tags{display:flex;gap:4px;flex-wrap:wrap}\n';
|
|
1750
|
+
css += '.agent-tag{font-size:10px;padding:1px 6px;background:#162035;border-radius:3px;color:#8899B0}\n';
|
|
1751
|
+
|
|
1752
|
+
var tabsHtml = '';
|
|
1753
|
+
var panelsHtml = '';
|
|
1754
|
+
projectData.forEach(function(p, i) {
|
|
1755
|
+
var activeClass = i === 0 ? " active" : "";
|
|
1756
|
+
tabsHtml += '<button class="tab' + activeClass + '" style="--tc:' + esc(p.color) + '" onclick="switchTab(\'' + esc(p.id) + '\')">';
|
|
1757
|
+
tabsHtml += '<span class="dot" style="background:' + esc(p.color) + '"></span>';
|
|
1758
|
+
tabsHtml += esc(p.name);
|
|
1759
|
+
tabsHtml += '<span class="cnt">' + p.entries.length + '</span>';
|
|
1760
|
+
tabsHtml += '</button>';
|
|
1761
|
+
|
|
1762
|
+
panelsHtml += '<div class="panel' + activeClass + '" data-project="' + esc(p.id) + '">';
|
|
1763
|
+
if (p.entries.length === 0) {
|
|
1764
|
+
panelsHtml += '<div class="empty-msg">No archives yet</div>';
|
|
1765
|
+
} else {
|
|
1766
|
+
panelsHtml += '<table><thead><tr><th>Date</th><th>Tasks</th><th>Agents</th></tr></thead><tbody>';
|
|
1767
|
+
p.entries.forEach(function(e) {
|
|
1768
|
+
panelsHtml += '<tr>';
|
|
1769
|
+
panelsHtml += '<td><a href="' + esc(e.dir) + '/' + esc(e.date) + '.html">' + esc(e.date) + '</a></td>';
|
|
1770
|
+
panelsHtml += '<td>' + e.count + '</td>';
|
|
1771
|
+
panelsHtml += '<td><div class="agent-tags">';
|
|
1772
|
+
e.agents.forEach(function(a) { panelsHtml += '<span class="agent-tag">' + esc(a) + '</span>'; });
|
|
1773
|
+
panelsHtml += '</div></td>';
|
|
1774
|
+
panelsHtml += '</tr>';
|
|
1775
|
+
});
|
|
1776
|
+
panelsHtml += '</tbody></table>';
|
|
1777
|
+
}
|
|
1778
|
+
panelsHtml += '</div>';
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
var js = 'function switchTab(id){';
|
|
1782
|
+
js += 'document.querySelectorAll(".tab").forEach(function(t){t.classList.remove("active")});';
|
|
1783
|
+
js += 'document.querySelectorAll(".panel").forEach(function(p){p.classList.remove("active")});';
|
|
1784
|
+
js += 'event.currentTarget.classList.add("active");';
|
|
1785
|
+
js += 'document.querySelector(\'[data-project="\'+id+\'"]\').classList.add("active");';
|
|
1786
|
+
js += '}';
|
|
1787
|
+
|
|
1788
|
+
var html = '<!DOCTYPE html>\n<html lang="ko"><head><meta charset="UTF-8">\n';
|
|
1789
|
+
html += '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n';
|
|
1790
|
+
html += '<title>Archives</title>\n';
|
|
1791
|
+
html += '<style>' + css + '</style></head><body>\n';
|
|
1792
|
+
html += '<div class="wrap">\n';
|
|
1793
|
+
html += '<h1>Archives</h1>\n';
|
|
1794
|
+
html += '<div class="subtitle">' + projects.length + ' projects · ' + totalDays + ' days</div>\n';
|
|
1795
|
+
html += '<div class="tabs">' + tabsHtml + '</div>\n';
|
|
1796
|
+
html += panelsHtml;
|
|
1797
|
+
html += '</div>\n';
|
|
1798
|
+
html += '<script>' + js + '</script>\n';
|
|
1799
|
+
html += '</body></html>';
|
|
1800
|
+
|
|
1801
|
+
fs.writeFileSync(path.join(ARCHIVES_DIR, "index.html"), html);
|
|
1802
|
+
|
|
1803
|
+
// Per-project index.html → redirect to unified
|
|
1804
|
+
projectData.forEach(function(p) {
|
|
1805
|
+
var perDir = path.join(ARCHIVES_DIR, p.dir);
|
|
1806
|
+
if (fs.existsSync(perDir)) {
|
|
1807
|
+
fs.writeFileSync(path.join(perDir, "index.html"),
|
|
1808
|
+
'<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=../index.html"></head><body><a href="../index.html">Archives</a></body></html>');
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1510
1813
|
// ── Request body parser ──
|
|
1511
1814
|
function parseBody(req) {
|
|
1512
1815
|
return new Promise((resolve, reject) => {
|
|
@@ -1601,94 +1904,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
1601
1904
|
return;
|
|
1602
1905
|
}
|
|
1603
1906
|
|
|
1604
|
-
// GET /api/metrics — progress metrics
|
|
1605
|
-
if (req.url.split("?")[0] === "/api/metrics" && req.method === "GET") {
|
|
1606
|
-
var metricsParams = new URL(req.url, "http://localhost").searchParams;
|
|
1607
|
-
var mProject = metricsParams.get("project") || null;
|
|
1608
|
-
var allT = readAllTasks(mProject);
|
|
1609
|
-
var total = allT.length, pending = 0, active = 0, review = 0, completed = 0, blocked = 0, stale = 0;
|
|
1610
|
-
var byAgent = {}, byPriority = { high: 0, medium: 0, low: 0 };
|
|
1611
|
-
var dailyCompleted = {}; // date -> count
|
|
1612
|
-
var dailyCreated = {};
|
|
1613
|
-
allT.forEach(function (t) {
|
|
1614
|
-
var st = t.status || "pending";
|
|
1615
|
-
if (st === "pending") pending++;
|
|
1616
|
-
else if (st === "in_progress") active++;
|
|
1617
|
-
else if (st === "in_review") review++;
|
|
1618
|
-
else if (st === "completed") completed++;
|
|
1619
|
-
if (t.blockedBy && t.blockedBy.length > 0 && st !== "completed") blocked++;
|
|
1620
|
-
if (t._stale) stale++;
|
|
1621
|
-
var ag = t.agent || "unassigned";
|
|
1622
|
-
if (!byAgent[ag]) byAgent[ag] = { total: 0, completed: 0, in_progress: 0, pending: 0 };
|
|
1623
|
-
byAgent[ag].total++;
|
|
1624
|
-
byAgent[ag][st] = (byAgent[ag][st] || 0) + 1;
|
|
1625
|
-
byPriority[t.priority || "medium"]++;
|
|
1626
|
-
if (t.completedAt) {
|
|
1627
|
-
var d = t.completedAt.slice(0, 10);
|
|
1628
|
-
dailyCompleted[d] = (dailyCompleted[d] || 0) + 1;
|
|
1629
|
-
}
|
|
1630
|
-
if (t.createdAt) {
|
|
1631
|
-
var d2 = t.createdAt.slice(0, 10);
|
|
1632
|
-
dailyCreated[d2] = (dailyCreated[d2] || 0) + 1;
|
|
1633
|
-
}
|
|
1634
|
-
});
|
|
1635
|
-
// Build burndown: remaining tasks over time
|
|
1636
|
-
var burndown = [];
|
|
1637
|
-
var dates = Object.keys(Object.assign({}, dailyCompleted, dailyCreated)).sort();
|
|
1638
|
-
if (dates.length > 0) {
|
|
1639
|
-
var remaining = total;
|
|
1640
|
-
for (var di = dates.length - 1; di >= 0; di--) {
|
|
1641
|
-
burndown.unshift({ date: dates[di], remaining: remaining });
|
|
1642
|
-
remaining = remaining + (dailyCompleted[dates[di]] || 0) - (dailyCreated[dates[di]] || 0);
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1646
|
-
res.end(JSON.stringify({
|
|
1647
|
-
total: total, pending: pending, active: active, review: review, completed: completed,
|
|
1648
|
-
blocked: blocked, stale: stale,
|
|
1649
|
-
completionRate: total > 0 ? Math.round(completed / total * 100) : 0,
|
|
1650
|
-
byAgent: byAgent, byPriority: byPriority,
|
|
1651
|
-
dailyCompleted: dailyCompleted, dailyCreated: dailyCreated,
|
|
1652
|
-
burndown: burndown
|
|
1653
|
-
}));
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
// GET /api/agents/status — agent tracker + zombie info
|
|
1658
|
-
if (req.url === "/api/agents/status" && req.method === "GET") {
|
|
1659
|
-
var agents = {};
|
|
1660
|
-
var allT = readAllTasks();
|
|
1661
|
-
// Collect agent stats
|
|
1662
|
-
allT.forEach(function(t) {
|
|
1663
|
-
var a = t.agent || "unassigned";
|
|
1664
|
-
if (!agents[a]) agents[a] = { total: 0, completed: 0, in_progress: 0, pending: 0, in_review: 0 };
|
|
1665
|
-
agents[a].total++;
|
|
1666
|
-
agents[a][t.status] = (agents[a][t.status] || 0) + 1;
|
|
1667
|
-
});
|
|
1668
|
-
// Merge live tracking
|
|
1669
|
-
var tracked = {};
|
|
1670
|
-
Object.keys(agentTracker).forEach(function(tid) {
|
|
1671
|
-
var info = agentTracker[tid];
|
|
1672
|
-
tracked[tid] = { pid: info.pid, alive: isProcessAlive(info.pid), startedAt: info.startedAt, lastActivity: info.lastActivity, agent: info.agent, subject: info.subject };
|
|
1673
|
-
});
|
|
1674
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1675
|
-
res.end(JSON.stringify({ agents: agents, tracked: tracked, activeExec: activeExec ? { taskId: activeExec.taskId } : null }));
|
|
1676
|
-
return;
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
// POST /api/tasks/:id/recover — recover zombie task
|
|
1680
|
-
var recoverMatch = req.url.match(/^\/api\/tasks\/(\d+)\/recover$/);
|
|
1681
|
-
if (recoverMatch && req.method === "POST") {
|
|
1682
|
-
var rid = recoverMatch[1];
|
|
1683
|
-
untrackAgent(rid);
|
|
1684
|
-
updateTask(rid, { status: "pending", _stale: false, _staleReason: null, _staleAt: null, activeForm: null });
|
|
1685
|
-
logActivity({ type: "updated", taskId: rid, subject: "", detail: "Recovered from zombie state" });
|
|
1686
|
-
broadcastRaw({ type: "tasks_updated" });
|
|
1687
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1688
|
-
res.end(JSON.stringify({ ok: true, taskId: rid }));
|
|
1689
|
-
return;
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
1907
|
// GET /api/tasks
|
|
1693
1908
|
if (req.url.split("?")[0] === "/api/tasks" && req.method === "GET") {
|
|
1694
1909
|
var taskParams = new URL(req.url, "http://localhost").searchParams;
|
|
@@ -1770,39 +1985,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
1770
1985
|
return;
|
|
1771
1986
|
}
|
|
1772
1987
|
|
|
1773
|
-
// GET /api/tasks/:id/comments — list comments
|
|
1774
|
-
var commentsGetMatch = req.url.match(/^\/api\/tasks\/(\d+)\/comments$/);
|
|
1775
|
-
if (commentsGetMatch && req.method === "GET") {
|
|
1776
|
-
var cid = commentsGetMatch[1];
|
|
1777
|
-
var ct = readTask(cid);
|
|
1778
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1779
|
-
res.end(JSON.stringify(ct && ct.comments ? ct.comments : []));
|
|
1780
|
-
return;
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
// POST /api/tasks/:id/comments — add comment
|
|
1784
|
-
var commentsPostMatch = req.url.match(/^\/api\/tasks\/(\d+)\/comments$/);
|
|
1785
|
-
if (commentsPostMatch && req.method === "POST") {
|
|
1786
|
-
var cid2 = commentsPostMatch[1];
|
|
1787
|
-
collectBody(req, function (body) {
|
|
1788
|
-
try {
|
|
1789
|
-
var cdata = JSON.parse(body);
|
|
1790
|
-
var filePath = findTaskFile(cid2);
|
|
1791
|
-
if (!filePath) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; }
|
|
1792
|
-
var task = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
1793
|
-
if (!task.comments) task.comments = [];
|
|
1794
|
-
var comment = { author: cdata.author || "user", text: cdata.text || "", createdAt: new Date().toISOString() };
|
|
1795
|
-
task.comments.push(comment);
|
|
1796
|
-
fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
|
|
1797
|
-
broadcastRaw({ type: "tasks_updated" });
|
|
1798
|
-
logActivity({ type: "updated", taskId: cid2, subject: task.subject || "", detail: "Comment by " + comment.author });
|
|
1799
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1800
|
-
res.end(JSON.stringify({ ok: true, comment: comment }));
|
|
1801
|
-
} catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: String(e) })); }
|
|
1802
|
-
});
|
|
1803
|
-
return;
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
1988
|
// POST /api/tasks/:id/slack — Agent progress reporting via thread
|
|
1807
1989
|
const slackMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)\/slack$/);
|
|
1808
1990
|
if (slackMatch && req.method === "POST") {
|
|
@@ -1844,6 +2026,71 @@ const server = http.createServer(async (req, res) => {
|
|
|
1844
2026
|
return;
|
|
1845
2027
|
}
|
|
1846
2028
|
|
|
2029
|
+
// POST /api/archive
|
|
2030
|
+
if (req.url === "/api/archive" && req.method === "POST") {
|
|
2031
|
+
try {
|
|
2032
|
+
var archBody = await parseBody(req);
|
|
2033
|
+
var archProject = archBody.project || (readProjects()[0] || {}).id || "apex";
|
|
2034
|
+
var result = archiveTasks(archProject, archBody.taskIds || null);
|
|
2035
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2036
|
+
res.end(JSON.stringify(result));
|
|
2037
|
+
} catch (e) {
|
|
2038
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2039
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2040
|
+
}
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// GET /api/archives?project=<id>
|
|
2045
|
+
if (req.url.split("?")[0] === "/api/archives" && !req.url.match(/\/api\/archives\//) && req.method === "GET") {
|
|
2046
|
+
var archParams = new URL(req.url, "http://localhost").searchParams;
|
|
2047
|
+
var archProjId = archParams.get("project") || (readProjects()[0] || {}).id || "apex";
|
|
2048
|
+
var archProjects = readProjects();
|
|
2049
|
+
var archProj = archProjects.find(function(p) { return p.id === archProjId; });
|
|
2050
|
+
var archDirName = archProj ? (archProj.dir || archProj.id) : archProjId;
|
|
2051
|
+
var archDir = path.join(ARCHIVES_DIR, archDirName);
|
|
2052
|
+
var archList = [];
|
|
2053
|
+
if (fs.existsSync(archDir)) {
|
|
2054
|
+
try {
|
|
2055
|
+
var archFiles = fs.readdirSync(archDir).filter(function(f) { return f.endsWith(".json"); }).sort().reverse();
|
|
2056
|
+
archList = archFiles.map(function(f) {
|
|
2057
|
+
try {
|
|
2058
|
+
var data = JSON.parse(fs.readFileSync(path.join(archDir, f), "utf-8"));
|
|
2059
|
+
return { date: data.date, count: (data.tasks || []).length, agents: (data.stats || {}).agents || [] };
|
|
2060
|
+
} catch { return { date: f.replace(".json", ""), count: 0, agents: [] }; }
|
|
2061
|
+
});
|
|
2062
|
+
} catch {}
|
|
2063
|
+
}
|
|
2064
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2065
|
+
res.end(JSON.stringify(archList));
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// GET /api/archives/:date?project=<id>
|
|
2070
|
+
var archDateMatch = req.url.match(/^\/api\/archives\/([\d-]+)/);
|
|
2071
|
+
if (archDateMatch && req.method === "GET") {
|
|
2072
|
+
var archParams2 = new URL(req.url, "http://localhost").searchParams;
|
|
2073
|
+
var archProjId2 = archParams2.get("project") || (readProjects()[0] || {}).id || "apex";
|
|
2074
|
+
var archProjects2 = readProjects();
|
|
2075
|
+
var archProj2 = archProjects2.find(function(p) { return p.id === archProjId2; });
|
|
2076
|
+
var archDirName2 = archProj2 ? (archProj2.dir || archProj2.id) : archProjId2;
|
|
2077
|
+
var archFile2 = path.join(ARCHIVES_DIR, archDirName2, archDateMatch[1] + ".json");
|
|
2078
|
+
if (!fs.existsSync(archFile2)) {
|
|
2079
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2080
|
+
res.end('{"error":"Archive not found"}');
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
try {
|
|
2084
|
+
var archData = JSON.parse(fs.readFileSync(archFile2, "utf-8"));
|
|
2085
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2086
|
+
res.end(JSON.stringify(archData));
|
|
2087
|
+
} catch (e) {
|
|
2088
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2089
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2090
|
+
}
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
1847
2094
|
// DELETE /api/tasks/:id
|
|
1848
2095
|
const delMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)$/);
|
|
1849
2096
|
if (delMatch && req.method === "DELETE") {
|
|
@@ -1882,19 +2129,58 @@ const server = http.createServer(async (req, res) => {
|
|
|
1882
2129
|
}
|
|
1883
2130
|
|
|
1884
2131
|
// GET /api/agents — list all agents with prompts
|
|
1885
|
-
|
|
1886
|
-
|
|
2132
|
+
// Supports ?project=<id> to return project-specific agents
|
|
2133
|
+
if (req.url.split("?")[0] === "/api/agents" && req.method === "GET") {
|
|
2134
|
+
var qs = (req.url.split("?")[1] || "");
|
|
2135
|
+
var projectFilter = null;
|
|
2136
|
+
qs.split("&").forEach(function(kv) {
|
|
2137
|
+
var parts = kv.split("=");
|
|
2138
|
+
if (parts[0] === "project") projectFilter = decodeURIComponent(parts[1]);
|
|
2139
|
+
});
|
|
2140
|
+
|
|
1887
2141
|
var agentList = [];
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
2142
|
+
|
|
2143
|
+
// Check if the filtered project has an agentsSource (e.g. pipeline.json)
|
|
2144
|
+
var projectHasSource = false;
|
|
2145
|
+
if (projectFilter) {
|
|
2146
|
+
var prjs = readProjects();
|
|
2147
|
+
var prj = prjs.find(function(p) { return p.id === projectFilter; });
|
|
2148
|
+
if (prj && prj.agentsSource) {
|
|
2149
|
+
projectHasSource = true;
|
|
2150
|
+
try {
|
|
2151
|
+
var pipeData = JSON.parse(fs.readFileSync(prj.agentsSource, "utf8"));
|
|
2152
|
+
var pipelineArr = pipeData.pipeline || pipeData.agents || [];
|
|
2153
|
+
pipelineArr.forEach(function(agent) {
|
|
2154
|
+
agentList.push({
|
|
2155
|
+
name: agent.id || agent.name,
|
|
2156
|
+
model: agent.model || "sonnet",
|
|
2157
|
+
prompt: agent.prompt || "",
|
|
2158
|
+
role: agent.role || "",
|
|
2159
|
+
order: agent.order || 0,
|
|
2160
|
+
project: projectFilter
|
|
2161
|
+
});
|
|
2162
|
+
});
|
|
2163
|
+
// Sort by order
|
|
2164
|
+
agentList.sort(function(a, b) { return (a.order || 0) - (b.order || 0); });
|
|
2165
|
+
} catch (e) { /* pipeline.json read error */ }
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// Default: read from .claude/agents/*.md
|
|
2170
|
+
if (!projectHasSource) {
|
|
2171
|
+
var agentsDir = path.join(__dirname, "..", ".claude", "agents");
|
|
2172
|
+
try {
|
|
2173
|
+
var files = fs.readdirSync(agentsDir).filter(function (f) { return f.endsWith(".md") && !f.startsWith("_"); });
|
|
2174
|
+
files.forEach(function (f) {
|
|
2175
|
+
var name = f.replace(".md", "");
|
|
2176
|
+
var content = "";
|
|
2177
|
+
try { content = fs.readFileSync(path.join(agentsDir, f), "utf8"); } catch (e) {}
|
|
2178
|
+
var model = getModelForAgent(resolveAgentName(name) || name);
|
|
2179
|
+
agentList.push({ name: name, model: model, prompt: content });
|
|
2180
|
+
});
|
|
2181
|
+
} catch (e) {}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
1898
2184
|
// Also include project context
|
|
1899
2185
|
var projectCtx = loadProjectContext();
|
|
1900
2186
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -1995,7 +2281,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1995
2281
|
var chatBody = await parseBody(req);
|
|
1996
2282
|
var chatMessage = chatBody.message || "";
|
|
1997
2283
|
var chatModel = chatBody.model || "sonnet";
|
|
1998
|
-
var chatProject = chatBody.project || readProjects()[0].id || "
|
|
2284
|
+
var chatProject = chatBody.project || readProjects()[0].id || "apex";
|
|
1999
2285
|
var chatHistory = chatBody.history || []; // [{role,content}, ...]
|
|
2000
2286
|
|
|
2001
2287
|
var tasks = readAllTasks(chatProject);
|
|
@@ -2006,13 +2292,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
2006
2292
|
var chatArgs = ["-p", "--verbose", "--output-format", "stream-json",
|
|
2007
2293
|
"--model", chatModel, "--dangerously-skip-permissions"];
|
|
2008
2294
|
|
|
2009
|
-
// Session continuity: resume if same model, otherwise start fresh
|
|
2010
|
-
if (chatSessionId && chatSessionModel === chatModel) {
|
|
2295
|
+
// Session continuity: resume if same model AND same project, otherwise start fresh
|
|
2296
|
+
if (chatSessionId && chatSessionModel === chatModel && chatSessionProject === chatProject) {
|
|
2011
2297
|
chatArgs.push("--resume", chatSessionId);
|
|
2012
2298
|
} else {
|
|
2013
|
-
//
|
|
2299
|
+
// Project or model changed — start fresh session so new CLAUDE.md context is loaded
|
|
2014
2300
|
chatSessionId = null;
|
|
2015
2301
|
chatSessionModel = chatModel;
|
|
2302
|
+
chatSessionProject = chatProject;
|
|
2016
2303
|
}
|
|
2017
2304
|
|
|
2018
2305
|
// Build conversation history string for new sessions
|
|
@@ -2040,8 +2327,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2040
2327
|
|
|
2041
2328
|
var chatEnv = Object.assign({}, process.env);
|
|
2042
2329
|
delete chatEnv.ANTHROPIC_API_KEY; // Max 구독 OAuth 사용
|
|
2330
|
+
var chatCwd = getProjectSourceDir(chatProject) || path.join(__dirname, "..");
|
|
2043
2331
|
var chatProc = spawn("claude", chatArgs, {
|
|
2044
|
-
cwd:
|
|
2332
|
+
cwd: chatCwd,
|
|
2045
2333
|
env: chatEnv,
|
|
2046
2334
|
stdio: ["pipe", "pipe", "pipe"],
|
|
2047
2335
|
});
|
|
@@ -2286,6 +2574,7 @@ server.listen(PORT, () => {
|
|
|
2286
2574
|
console.log(" N : new task");
|
|
2287
2575
|
console.log(" C : chat panel");
|
|
2288
2576
|
console.log(" A : activity panel");
|
|
2577
|
+
console.log(" R : archive panel");
|
|
2289
2578
|
console.log(" Drag : move between columns");
|
|
2290
2579
|
console.log(" Ctrl+Shift+K : stop execution");
|
|
2291
2580
|
console.log(" Ctrl+C : quit");
|