create-claude-kanban 2.0.4 → 3.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-kanban",
3
- "version": "2.0.4",
3
+ "version": "3.1.0",
4
4
  "description": "Scaffold a multi-agent kanban system for Claude Code projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -151,6 +151,60 @@ function getThreadTs(taskId) {
151
151
  let chatSessionId = null;
152
152
  let chatSessionModel = null;
153
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);
207
+
154
208
  // ── Orchestrator Prompt System ──
155
209
  const ORCHESTRATOR_FILE = path.join(os.homedir(), ".claude", "orchestrator.md");
156
210
  const ORCHESTRATOR_LOG = path.join(os.homedir(), ".claude", "orchestrator-history.jsonl");
@@ -546,6 +600,11 @@ function readActivity(since, limit) {
546
600
  }
547
601
 
548
602
  // ── Task 파일 읽기 ──
603
+ function readTask(id) {
604
+ var all = readAllTasks();
605
+ return all.find(function(t) { return String(t.id) === String(id); }) || null;
606
+ }
607
+
549
608
  function readAllTasks(projectFilter) {
550
609
  const tasks = [];
551
610
  if (!fs.existsSync(TASKS_DIR)) return tasks;
@@ -684,6 +743,9 @@ function updateTask(id, data) {
684
743
  if (data.reportPath !== undefined) task.reportPath = data.reportPath;
685
744
  if (data.reportSummary !== undefined) task.reportSummary = data.reportSummary;
686
745
  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;
687
749
  task.updatedAt = now;
688
750
  fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
689
751
 
@@ -873,8 +935,18 @@ function buildExecutorPrompt(task) {
873
935
  if (agentPrompt) parts.push(agentPrompt);
874
936
 
875
937
  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
+
876
948
  parts.push("작업 디렉토리: " + path.join(__dirname, ".."));
877
- 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를 통해 보고.");
949
+ parts.push("## Slack Progress Reporting\n\n진행 상황을 Slack에 보고할 때 반드시 아래 API를 사용한다:\n\n```bash\ncurl -s -X POST http://localhost:" + PORT + "/api/tasks/" + task.id + "/slack \\\n -H \"Content-Type: application/json\" \\\n -d '{\"text\":\"progress message\"}'\n```\n\n`$SLACK_AGENT_WEBHOOK` 직접 사용 금지. 반드시 위 API를 통해 보고.");
878
950
 
879
951
  return parts.join("\n\n---\n\n");
880
952
  }
@@ -895,6 +967,7 @@ function spawnExecutor(task) {
895
967
  });
896
968
 
897
969
  activeExec = { process: proc, taskId: taskId, output: "" };
970
+ trackAgent(taskId, proc.pid, task.agent, task.subject);
898
971
  broadcastRaw({ type: "exec_start", taskId: taskId, subject: task.subject, agent: task.agent || "", model: model });
899
972
  logActivity({ type: "started", taskId: taskId, subject: task.subject, detail: "Agent: " + (task.agent || "none") + " | Model: " + model });
900
973
 
@@ -918,6 +991,7 @@ function spawnExecutor(task) {
918
991
  else if (json.type === "content_block_delta" && json.delta) text = json.delta.text || "";
919
992
  if (text && activeExec) {
920
993
  activeExec.output += text;
994
+ touchAgent(taskId);
921
995
  broadcastRaw({ type: "exec", taskId: taskId, chunk: text });
922
996
  }
923
997
  } catch (e) {}
@@ -931,6 +1005,7 @@ function spawnExecutor(task) {
931
1005
  proc.on("close", function (code) {
932
1006
  var output = activeExec ? activeExec.output : "";
933
1007
  activeExec = null;
1008
+ untrackAgent(taskId);
934
1009
  if (code === 0) {
935
1010
  updateTask(taskId, { status: "in_review", reportSummary: output.slice(0, 500) });
936
1011
  broadcastRaw({ type: "exec_done", taskId: taskId, exitCode: 0 });
@@ -1526,6 +1601,94 @@ const server = http.createServer(async (req, res) => {
1526
1601
  return;
1527
1602
  }
1528
1603
 
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
+
1529
1692
  // GET /api/tasks
1530
1693
  if (req.url.split("?")[0] === "/api/tasks" && req.method === "GET") {
1531
1694
  var taskParams = new URL(req.url, "http://localhost").searchParams;
@@ -1607,6 +1770,39 @@ const server = http.createServer(async (req, res) => {
1607
1770
  return;
1608
1771
  }
1609
1772
 
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
+
1610
1806
  // POST /api/tasks/:id/slack — Agent progress reporting via thread
1611
1807
  const slackMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)\/slack$/);
1612
1808
  if (slackMatch && req.method === "POST") {