kanban-system 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/.env.example +76 -0
- package/CLAUDE.md +108 -0
- package/README.md +272 -0
- package/agents/_TEMPLATE.md +42 -0
- package/agents/backend-agent.md +81 -0
- package/agents/deploy-gate-agent.md +73 -0
- package/agents/frontend-agent.md +73 -0
- package/agents/monitor-agent.md +65 -0
- package/agents/orchestrator.md +91 -0
- package/agents/reviewer-codex.md +51 -0
- package/bin/cli.js +171 -0
- package/config.example.js +99 -0
- package/docs/adapting-to-your-project.md +155 -0
- package/docs/example-apex.md +86 -0
- package/docs/the-pattern.md +92 -0
- package/hooks/launchd.plist.template +66 -0
- package/hooks/pre-push.sample +61 -0
- package/lib/config.cjs +138 -0
- package/lib/detect/_template.cjs +63 -0
- package/lib/detect/rules.json +28 -0
- package/lib/detect/sentry.cjs +86 -0
- package/lib/detect/vercel.cjs +62 -0
- package/lib/gate/index.cjs +182 -0
- package/lib/runner/adapters/both.cjs +33 -0
- package/lib/runner/adapters/claude.cjs +119 -0
- package/lib/runner/adapters/codex.cjs +43 -0
- package/lib/runner/adapters/reviewer.cjs +91 -0
- package/lib/runner/budget.cjs +75 -0
- package/lib/runner/index.cjs +93 -0
- package/lib/runner/result-merger.cjs +58 -0
- package/lib/runner/worktree-manager.cjs +64 -0
- package/lib/watch/scheduler.cjs +164 -0
- package/package.json +59 -0
- package/playbooks/_TEMPLATE.html +54 -0
- package/playbooks/build-fail.html +57 -0
- package/playbooks/deploy-rollback.html +53 -0
- package/playbooks/e2e-regression.html +58 -0
- package/playbooks/playbook.css +26 -0
- package/playbooks/sentry-spike.html +53 -0
- package/server/kanban.cjs +1152 -0
- package/skills/archive.md +18 -0
- package/skills/gate.md +22 -0
- package/skills/standup.md +24 -0
- package/skills/triage.md +24 -0
- package/ui/kanban.html +628 -0
- package/ui/styles/kanban.css +436 -0
- package/ui/styles/progress.css +315 -0
- package/ui/styles/tokens.css +291 -0
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kanban-system — real-time multi-agent kanban dashboard + REST API.
|
|
4
|
+
*
|
|
5
|
+
* ~/.claude/tasks/ → file watch → SSE → browser auto-update
|
|
6
|
+
* POST/PUT/DELETE /api/tasks → server-side CRUD
|
|
7
|
+
* /api/agents → agent registry (from agents/*.md)
|
|
8
|
+
* /events → SSE stream
|
|
9
|
+
*
|
|
10
|
+
* Config comes from <repo-root>/config.js (see config.example.js).
|
|
11
|
+
* Tasks live under ~/.claude/tasks/kanban/ (manual) and ~/.claude/tasks/<session>/.
|
|
12
|
+
*
|
|
13
|
+
* Run: npm start (or node server/kanban.cjs)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const config = require("../lib/config.cjs");
|
|
17
|
+
|
|
18
|
+
const http = require("http");
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const os = require("os");
|
|
22
|
+
const { execSync, spawn } = require("child_process");
|
|
23
|
+
|
|
24
|
+
const PORT = config.port;
|
|
25
|
+
const PROJECT_NAME = config.projectName;
|
|
26
|
+
const REPO_PATH = config.repoPath; // the application repo this harness drives
|
|
27
|
+
const HARNESS_ROOT = config.repoRoot; // this kanban-system checkout
|
|
28
|
+
const TASKS_DIR = path.join(os.homedir(), ".claude", "tasks");
|
|
29
|
+
const KANBAN_DIR = path.join(TASKS_DIR, "kanban");
|
|
30
|
+
const ACTIVITY_FILE = path.join(TASKS_DIR, "activity.jsonl");
|
|
31
|
+
|
|
32
|
+
if (!fs.existsSync(KANBAN_DIR)) fs.mkdirSync(KANBAN_DIR, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const OPS_THREAD_FILE = path.join(TASKS_DIR, "data", "ops-thread.jsonl");
|
|
35
|
+
const TELEGRAM_OFFSET_FILE = path.join(TASKS_DIR, "data", "telegram-offset.json");
|
|
36
|
+
|
|
37
|
+
const SLACK_WEBHOOK = config.slack.webhookUrl;
|
|
38
|
+
const SLACK_BOT_TOKEN = config.slack.botToken;
|
|
39
|
+
const SLACK_APP_TOKEN = config.slack.appToken;
|
|
40
|
+
const SLACK_CHANNEL_ID = config.slack.channelId;
|
|
41
|
+
const SLACK_ADMIN_USERS = config.slack.adminUsers;
|
|
42
|
+
const SLACK_COMMAND = config.slack.command;
|
|
43
|
+
let slackApp = null;
|
|
44
|
+
let slackAskActive = false;
|
|
45
|
+
|
|
46
|
+
// ── Orchestrator chat prompt (optional, drives the in-UI chat panel) ─────────
|
|
47
|
+
const ORCHESTRATOR_FILE = path.join(os.homedir(), ".claude", "orchestrator.md");
|
|
48
|
+
const ORCHESTRATOR_LOG = path.join(os.homedir(), ".claude", "orchestrator-history.jsonl");
|
|
49
|
+
|
|
50
|
+
function readOrchestratorPrompt() {
|
|
51
|
+
try { return fs.readFileSync(ORCHESTRATOR_FILE, "utf-8"); } catch { return ""; }
|
|
52
|
+
}
|
|
53
|
+
function readOrchestratorHistory(limit) {
|
|
54
|
+
if (!fs.existsSync(ORCHESTRATOR_LOG)) return "";
|
|
55
|
+
try {
|
|
56
|
+
const lines = fs.readFileSync(ORCHESTRATOR_LOG, "utf-8").trim().split("\n").filter(Boolean);
|
|
57
|
+
return lines.slice(-1 * (limit || 10)).map((line) => {
|
|
58
|
+
try { const e = JSON.parse(line); return "[" + e.ts + "] " + (e.role || "system") + ": " + e.content; }
|
|
59
|
+
catch { return ""; }
|
|
60
|
+
}).filter(Boolean).join("\n");
|
|
61
|
+
} catch { return ""; }
|
|
62
|
+
}
|
|
63
|
+
function appendOrchestratorHistory(role, content) {
|
|
64
|
+
const entry = { ts: new Date().toISOString(), role, content: content.slice(0, 500) };
|
|
65
|
+
try { fs.appendFileSync(ORCHESTRATOR_LOG, JSON.stringify(entry) + "\n"); } catch {}
|
|
66
|
+
try {
|
|
67
|
+
const lines = fs.readFileSync(ORCHESTRATOR_LOG, "utf-8").trim().split("\n");
|
|
68
|
+
if (lines.length > 600) fs.writeFileSync(ORCHESTRATOR_LOG, lines.slice(-500).join("\n") + "\n");
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
function extractAndSaveLearnings(text) {
|
|
72
|
+
const regex = /<!--\s*LEARN:\s*(.*?)\s*-->/g;
|
|
73
|
+
let match; const learnings = [];
|
|
74
|
+
while ((match = regex.exec(text)) !== null) learnings.push(match[1].trim());
|
|
75
|
+
if (!learnings.length) return;
|
|
76
|
+
try {
|
|
77
|
+
let content = fs.readFileSync(ORCHESTRATOR_FILE, "utf-8");
|
|
78
|
+
const marker = "## Learnings";
|
|
79
|
+
const idx = content.indexOf(marker);
|
|
80
|
+
if (idx >= 0) {
|
|
81
|
+
const ts = new Date().toISOString().slice(0, 10);
|
|
82
|
+
const additions = learnings.map((l) => "- [" + ts + "] " + l).join("\n");
|
|
83
|
+
let insertPos = content.indexOf("\n", idx + marker.length);
|
|
84
|
+
if (insertPos < 0) insertPos = content.length;
|
|
85
|
+
let afterMarker = content.indexOf("\n", insertPos + 1);
|
|
86
|
+
if (afterMarker < 0) afterMarker = content.length;
|
|
87
|
+
content = content.slice(0, afterMarker) + "\n" + additions + content.slice(afterMarker);
|
|
88
|
+
fs.writeFileSync(ORCHESTRATOR_FILE, content);
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildChatSystemPrompt(tasks, projectName) {
|
|
94
|
+
const orchestratorPrompt = readOrchestratorPrompt();
|
|
95
|
+
const history = readOrchestratorHistory(5);
|
|
96
|
+
const taskDetail = tasks.map((t) => {
|
|
97
|
+
let line = "#" + t.id + " [" + t.status + "] " + t.subject;
|
|
98
|
+
if (t.agent) line += " (agent:" + t.agent + ")";
|
|
99
|
+
if (t.owner) line += " (owner:" + t.owner + ")";
|
|
100
|
+
if (t.priority === "high") line += " [HIGH]";
|
|
101
|
+
if (t.blockedBy && t.blockedBy.length) line += " blocked-by:" + t.blockedBy.join(",");
|
|
102
|
+
if (t.activeForm) line += " — " + t.activeForm;
|
|
103
|
+
return line;
|
|
104
|
+
}).join("\n");
|
|
105
|
+
|
|
106
|
+
let prompt = "";
|
|
107
|
+
prompt += orchestratorPrompt ? orchestratorPrompt + "\n\n"
|
|
108
|
+
: "You are the Orchestrator for the " + projectName + " kanban board.\n\n";
|
|
109
|
+
prompt += "## Current Board State\n";
|
|
110
|
+
prompt += "Project: " + projectName + "\n";
|
|
111
|
+
prompt += "Application repo: " + REPO_PATH + "\n\n";
|
|
112
|
+
prompt += taskDetail + "\n\n";
|
|
113
|
+
if (history) prompt += "## Recent Orchestrator Decisions\n" + history + "\n\n";
|
|
114
|
+
return prompt;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function boardSummary(tasks) {
|
|
118
|
+
const pending = tasks.filter((t) => t.status === "pending");
|
|
119
|
+
const inProgress = tasks.filter((t) => t.status === "in_progress");
|
|
120
|
+
const completed = tasks.filter((t) => t.status === "completed");
|
|
121
|
+
const total = tasks.length;
|
|
122
|
+
const pct = total ? Math.round((completed.length / total) * 100) : 0;
|
|
123
|
+
let summary = `Board: ${completed.length} done / ${inProgress.length} in progress / ${pending.length} pending (${pct}%)`;
|
|
124
|
+
if (inProgress.length > 0) {
|
|
125
|
+
summary += "\nIn progress: " + inProgress.map((t) => `#${t.id} ${t.subject}${t.activeForm ? " — " + t.activeForm : ""}`).join(", ");
|
|
126
|
+
}
|
|
127
|
+
if (pending.length > 0 && pending.length <= 5) {
|
|
128
|
+
summary += "\nUp next: " + pending.map((t) => `#${t.id} ${t.subject}`).join(", ");
|
|
129
|
+
} else if (pending.length > 5) {
|
|
130
|
+
summary += "\nUp next: " + pending.slice(0, 3).map((t) => `#${t.id} ${t.subject}`).join(", ") + ` +${pending.length - 3} more`;
|
|
131
|
+
}
|
|
132
|
+
return summary;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function slackNotify(text) {
|
|
136
|
+
if (slackApp && SLACK_CHANNEL_ID) {
|
|
137
|
+
slackApp.client.chat.postMessage({ channel: SLACK_CHANNEL_ID, text }).catch(() => {});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!SLACK_WEBHOOK) return;
|
|
141
|
+
const payload = JSON.stringify({ text });
|
|
142
|
+
const url = new URL(SLACK_WEBHOOK);
|
|
143
|
+
const req = require("https").request({
|
|
144
|
+
hostname: url.hostname, path: url.pathname + url.search, method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
|
146
|
+
});
|
|
147
|
+
req.on("error", () => {});
|
|
148
|
+
req.write(payload); req.end();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Activity log ─────────────────────────────────────────────────────────────
|
|
152
|
+
function logActivity(evt) {
|
|
153
|
+
const ts = new Date().toISOString();
|
|
154
|
+
const record = { ts, ...evt };
|
|
155
|
+
try { fs.appendFileSync(ACTIVITY_FILE, JSON.stringify(record) + "\n"); }
|
|
156
|
+
catch {
|
|
157
|
+
try { fs.mkdirSync(path.dirname(ACTIVITY_FILE), { recursive: true }); } catch {}
|
|
158
|
+
try { fs.appendFileSync(ACTIVITY_FILE, JSON.stringify(record) + "\n"); } catch {}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const lines = fs.readFileSync(ACTIVITY_FILE, "utf-8").trim().split("\n");
|
|
162
|
+
if (lines.length > 1200) fs.writeFileSync(ACTIVITY_FILE, lines.slice(lines.length - 1000).join("\n") + "\n");
|
|
163
|
+
} catch {}
|
|
164
|
+
const msg = "data: " + JSON.stringify({ type: "activity", event: record }) + "\n\n";
|
|
165
|
+
for (const res of sseClients) { try { res.write(msg); } catch { sseClients.delete(res); } }
|
|
166
|
+
|
|
167
|
+
const summary = boardSummary(readAllTasks());
|
|
168
|
+
let slackMsg = "";
|
|
169
|
+
if (evt.type === "created") {
|
|
170
|
+
slackMsg = `New Task #${evt.taskId}: ${evt.subject}`;
|
|
171
|
+
if (evt.description) slackMsg += "\n> " + evt.description.split("\n")[0].slice(0, 120);
|
|
172
|
+
if (evt.priority === "high") slackMsg += "\nPriority: HIGH";
|
|
173
|
+
if (evt.owner) slackMsg += "\nAssigned: " + evt.owner;
|
|
174
|
+
if (evt.parentId) slackMsg += "\nSubtask of #" + evt.parentId;
|
|
175
|
+
} else if (evt.type === "started") {
|
|
176
|
+
slackMsg = `Task #${evt.taskId} Started: ${evt.subject}`;
|
|
177
|
+
if (evt.owner) slackMsg += "\n" + evt.owner;
|
|
178
|
+
if (evt.activeForm) slackMsg += "\n" + evt.activeForm;
|
|
179
|
+
} else if (evt.type === "completed") {
|
|
180
|
+
slackMsg = `Task #${evt.taskId} Done: ${evt.subject}`;
|
|
181
|
+
if (evt.reportSummary) slackMsg += "\n> " + evt.reportSummary.split("\n")[0].slice(0, 200);
|
|
182
|
+
if (evt.reportPath) slackMsg += "\nReport: " + evt.reportPath;
|
|
183
|
+
} else if (evt.type === "deleted") {
|
|
184
|
+
slackMsg = `Task #${evt.taskId} Deleted: ${evt.subject}`;
|
|
185
|
+
} else if (evt.type === "updated") {
|
|
186
|
+
slackMsg = `Task #${evt.taskId} Updated: ${evt.subject}`;
|
|
187
|
+
if (evt.detail) slackMsg += "\n> " + evt.detail;
|
|
188
|
+
}
|
|
189
|
+
if (slackMsg) slackNotify(slackMsg + "\n" + summary);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readActivity(since, limit) {
|
|
193
|
+
if (!fs.existsSync(ACTIVITY_FILE)) return [];
|
|
194
|
+
try {
|
|
195
|
+
const lines = fs.readFileSync(ACTIVITY_FILE, "utf-8").trim().split("\n").filter(Boolean);
|
|
196
|
+
let events = lines.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
197
|
+
if (since) events = events.filter((e) => e.ts > since);
|
|
198
|
+
events.reverse();
|
|
199
|
+
if (limit) events = events.slice(0, limit);
|
|
200
|
+
return events;
|
|
201
|
+
} catch { return []; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Task files ───────────────────────────────────────────────────────────────
|
|
205
|
+
function readAllTasks() {
|
|
206
|
+
const tasks = [];
|
|
207
|
+
if (!fs.existsSync(TASKS_DIR)) return tasks;
|
|
208
|
+
try {
|
|
209
|
+
for (const session of fs.readdirSync(TASKS_DIR, { withFileTypes: true })) {
|
|
210
|
+
if (!session.isDirectory()) continue;
|
|
211
|
+
const sessionPath = path.join(TASKS_DIR, session.name);
|
|
212
|
+
const files = fs.readdirSync(sessionPath).filter((f) => f.endsWith(".json") && f !== ".lock");
|
|
213
|
+
for (const file of files) {
|
|
214
|
+
try {
|
|
215
|
+
const filePath = path.join(sessionPath, file);
|
|
216
|
+
const task = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
217
|
+
task._session = session.name;
|
|
218
|
+
task._file = file;
|
|
219
|
+
task._mtime = fs.statSync(filePath).mtimeMs;
|
|
220
|
+
task._editable = session.name === "kanban";
|
|
221
|
+
tasks.push(task);
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {}
|
|
226
|
+
return tasks.sort((a, b) => (a.id || 0) - (b.id || 0));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getNextId() {
|
|
230
|
+
const files = fs.existsSync(KANBAN_DIR) ? fs.readdirSync(KANBAN_DIR).filter((f) => f.endsWith(".json")) : [];
|
|
231
|
+
let max = 0;
|
|
232
|
+
for (const f of files) { const n = parseInt(f.replace(".json", ""), 10); if (n > max) max = n; }
|
|
233
|
+
return max + 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createTask(data) {
|
|
237
|
+
const id = getNextId();
|
|
238
|
+
const now = new Date().toISOString();
|
|
239
|
+
const task = {
|
|
240
|
+
id: String(id),
|
|
241
|
+
subject: data.subject || data.title || "Untitled",
|
|
242
|
+
description: data.description || "",
|
|
243
|
+
status: data.status || "pending",
|
|
244
|
+
priority: data.priority || "medium",
|
|
245
|
+
agent: data.agent || "",
|
|
246
|
+
owner: data.owner || "",
|
|
247
|
+
activeForm: data.activeForm || "",
|
|
248
|
+
blockedBy: data.blockedBy || [],
|
|
249
|
+
blocks: data.blocks || [],
|
|
250
|
+
parentId: data.parentId || null,
|
|
251
|
+
reportPath: data.reportPath || null,
|
|
252
|
+
reportSummary: data.reportSummary || null,
|
|
253
|
+
metadata: data.metadata || {},
|
|
254
|
+
createdAt: now,
|
|
255
|
+
updatedAt: now,
|
|
256
|
+
};
|
|
257
|
+
fs.writeFileSync(path.join(KANBAN_DIR, id + ".json"), JSON.stringify(task, null, 2));
|
|
258
|
+
taskSnapshot.set(String(id), { status: task.status, subject: task.subject, owner: task.owner || "", reportSummary: "" });
|
|
259
|
+
logActivity({
|
|
260
|
+
type: "created", taskId: String(id), subject: task.subject,
|
|
261
|
+
agent: task.agent || task.owner || "", detail: task.priority === "high" ? "Priority: high" : "",
|
|
262
|
+
description: task.description, priority: task.priority, owner: task.owner, parentId: task.parentId,
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
const line = "📋 #" + id + " " + (task.subject || "");
|
|
266
|
+
opsAppend("system", line, String(id));
|
|
267
|
+
telegramSend(line);
|
|
268
|
+
} catch {}
|
|
269
|
+
return task;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function findTaskFile(id) {
|
|
273
|
+
const kanbanPath = path.join(KANBAN_DIR, id + ".json");
|
|
274
|
+
if (fs.existsSync(kanbanPath)) return kanbanPath;
|
|
275
|
+
try {
|
|
276
|
+
for (const session of fs.readdirSync(TASKS_DIR, { withFileTypes: true })) {
|
|
277
|
+
if (!session.isDirectory()) continue;
|
|
278
|
+
const fp = path.join(TASKS_DIR, session.name, id + ".json");
|
|
279
|
+
if (fs.existsSync(fp)) return fp;
|
|
280
|
+
}
|
|
281
|
+
} catch {}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function updateTask(id, data) {
|
|
286
|
+
const filePath = findTaskFile(id);
|
|
287
|
+
if (!filePath) return null;
|
|
288
|
+
const task = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
289
|
+
const now = new Date().toISOString();
|
|
290
|
+
const prevStatus = task.status;
|
|
291
|
+
let statusChanged = false;
|
|
292
|
+
if (data.status !== undefined && data.status !== task.status) {
|
|
293
|
+
statusChanged = true;
|
|
294
|
+
task.status = data.status;
|
|
295
|
+
if (data.status === "in_progress" && !task.startedAt) task.startedAt = now;
|
|
296
|
+
if (data.status === "completed") task.completedAt = now;
|
|
297
|
+
}
|
|
298
|
+
if (data.subject !== undefined) task.subject = data.subject;
|
|
299
|
+
if (data.description !== undefined) task.description = data.description;
|
|
300
|
+
if (data.priority !== undefined) task.priority = data.priority;
|
|
301
|
+
if (data.agent !== undefined) task.agent = data.agent;
|
|
302
|
+
if (data.owner !== undefined) task.owner = data.owner;
|
|
303
|
+
if (data.activeForm !== undefined) task.activeForm = data.activeForm;
|
|
304
|
+
if (data.blockedBy !== undefined) task.blockedBy = data.blockedBy;
|
|
305
|
+
if (data.blocks !== undefined) task.blocks = data.blocks;
|
|
306
|
+
if (data.reportPath !== undefined) task.reportPath = data.reportPath;
|
|
307
|
+
if (data.reportSummary !== undefined) task.reportSummary = data.reportSummary;
|
|
308
|
+
if (data.parentId !== undefined) task.parentId = data.parentId;
|
|
309
|
+
if (data.metadata !== undefined) task.metadata = Object.assign({}, task.metadata || {}, data.metadata);
|
|
310
|
+
task.updatedAt = now;
|
|
311
|
+
fs.writeFileSync(filePath, JSON.stringify(task, null, 2));
|
|
312
|
+
taskSnapshot.set(String(id), { status: task.status, subject: task.subject, owner: task.owner || "", reportSummary: task.reportSummary || "" });
|
|
313
|
+
|
|
314
|
+
if (statusChanged) {
|
|
315
|
+
if (data.status === "in_progress" && prevStatus === "pending") {
|
|
316
|
+
logActivity({ type: "started", taskId: String(id), subject: task.subject, agent: task.agent || task.owner || "", detail: task.activeForm || "", owner: task.owner, activeForm: task.activeForm, description: task.description });
|
|
317
|
+
try { const line = "▶️ #" + id + " " + (task.subject || ""); opsAppend("system", line, String(id)); telegramSend(line); } catch {}
|
|
318
|
+
} else if (data.status === "completed") {
|
|
319
|
+
logActivity({ type: "completed", taskId: String(id), subject: task.subject, agent: task.agent || task.owner || "", detail: task.reportSummary || "", reportSummary: task.reportSummary, reportPath: task.reportPath, parentId: task.parentId });
|
|
320
|
+
try {
|
|
321
|
+
const head = (task.reportSummary || "").split("\n")[0].slice(0, 240);
|
|
322
|
+
const line = "✅ #" + id + " 완료" + (head ? " — " + head : "");
|
|
323
|
+
opsAppend("system", line, String(id));
|
|
324
|
+
telegramSend(line);
|
|
325
|
+
} catch {}
|
|
326
|
+
} else {
|
|
327
|
+
logActivity({ type: "updated", taskId: String(id), subject: task.subject, agent: task.agent || task.owner || "", detail: prevStatus + " → " + data.status });
|
|
328
|
+
}
|
|
329
|
+
} else if (data.subject !== undefined || data.description !== undefined || data.owner !== undefined) {
|
|
330
|
+
logActivity({ type: "updated", taskId: String(id), subject: task.subject, agent: task.agent || task.owner || "", detail: "Fields updated" });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Auto-execute: pending → in_progress with a description, when the `claude`
|
|
334
|
+
// CLI is on PATH and nothing else is running.
|
|
335
|
+
if (statusChanged && data.status === "in_progress" && prevStatus === "pending" && task.description && cliAvailable && !activeExec) {
|
|
336
|
+
setTimeout(() => spawnExecutor(task), 100);
|
|
337
|
+
}
|
|
338
|
+
return task;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function deleteTask(id) {
|
|
342
|
+
const filePath = findTaskFile(id);
|
|
343
|
+
if (!filePath) return false;
|
|
344
|
+
let taskData = {};
|
|
345
|
+
try { taskData = JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch {}
|
|
346
|
+
fs.unlinkSync(filePath);
|
|
347
|
+
taskSnapshot.delete(String(id));
|
|
348
|
+
logActivity({ type: "deleted", taskId: String(id), subject: taskData.subject || "Unknown", agent: taskData.agent || taskData.owner || "", detail: "" });
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Agent registry — parse frontmatter of agents/*.md ────────────────────────
|
|
353
|
+
const AGENTS_DIR = path.join(HARNESS_ROOT, "agents");
|
|
354
|
+
|
|
355
|
+
function parseFrontmatter(md) {
|
|
356
|
+
const m = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
357
|
+
if (!m) return { meta: {}, body: md };
|
|
358
|
+
const meta = {};
|
|
359
|
+
const lines = m[1].split("\n");
|
|
360
|
+
for (let i = 0; i < lines.length; i++) {
|
|
361
|
+
const line = lines[i];
|
|
362
|
+
if (!line.trim() || line.startsWith("#") || /^\s/.test(line)) continue; // skip blanks/comments/indented continuations
|
|
363
|
+
const kv = line.match(/^([\w.-]+):\s*(.*)$/);
|
|
364
|
+
if (!kv) continue;
|
|
365
|
+
const key = kv[1];
|
|
366
|
+
let val = kv[2].trim();
|
|
367
|
+
// YAML block scalar: `key: >-` or `key: |` — gather indented lines that follow.
|
|
368
|
+
if (val === ">-" || val === ">" || val === "|" || val === "|-") {
|
|
369
|
+
const fold = val.startsWith(">");
|
|
370
|
+
const parts = [];
|
|
371
|
+
while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === "")) {
|
|
372
|
+
i++;
|
|
373
|
+
parts.push(lines[i].replace(/^\s+/, ""));
|
|
374
|
+
}
|
|
375
|
+
val = (fold ? parts.join(" ") : parts.join("\n")).trim();
|
|
376
|
+
} else if (val === "") {
|
|
377
|
+
// YAML block list: key:\n # comment\n - a\n - b
|
|
378
|
+
const items = [];
|
|
379
|
+
let j = i + 1;
|
|
380
|
+
while (j < lines.length) {
|
|
381
|
+
const next = lines[j];
|
|
382
|
+
if (/^\s*#/.test(next) || next.trim() === "") { j++; continue; } // skip indented comments / blanks
|
|
383
|
+
if (/^\s*-\s/.test(next)) { items.push(next.replace(/^\s*-\s+/, "").trim()); j++; continue; }
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
if (items.length) { val = items; i = j - 1; }
|
|
387
|
+
} else if (val.startsWith("[") && val.endsWith("]")) {
|
|
388
|
+
val = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
389
|
+
} else {
|
|
390
|
+
val = val.replace(/^["']|["']$/g, "");
|
|
391
|
+
}
|
|
392
|
+
meta[key] = val;
|
|
393
|
+
}
|
|
394
|
+
return { meta, body: m[2] };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function listAgents() {
|
|
398
|
+
if (!fs.existsSync(AGENTS_DIR)) return [];
|
|
399
|
+
return fs.readdirSync(AGENTS_DIR)
|
|
400
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
|
401
|
+
.map((f) => {
|
|
402
|
+
try {
|
|
403
|
+
const { meta } = parseFrontmatter(fs.readFileSync(path.join(AGENTS_DIR, f), "utf-8"));
|
|
404
|
+
return {
|
|
405
|
+
name: meta.name || f.replace(/\.md$/, ""),
|
|
406
|
+
file: f,
|
|
407
|
+
mission: meta.mission || "",
|
|
408
|
+
runner: meta.runner || "claude",
|
|
409
|
+
model: meta.model_default || "",
|
|
410
|
+
owns: meta.owns || [],
|
|
411
|
+
escalation: meta.escalation || "",
|
|
412
|
+
};
|
|
413
|
+
} catch { return null; }
|
|
414
|
+
})
|
|
415
|
+
.filter(Boolean);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function getAgentFull(name) {
|
|
419
|
+
if (!fs.existsSync(AGENTS_DIR)) return null;
|
|
420
|
+
for (const f of fs.readdirSync(AGENTS_DIR)) {
|
|
421
|
+
if (!f.endsWith(".md")) continue;
|
|
422
|
+
const raw = fs.readFileSync(path.join(AGENTS_DIR, f), "utf-8");
|
|
423
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
424
|
+
if ((meta.name || f.replace(/\.md$/, "")) === name) return { name, file: f, meta, body, raw };
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── SSE ──────────────────────────────────────────────────────────────────────
|
|
430
|
+
const sseClients = new Set();
|
|
431
|
+
let lastHash = "";
|
|
432
|
+
function broadcast(data) {
|
|
433
|
+
const hash = JSON.stringify(data.tasks?.map((t) => t.status + t.id + (t.updatedAt || "")));
|
|
434
|
+
if (hash === lastHash) return;
|
|
435
|
+
lastHash = hash;
|
|
436
|
+
const msg = "data: " + JSON.stringify(data) + "\n\n";
|
|
437
|
+
for (const res of sseClients) { try { res.write(msg); } catch { sseClients.delete(res); } }
|
|
438
|
+
}
|
|
439
|
+
function broadcastRaw(data) {
|
|
440
|
+
const msg = "data: " + JSON.stringify(data) + "\n\n";
|
|
441
|
+
for (const res of sseClients) { try { res.write(msg); } catch { sseClients.delete(res); } }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Ops Thread (Telegram mirror) ─────────────────────────────────────────────
|
|
445
|
+
// Append-only chat log between the operator (you) and the agents, persisted as
|
|
446
|
+
// JSONL and broadcast over SSE. Optionally mirrored to Telegram: outbound via
|
|
447
|
+
// sendMessage, inbound via getUpdates long-poll. Both halves are best-effort —
|
|
448
|
+
// missing token / chatId just disables that half; the panel still works locally.
|
|
449
|
+
const TELEGRAM = config.telegram || {};
|
|
450
|
+
let telegramOffset = 0;
|
|
451
|
+
let telegramPollerHandle = null;
|
|
452
|
+
|
|
453
|
+
function opsAppend(role, text, taskId, opts) {
|
|
454
|
+
const msg = {
|
|
455
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
456
|
+
ts: new Date().toISOString(),
|
|
457
|
+
role: role || "system",
|
|
458
|
+
text: String(text == null ? "" : text),
|
|
459
|
+
taskId: taskId != null ? String(taskId) : null,
|
|
460
|
+
source: (opts && opts.source) || "kanban",
|
|
461
|
+
};
|
|
462
|
+
try {
|
|
463
|
+
fs.mkdirSync(path.dirname(OPS_THREAD_FILE), { recursive: true });
|
|
464
|
+
fs.appendFileSync(OPS_THREAD_FILE, JSON.stringify(msg) + "\n");
|
|
465
|
+
} catch (e) {
|
|
466
|
+
console.error("[ops-thread] append failed:", e && e.code ? e.code : e);
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const lines = fs.readFileSync(OPS_THREAD_FILE, "utf-8").trim().split("\n");
|
|
470
|
+
if (lines.length > 2400) fs.writeFileSync(OPS_THREAD_FILE, lines.slice(-2000).join("\n") + "\n");
|
|
471
|
+
} catch {}
|
|
472
|
+
broadcastRaw({ type: "ops.message", message: msg });
|
|
473
|
+
return msg;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function readOpsThread(since) {
|
|
477
|
+
if (!fs.existsSync(OPS_THREAD_FILE)) return [];
|
|
478
|
+
try {
|
|
479
|
+
let msgs = fs.readFileSync(OPS_THREAD_FILE, "utf-8").trim().split("\n").filter(Boolean)
|
|
480
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
481
|
+
if (since) {
|
|
482
|
+
let idx = -1;
|
|
483
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
484
|
+
if (msgs[i].id === since || (msgs[i].ts && msgs[i].ts > since)) { idx = i; break; }
|
|
485
|
+
}
|
|
486
|
+
if (idx < 0) msgs = [];
|
|
487
|
+
else if (msgs[idx].id === since) msgs = msgs.slice(idx + 1);
|
|
488
|
+
else msgs = msgs.slice(idx);
|
|
489
|
+
}
|
|
490
|
+
if (msgs.length > 500) msgs = msgs.slice(-500);
|
|
491
|
+
return msgs;
|
|
492
|
+
} catch { return []; }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function telegramHttp(method, params) {
|
|
496
|
+
return new Promise((resolve) => {
|
|
497
|
+
if (!TELEGRAM.botToken) return resolve({ ok: false, error: "no token" });
|
|
498
|
+
const body = JSON.stringify(params || {});
|
|
499
|
+
const req = require("https").request({
|
|
500
|
+
hostname: "api.telegram.org",
|
|
501
|
+
path: `/bot${TELEGRAM.botToken}/${method}`,
|
|
502
|
+
method: "POST",
|
|
503
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
504
|
+
timeout: 30000,
|
|
505
|
+
}, (res) => {
|
|
506
|
+
let data = "";
|
|
507
|
+
res.on("data", (c) => data += c);
|
|
508
|
+
res.on("end", () => { try { resolve(JSON.parse(data)); } catch { resolve({ ok: false, error: "parse" }); } });
|
|
509
|
+
});
|
|
510
|
+
req.on("error", (e) => resolve({ ok: false, error: String(e && e.code || e.message || e) }));
|
|
511
|
+
req.on("timeout", () => { try { req.destroy(); } catch {} resolve({ ok: false, error: "timeout" }); });
|
|
512
|
+
req.write(body); req.end();
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function telegramSend(text) {
|
|
517
|
+
if (!TELEGRAM.botToken || !TELEGRAM.chatId) return;
|
|
518
|
+
telegramHttp("sendMessage", { chat_id: TELEGRAM.chatId, text: String(text == null ? "" : text), disable_web_page_preview: true })
|
|
519
|
+
.then((r) => { if (!r.ok) console.error("[telegram] send failed:", r.error || r.description); });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function loadTelegramOffset() {
|
|
523
|
+
try { telegramOffset = JSON.parse(fs.readFileSync(TELEGRAM_OFFSET_FILE, "utf-8")).offset || 0; } catch { telegramOffset = 0; }
|
|
524
|
+
}
|
|
525
|
+
function saveTelegramOffset() {
|
|
526
|
+
try { fs.mkdirSync(path.dirname(TELEGRAM_OFFSET_FILE), { recursive: true }); fs.writeFileSync(TELEGRAM_OFFSET_FILE, JSON.stringify({ offset: telegramOffset })); } catch {}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function telegramChatAllowed(chatId) {
|
|
530
|
+
const id = String(chatId);
|
|
531
|
+
if (TELEGRAM.allowedChatIds && TELEGRAM.allowedChatIds.length) return TELEGRAM.allowedChatIds.map(String).includes(id);
|
|
532
|
+
if (TELEGRAM.chatId) return String(TELEGRAM.chatId) === id;
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function telegramPollOnce() {
|
|
537
|
+
const r = await telegramHttp("getUpdates", { offset: telegramOffset + 1, timeout: 25, allowed_updates: ["message"] });
|
|
538
|
+
if (!r || !r.ok || !Array.isArray(r.result)) return { updates: 0, error: r && r.error };
|
|
539
|
+
let updates = 0;
|
|
540
|
+
for (const u of r.result) {
|
|
541
|
+
if (u.update_id > telegramOffset) telegramOffset = u.update_id;
|
|
542
|
+
const m = u.message;
|
|
543
|
+
if (!m || !m.text) continue;
|
|
544
|
+
if (!telegramChatAllowed(m.chat && m.chat.id)) continue;
|
|
545
|
+
opsAppend("operator", m.text, null, { source: "telegram" });
|
|
546
|
+
updates++;
|
|
547
|
+
}
|
|
548
|
+
if (updates) saveTelegramOffset();
|
|
549
|
+
return { updates };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function startTelegramPoller() {
|
|
553
|
+
if (telegramPollerHandle) return;
|
|
554
|
+
if (!TELEGRAM.botToken || !TELEGRAM.chatId || !TELEGRAM.pollEnabled) return;
|
|
555
|
+
loadTelegramOffset();
|
|
556
|
+
const tick = async () => {
|
|
557
|
+
try { await telegramPollOnce(); }
|
|
558
|
+
catch (e) { console.error("[telegram] poll error:", e && e.message); await new Promise((r) => setTimeout(r, 5000)); }
|
|
559
|
+
telegramPollerHandle = setTimeout(tick, TELEGRAM.pollIntervalMs || 1500);
|
|
560
|
+
};
|
|
561
|
+
telegramPollerHandle = setTimeout(tick, 100);
|
|
562
|
+
console.log(" Telegram: poller started (chat=" + TELEGRAM.chatId + ")");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── Claude CLI auto-executor (optional) ──────────────────────────────────────
|
|
566
|
+
let cliAvailable = false;
|
|
567
|
+
function checkClaudeCLI() {
|
|
568
|
+
if (process.env.CLAUDECODE) { console.log(" CLI: unavailable (nested session)"); return; }
|
|
569
|
+
try { execSync("which claude", { encoding: "utf-8", timeout: 5000 }); cliAvailable = true; }
|
|
570
|
+
catch { cliAvailable = false; }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let activeExec = null; // { process, taskId, output }
|
|
574
|
+
|
|
575
|
+
// Hard rules every auto-executed task must follow. Prepended to the prompt so
|
|
576
|
+
// the spawned CLI inherits them without an external CLAUDE.md hop. Keep this
|
|
577
|
+
// short — it costs every auto-exec turn.
|
|
578
|
+
const EXECUTOR_GUARDRAILS = [
|
|
579
|
+
"## Hard rules (must follow)",
|
|
580
|
+
"- NEVER include `Co-Authored-By` lines or any Claude/Anthropic/Codex attribution in git commit messages, PR descriptions, or any committed text. This applies to every commit you create, including amends.",
|
|
581
|
+
"- NEVER run destructive git operations (force-push, reset --hard, branch -D, push --no-verify) without an explicit user request.",
|
|
582
|
+
"- NEVER commit `.env`, `config.js`, or any secret. Both are gitignored — keep it that way.",
|
|
583
|
+
"- Stay inside the agent's `owns` globs (selvedge boundaries).",
|
|
584
|
+
"- When you finish, set the task to `in_review` with a `reportSummary` and (if a code change was made) a `reportPath` pointing at the commit/PR — do not self-mark `completed`.",
|
|
585
|
+
"",
|
|
586
|
+
].join("\n");
|
|
587
|
+
|
|
588
|
+
function spawnExecutor(task) {
|
|
589
|
+
if (!cliAvailable || activeExec) return;
|
|
590
|
+
const taskId = String(task.id);
|
|
591
|
+
const prompt = EXECUTOR_GUARDRAILS + "## Task\n" + task.subject + "\n\n" + (task.description || "(no description)") + "\n\nWorking directory: " + REPO_PATH;
|
|
592
|
+
const execEnv = Object.assign({}, process.env);
|
|
593
|
+
delete execEnv.ANTHROPIC_API_KEY;
|
|
594
|
+
const proc = spawn("claude", ["-p", "--verbose", "--output-format", "stream-json", "--model", "sonnet", "--no-session-persistence"], {
|
|
595
|
+
cwd: REPO_PATH, env: execEnv, stdio: ["pipe", "pipe", "pipe"],
|
|
596
|
+
});
|
|
597
|
+
activeExec = { process: proc, taskId, output: "" };
|
|
598
|
+
broadcastRaw({ type: "exec_start", taskId, subject: task.subject });
|
|
599
|
+
logActivity({ type: "started", taskId, subject: task.subject, detail: "Auto-execute started" });
|
|
600
|
+
proc.stdin.write(prompt); proc.stdin.end();
|
|
601
|
+
|
|
602
|
+
let buffer = "";
|
|
603
|
+
proc.stdout.on("data", (data) => {
|
|
604
|
+
buffer += data.toString();
|
|
605
|
+
const lines = buffer.split("\n");
|
|
606
|
+
buffer = lines.pop();
|
|
607
|
+
for (const line of lines) {
|
|
608
|
+
if (!line.trim()) continue;
|
|
609
|
+
try {
|
|
610
|
+
const json = JSON.parse(line);
|
|
611
|
+
let text = "";
|
|
612
|
+
if (json.type === "assistant" && json.subtype === "text") text = json.text || "";
|
|
613
|
+
else if (json.type === "assistant" && json.message && json.message.content) {
|
|
614
|
+
json.message.content.forEach((c) => { if (c.type === "text") text += c.text; });
|
|
615
|
+
} else if (json.type === "content_block_delta" && json.delta) text = json.delta.text || "";
|
|
616
|
+
if (text && activeExec) { activeExec.output += text; broadcastRaw({ type: "exec", taskId, chunk: text }); }
|
|
617
|
+
} catch {}
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
proc.stderr.on("data", (data) => broadcastRaw({ type: "exec_error", taskId, chunk: data.toString() }));
|
|
621
|
+
proc.on("close", (code) => {
|
|
622
|
+
const output = activeExec ? activeExec.output : "";
|
|
623
|
+
activeExec = null;
|
|
624
|
+
if (code === 0) {
|
|
625
|
+
updateTask(taskId, { status: "in_review", reportSummary: output.slice(0, 500) });
|
|
626
|
+
broadcastRaw({ type: "exec_done", taskId, exitCode: 0 });
|
|
627
|
+
} else {
|
|
628
|
+
updateTask(taskId, { status: "pending" });
|
|
629
|
+
broadcastRaw({ type: "exec_done", taskId, exitCode: code });
|
|
630
|
+
logActivity({ type: "updated", taskId, subject: task.subject || "", detail: "Exec failed (exit " + code + ")" });
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
function stopExec() {
|
|
635
|
+
if (!activeExec) return null;
|
|
636
|
+
const taskId = activeExec.taskId;
|
|
637
|
+
try { activeExec.process.kill(); } catch {}
|
|
638
|
+
activeExec = null;
|
|
639
|
+
updateTask(taskId, { status: "pending" });
|
|
640
|
+
broadcastRaw({ type: "exec_done", taskId, exitCode: -1, stopped: true });
|
|
641
|
+
logActivity({ type: "updated", taskId, subject: "", detail: "Execution stopped by user" });
|
|
642
|
+
return taskId;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Slack bot (Socket Mode) — optional ───────────────────────────────────────
|
|
646
|
+
async function initSlackBot() {
|
|
647
|
+
if (!SLACK_BOT_TOKEN || !SLACK_APP_TOKEN) { console.log(" Slack Bot: disabled (no tokens)"); return; }
|
|
648
|
+
try {
|
|
649
|
+
const { App } = require("@slack/bolt");
|
|
650
|
+
slackApp = new App({ token: SLACK_BOT_TOKEN, appToken: SLACK_APP_TOKEN, socketMode: true });
|
|
651
|
+
|
|
652
|
+
slackApp.command(SLACK_COMMAND, async ({ command, ack, respond, client }) => {
|
|
653
|
+
await ack();
|
|
654
|
+
const parts = (command.text || "").trim().split(/\s+/);
|
|
655
|
+
const sub = (parts[0] || "").toLowerCase();
|
|
656
|
+
const restText = parts.slice(1).join(" ");
|
|
657
|
+
switch (sub) {
|
|
658
|
+
case "board":
|
|
659
|
+
case "": {
|
|
660
|
+
await respond({ blocks: buildBoardBlocks(readAllTasks()), text: PROJECT_NAME + " kanban", response_type: "ephemeral" });
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
case "list": {
|
|
664
|
+
await respond({ blocks: buildTaskListBlocks(readAllTasks()), response_type: "ephemeral" });
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
case "add": {
|
|
668
|
+
if (!restText) await client.views.open({ trigger_id: command.trigger_id, view: buildAddTaskModal() });
|
|
669
|
+
else { const task = createTask({ subject: restText }); await respond({ text: `Task #${task.id} created: ${task.subject}`, response_type: "in_channel" }); }
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case "ask": {
|
|
673
|
+
if (!restText) { await respond({ text: "Usage: `" + SLACK_COMMAND + " ask <question>`", response_type: "ephemeral" }); break; }
|
|
674
|
+
if (slackAskActive) { await respond({ text: "Another ask is in progress. Please wait.", response_type: "ephemeral" }); break; }
|
|
675
|
+
if (!cliAvailable) { await respond({ text: "Claude CLI not available.", response_type: "ephemeral" }); break; }
|
|
676
|
+
await respond({ text: `Asking Claude: ${restText}`, response_type: "ephemeral" });
|
|
677
|
+
handleSlackAsk(restText, command.channel_id, client);
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
case "exec": {
|
|
681
|
+
if (SLACK_ADMIN_USERS.length > 0 && !SLACK_ADMIN_USERS.includes(command.user_id)) { await respond({ text: "Permission denied.", response_type: "ephemeral" }); break; }
|
|
682
|
+
const execId = restText.replace("#", "");
|
|
683
|
+
if (!execId) { await respond({ text: "Usage: `" + SLACK_COMMAND + " exec <task_id>`", response_type: "ephemeral" }); break; }
|
|
684
|
+
if (!cliAvailable) { await respond({ text: "Claude CLI not available.", response_type: "ephemeral" }); break; }
|
|
685
|
+
if (activeExec) { await respond({ text: "Already executing task #" + activeExec.taskId + ".", response_type: "ephemeral" }); break; }
|
|
686
|
+
const taskToExec = readAllTasks().find((t) => String(t.id) === execId);
|
|
687
|
+
if (!taskToExec) { await respond({ text: `Task #${execId} not found.`, response_type: "ephemeral" }); break; }
|
|
688
|
+
if (taskToExec.status === "pending") updateTask(execId, { status: "in_progress" });
|
|
689
|
+
if (!activeExec) spawnExecutor(taskToExec);
|
|
690
|
+
await respond({ text: `Executing task #${execId}: ${taskToExec.subject}`, response_type: "in_channel" });
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
case "stop": {
|
|
694
|
+
const stoppedId = stopExec();
|
|
695
|
+
await respond(stoppedId ? { text: `Stopped execution of task #${stoppedId}.`, response_type: "in_channel" } : { text: "No active execution.", response_type: "ephemeral" });
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
default:
|
|
699
|
+
await respond({ text: "Unknown: `" + sub + "`\nAvailable: `board` `list` `add` `ask` `exec` `stop`", response_type: "ephemeral" });
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
slackApp.action(/^task_(start|complete|delete)_/, async ({ action, ack, respond, body }) => {
|
|
704
|
+
await ack();
|
|
705
|
+
const match = action.action_id.match(/^task_(start|complete|delete)_(.+)$/);
|
|
706
|
+
if (!match) return;
|
|
707
|
+
const actionType = match[1]; const taskId = match[2];
|
|
708
|
+
const userName = (body.user && body.user.name) || (body.user && body.user.id) || "someone";
|
|
709
|
+
if (actionType === "start") {
|
|
710
|
+
const task = updateTask(taskId, { status: "in_progress" });
|
|
711
|
+
await respond(task ? { text: "Task #" + taskId + " started by " + userName + ": " + task.subject, response_type: "in_channel", replace_original: false } : { text: "Task #" + taskId + " not found.", response_type: "ephemeral" });
|
|
712
|
+
} else if (actionType === "complete") {
|
|
713
|
+
const task = updateTask(taskId, { status: "completed" });
|
|
714
|
+
await respond(task ? { text: "Task #" + taskId + " completed by " + userName + ": " + task.subject, response_type: "in_channel", replace_original: false } : { text: "Task #" + taskId + " not found.", response_type: "ephemeral" });
|
|
715
|
+
} else {
|
|
716
|
+
const task = readAllTasks().find((t) => String(t.id) === taskId);
|
|
717
|
+
const ok = deleteTask(taskId);
|
|
718
|
+
await respond(ok ? { text: "Task #" + taskId + " deleted by " + userName + (task ? ": " + task.subject : ""), response_type: "in_channel", replace_original: false } : { text: "Task #" + taskId + " not found.", response_type: "ephemeral" });
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
slackApp.view("add_task_modal", async ({ ack, view }) => {
|
|
723
|
+
await ack();
|
|
724
|
+
const vals = view.state.values;
|
|
725
|
+
const subject = vals.subject_block.subject_input.value || "";
|
|
726
|
+
const description = (vals.desc_block && vals.desc_block.desc_input && vals.desc_block.desc_input.value) || "";
|
|
727
|
+
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";
|
|
728
|
+
if (subject) {
|
|
729
|
+
const task = createTask({ subject, description, priority });
|
|
730
|
+
if (SLACK_CHANNEL_ID) slackApp.client.chat.postMessage({ channel: SLACK_CHANNEL_ID, text: "Task #" + task.id + " created via Slack: " + task.subject }).catch(() => {});
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
await slackApp.start();
|
|
735
|
+
console.log(" Slack Bot: connected (Socket Mode)");
|
|
736
|
+
} catch (e) {
|
|
737
|
+
console.log(" Slack Bot: failed — " + e.message);
|
|
738
|
+
slackApp = null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function buildBoardBlocks(tasks) {
|
|
743
|
+
const pending = tasks.filter((t) => t.status === "pending");
|
|
744
|
+
const inProgress = tasks.filter((t) => t.status === "in_progress");
|
|
745
|
+
const completed = tasks.filter((t) => t.status === "completed");
|
|
746
|
+
const total = tasks.length;
|
|
747
|
+
const pct = total ? Math.round((completed.length / total) * 100) : 0;
|
|
748
|
+
const filled = Math.round(pct / 5);
|
|
749
|
+
let bar = ""; for (let i = 0; i < 20; i++) bar += i < filled ? "█" : "░";
|
|
750
|
+
const blocks = [
|
|
751
|
+
{ type: "header", text: { type: "plain_text", text: PROJECT_NAME + " kanban" } },
|
|
752
|
+
{ type: "section", text: { type: "mrkdwn", text: "*" + completed.length + "* done · *" + inProgress.length + "* in progress · *" + pending.length + "* pending · *" + total + "* total\n`" + bar + "` " + pct + "%" } },
|
|
753
|
+
];
|
|
754
|
+
if (inProgress.length > 0) {
|
|
755
|
+
blocks.push({ type: "divider" }, { type: "section", text: { type: "mrkdwn", text: ":arrows_counterclockwise: *In Progress* (" + inProgress.length + ")" } });
|
|
756
|
+
for (const t of inProgress) {
|
|
757
|
+
let line = "> *#" + t.id + "* " + t.subject;
|
|
758
|
+
if (t.owner) line += " — " + t.owner;
|
|
759
|
+
if (t.activeForm) line += "\n> _" + t.activeForm + "_";
|
|
760
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: line } });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (pending.length > 0) {
|
|
764
|
+
blocks.push({ type: "divider" }, { type: "section", text: { type: "mrkdwn", text: ":hourglass_flowing_sand: *Pending* (" + pending.length + ")" } });
|
|
765
|
+
const showPending = pending.length > 8 ? pending.slice(0, 6) : pending;
|
|
766
|
+
const lines = showPending.map((p) => { let pl = "*#" + p.id + "* " + p.subject; if (p.priority === "high") pl += " :red_circle:"; if (p.owner) pl += " — " + p.owner; return pl; });
|
|
767
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } });
|
|
768
|
+
if (pending.length > 8) blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: "+" + (pending.length - 6) + " more — `" + SLACK_COMMAND + " list`" }] });
|
|
769
|
+
}
|
|
770
|
+
const recentDone = completed.slice(-3);
|
|
771
|
+
if (recentDone.length > 0) {
|
|
772
|
+
blocks.push({ type: "divider" }, { type: "section", text: { type: "mrkdwn", text: ":white_check_mark: *Recently Done*" } });
|
|
773
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: recentDone.map((d) => "~#" + d.id + " " + d.subject + "~").join("\n") }] });
|
|
774
|
+
}
|
|
775
|
+
blocks.push({ type: "divider" }, { type: "context", elements: [{ type: "mrkdwn", text: ":keyboard: `" + SLACK_COMMAND + " list` · `" + SLACK_COMMAND + " add` · `" + SLACK_COMMAND + " ask`" }] });
|
|
776
|
+
return blocks;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function buildTaskListBlocks(tasks) {
|
|
780
|
+
const blocks = [{ type: "header", text: { type: "plain_text", text: PROJECT_NAME + " kanban" } }];
|
|
781
|
+
const groups = [
|
|
782
|
+
{ key: "in_progress", label: "In Progress" },
|
|
783
|
+
{ key: "pending", label: "Pending" },
|
|
784
|
+
{ key: "completed", label: "Completed (recent)" },
|
|
785
|
+
];
|
|
786
|
+
for (const g of groups) {
|
|
787
|
+
let items = tasks.filter((t) => t.status === g.key);
|
|
788
|
+
if (g.key === "completed") items = items.slice(-5);
|
|
789
|
+
if (items.length === 0) continue;
|
|
790
|
+
blocks.push({ type: "divider" }, { type: "section", text: { type: "mrkdwn", text: "*" + g.label + "* (" + items.length + ")" } });
|
|
791
|
+
for (const t of items) {
|
|
792
|
+
let desc = "*#" + t.id + "* " + t.subject;
|
|
793
|
+
if (t.owner) desc += " " + t.owner;
|
|
794
|
+
if (t.activeForm) desc += "\n_" + t.activeForm + "_";
|
|
795
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: desc } });
|
|
796
|
+
const buttons = [];
|
|
797
|
+
if (t.status === "pending") buttons.push({ type: "button", text: { type: "plain_text", text: "Start" }, action_id: "task_start_" + t.id, style: "primary" });
|
|
798
|
+
if (t.status === "in_progress") buttons.push({ type: "button", text: { type: "plain_text", text: "Complete" }, action_id: "task_complete_" + t.id, style: "primary" });
|
|
799
|
+
if (t.status !== "completed") buttons.push({ type: "button", text: { type: "plain_text", text: "Delete" }, action_id: "task_delete_" + t.id, style: "danger" });
|
|
800
|
+
if (buttons.length > 0) blocks.push({ type: "actions", elements: buttons });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (blocks.length <= 1) blocks.push({ type: "section", text: { type: "mrkdwn", text: "_No tasks found._" } });
|
|
804
|
+
return blocks;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function buildAddTaskModal() {
|
|
808
|
+
return {
|
|
809
|
+
type: "modal", callback_id: "add_task_modal",
|
|
810
|
+
title: { type: "plain_text", text: "New Task" }, submit: { type: "plain_text", text: "Create" },
|
|
811
|
+
blocks: [
|
|
812
|
+
{ type: "input", block_id: "subject_block", element: { type: "plain_text_input", action_id: "subject_input", placeholder: { type: "plain_text", text: "Task title" } }, label: { type: "plain_text", text: "Subject" } },
|
|
813
|
+
{ type: "input", block_id: "desc_block", optional: true, element: { type: "plain_text_input", action_id: "desc_input", multiline: true, placeholder: { type: "plain_text", text: "Task description" } }, label: { type: "plain_text", text: "Description" } },
|
|
814
|
+
{ type: "input", block_id: "priority_block", optional: true, element: { type: "static_select", action_id: "priority_select", initial_option: { text: { type: "plain_text", text: "Medium" }, value: "medium" }, options: [{ text: { type: "plain_text", text: "Low" }, value: "low" }, { text: { type: "plain_text", text: "Medium" }, value: "medium" }, { text: { type: "plain_text", text: "High" }, value: "high" }] }, label: { type: "plain_text", text: "Priority" } },
|
|
815
|
+
],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function handleSlackAsk(question, channelId, client) {
|
|
820
|
+
slackAskActive = true;
|
|
821
|
+
let prompt = buildChatSystemPrompt(readAllTasks(), PROJECT_NAME);
|
|
822
|
+
prompt += "\n\n[User via Slack]: " + question;
|
|
823
|
+
appendOrchestratorHistory("user-slack", question);
|
|
824
|
+
const askEnv = Object.assign({}, process.env);
|
|
825
|
+
delete askEnv.ANTHROPIC_API_KEY;
|
|
826
|
+
const proc = spawn("claude", ["-p", "--output-format", "text", "--model", "sonnet", "--no-session-persistence"], { cwd: REPO_PATH, env: askEnv, stdio: ["pipe", "pipe", "pipe"] });
|
|
827
|
+
proc.stdin.write(prompt); proc.stdin.end();
|
|
828
|
+
let output = "";
|
|
829
|
+
proc.stdout.on("data", (data) => { output += data.toString(); });
|
|
830
|
+
proc.on("close", (code) => {
|
|
831
|
+
slackAskActive = false;
|
|
832
|
+
const targetChannel = channelId || SLACK_CHANNEL_ID;
|
|
833
|
+
if (!targetChannel || !client) return;
|
|
834
|
+
const text = code === 0 && output.trim() ? output.trim().slice(0, 3000) : "(Claude returned exit code " + code + ")";
|
|
835
|
+
appendOrchestratorHistory("orchestrator", text);
|
|
836
|
+
extractAndSaveLearnings(output);
|
|
837
|
+
client.chat.postMessage({ channel: targetChannel, text: "*Claude says:*\n" + text }).catch(() => {});
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── File watch + activity diffing ────────────────────────────────────────────
|
|
842
|
+
const taskSnapshot = new Map();
|
|
843
|
+
function snapshotTasks(tasks) { for (const t of tasks) taskSnapshot.set(String(t.id), { status: t.status, subject: t.subject, owner: t.owner || "", reportSummary: t.reportSummary || "" }); }
|
|
844
|
+
function detectAndNotifyChanges(tasks) {
|
|
845
|
+
for (const t of tasks) {
|
|
846
|
+
const id = String(t.id);
|
|
847
|
+
const prev = taskSnapshot.get(id);
|
|
848
|
+
if (!prev) {
|
|
849
|
+
logActivity({ type: "created", taskId: id, subject: t.subject, agent: t.agent || t.owner || "", detail: t.priority === "high" ? "Priority: high" : "", description: t.description, priority: t.priority, owner: t.owner, parentId: t.parentId });
|
|
850
|
+
} else if (prev.status !== t.status) {
|
|
851
|
+
if (t.status === "in_progress" && prev.status === "pending") logActivity({ type: "started", taskId: id, subject: t.subject, agent: t.agent || t.owner || "", detail: t.activeForm || "", owner: t.owner, activeForm: t.activeForm, description: t.description });
|
|
852
|
+
else if (t.status === "completed") logActivity({ type: "completed", taskId: id, subject: t.subject, agent: t.agent || t.owner || "", detail: t.reportSummary || "", reportSummary: t.reportSummary, reportPath: t.reportPath, parentId: t.parentId });
|
|
853
|
+
else logActivity({ type: "updated", taskId: id, subject: t.subject, agent: t.agent || t.owner || "", detail: prev.status + " → " + t.status });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const currentIds = new Set(tasks.map((t) => String(t.id)));
|
|
857
|
+
for (const [id, prev] of taskSnapshot) if (!currentIds.has(id)) logActivity({ type: "deleted", taskId: id, subject: prev.subject || "Unknown", agent: "", detail: "" });
|
|
858
|
+
snapshotTasks(tasks);
|
|
859
|
+
}
|
|
860
|
+
function watchTasks() {
|
|
861
|
+
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR, { recursive: true });
|
|
862
|
+
const watched = new Set();
|
|
863
|
+
snapshotTasks(readAllTasks());
|
|
864
|
+
function onFileChange() { const tasks = readAllTasks(); detectAndNotifyChanges(tasks); broadcast({ type: "update", tasks }); }
|
|
865
|
+
function scanAndWatch() {
|
|
866
|
+
if (!fs.existsSync(TASKS_DIR)) return;
|
|
867
|
+
try {
|
|
868
|
+
for (const session of fs.readdirSync(TASKS_DIR, { withFileTypes: true })) {
|
|
869
|
+
if (!session.isDirectory()) continue;
|
|
870
|
+
const sp = path.join(TASKS_DIR, session.name);
|
|
871
|
+
if (!watched.has(sp)) { watched.add(sp); try { fs.watch(sp, { persistent: false }, () => onFileChange()); } catch {} }
|
|
872
|
+
}
|
|
873
|
+
} catch {}
|
|
874
|
+
}
|
|
875
|
+
try { fs.watch(TASKS_DIR, { persistent: false }, () => { scanAndWatch(); onFileChange(); }); } catch {}
|
|
876
|
+
scanAndWatch();
|
|
877
|
+
setInterval(() => broadcast({ type: "update", tasks: readAllTasks() }), 2000);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Request body parser ──────────────────────────────────────────────────────
|
|
881
|
+
function parseBody(req) {
|
|
882
|
+
return new Promise((resolve, reject) => {
|
|
883
|
+
let body = "";
|
|
884
|
+
req.on("data", (chunk) => (body += chunk));
|
|
885
|
+
req.on("end", () => { try { resolve(body ? JSON.parse(body) : {}); } catch { reject(new Error("Invalid JSON")); } });
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ── Static assets ────────────────────────────────────────────────────────────
|
|
890
|
+
const HTML_PATH = path.join(HARNESS_ROOT, "ui", "kanban.html");
|
|
891
|
+
function getHTML() { return fs.readFileSync(HTML_PATH, "utf-8").replace(/\{\{PORT\}\}/g, String(PORT)); }
|
|
892
|
+
|
|
893
|
+
const STATIC_ROOTS = [path.join(HARNESS_ROOT, "ui"), path.join(HARNESS_ROOT, "playbooks")];
|
|
894
|
+
const MIME = { ".css": "text/css", ".js": "application/javascript", ".html": "text/html; charset=utf-8", ".svg": "image/svg+xml", ".json": "application/json", ".png": "image/png" };
|
|
895
|
+
function serveStatic(req, res) {
|
|
896
|
+
// /styles/foo.css → ui/styles/foo.css ; /playbooks/foo.html → playbooks/foo.html
|
|
897
|
+
let urlPath = decodeURIComponent((req.url.split("?")[0] || ""));
|
|
898
|
+
if (urlPath === "/" || urlPath === "") return false;
|
|
899
|
+
if (urlPath.includes("..")) return false;
|
|
900
|
+
const candidates = [
|
|
901
|
+
path.join(HARNESS_ROOT, "ui", urlPath.replace(/^\//, "")),
|
|
902
|
+
path.join(HARNESS_ROOT, urlPath.replace(/^\//, "")),
|
|
903
|
+
];
|
|
904
|
+
for (const file of candidates) {
|
|
905
|
+
if (!STATIC_ROOTS.some((root) => file.startsWith(root))) continue;
|
|
906
|
+
if (fs.existsSync(file) && fs.statSync(file).isFile()) {
|
|
907
|
+
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream" });
|
|
908
|
+
res.end(fs.readFileSync(file));
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ── HTTP server ──────────────────────────────────────────────────────────────
|
|
916
|
+
const server = http.createServer(async (req, res) => {
|
|
917
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
918
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
919
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
920
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
921
|
+
|
|
922
|
+
if (req.url === "/events") {
|
|
923
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
924
|
+
sseClients.add(res);
|
|
925
|
+
res.write("data: " + JSON.stringify({ type: "update", tasks: readAllTasks() }) + "\n\n");
|
|
926
|
+
req.on("close", () => sseClients.delete(res));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (req.url === "/api/tasks" && req.method === "GET") {
|
|
931
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(readAllTasks())); return;
|
|
932
|
+
}
|
|
933
|
+
if (req.url === "/api/tasks" && req.method === "POST") {
|
|
934
|
+
try { const task = createTask(await parseBody(req)); res.writeHead(201, { "Content-Type": "application/json" }); res.end(JSON.stringify(task)); }
|
|
935
|
+
catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const putMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)$/);
|
|
939
|
+
if (putMatch && req.method === "PUT") {
|
|
940
|
+
try {
|
|
941
|
+
const task = updateTask(putMatch[1], await parseBody(req));
|
|
942
|
+
if (!task) { res.writeHead(404); res.end('{"error":"Not found"}'); return; }
|
|
943
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(task));
|
|
944
|
+
} catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const delMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)$/);
|
|
948
|
+
if (delMatch && req.method === "DELETE") { const ok = deleteTask(delMatch[1]); res.writeHead(ok ? 204 : 404); res.end(); return; }
|
|
949
|
+
|
|
950
|
+
// POST /api/tasks/:id/slack — post a one-off note to Slack for this task
|
|
951
|
+
const slackMatch = req.url.match(/^\/api\/tasks\/([\w.-]+)\/slack$/);
|
|
952
|
+
if (slackMatch && req.method === "POST") {
|
|
953
|
+
try {
|
|
954
|
+
const body = await parseBody(req);
|
|
955
|
+
const t = readAllTasks().find((x) => String(x.id) === slackMatch[1]);
|
|
956
|
+
const prefix = t ? `[#${t.id}] ` : "";
|
|
957
|
+
slackNotify(prefix + (body.text || ""));
|
|
958
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end('{"ok":true}');
|
|
959
|
+
} catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// GET /api/agents — registry from agents/*.md frontmatter
|
|
964
|
+
if (req.url.split("?")[0] === "/api/agents" && req.method === "GET") {
|
|
965
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ agents: listAgents() })); return;
|
|
966
|
+
}
|
|
967
|
+
const agentFullMatch = req.url.match(/^\/api\/agents\/([^/]+)\/full$/);
|
|
968
|
+
if (agentFullMatch && req.method === "GET") {
|
|
969
|
+
const a = getAgentFull(decodeURIComponent(agentFullMatch[1]));
|
|
970
|
+
if (!a) { res.writeHead(404); res.end('{"error":"Not found"}'); return; }
|
|
971
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(a)); return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── Ops Thread (Telegram mirror) ──
|
|
975
|
+
if (req.url.split("?")[0] === "/api/ops-thread" && req.method === "GET") {
|
|
976
|
+
const since = new URL(req.url, "http://localhost").searchParams.get("since") || null;
|
|
977
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
978
|
+
res.end(JSON.stringify(readOpsThread(since)));
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (req.url === "/api/ops-thread/append" && req.method === "POST") {
|
|
982
|
+
try {
|
|
983
|
+
const body = await parseBody(req);
|
|
984
|
+
const msg = opsAppend(body.role || "system", body.text || "", body.taskId != null ? body.taskId : null, { source: body.source });
|
|
985
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
986
|
+
res.end(JSON.stringify(msg));
|
|
987
|
+
} catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (req.url === "/api/ops-thread/send" && req.method === "POST") {
|
|
991
|
+
try {
|
|
992
|
+
const body = await parseBody(req);
|
|
993
|
+
const text = String(body.text == null ? "" : body.text);
|
|
994
|
+
const msg = opsAppend("you", text, null, { source: "kanban" });
|
|
995
|
+
telegramSend(text); // best-effort
|
|
996
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
997
|
+
res.end(JSON.stringify(msg));
|
|
998
|
+
} catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (req.url === "/api/telegram/status" && req.method === "GET") {
|
|
1002
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1003
|
+
res.end(JSON.stringify({
|
|
1004
|
+
configured: !!(TELEGRAM.botToken && TELEGRAM.chatId),
|
|
1005
|
+
botToken: TELEGRAM.botToken ? "(set)" : "",
|
|
1006
|
+
chatId: TELEGRAM.chatId || "",
|
|
1007
|
+
polling: !!telegramPollerHandle,
|
|
1008
|
+
offset: telegramOffset,
|
|
1009
|
+
}));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
// Convenience: send any DM to your bot, then GET this to see your chat id.
|
|
1013
|
+
if (req.url === "/api/telegram/whoami" && req.method === "GET") {
|
|
1014
|
+
if (!TELEGRAM.botToken) { res.writeHead(400, { "Content-Type": "application/json" }); res.end('{"error":"TELEGRAM_BOT_TOKEN not set"}'); return; }
|
|
1015
|
+
const r = await telegramHttp("getUpdates", { limit: 5 });
|
|
1016
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1017
|
+
const chats = (r && r.result || []).map((u) => u.message && u.message.chat).filter(Boolean);
|
|
1018
|
+
res.end(JSON.stringify({ ok: r && r.ok, chats }));
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (req.url.startsWith("/api/activity") && req.method === "GET") {
|
|
1023
|
+
const params = new URL(req.url, "http://localhost").searchParams;
|
|
1024
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1025
|
+
res.end(JSON.stringify(readActivity(params.get("since") || null, parseInt(params.get("limit")) || 200)));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// GET /api/report?path=<filepath> — read a report file (sandboxed)
|
|
1030
|
+
if (req.url.startsWith("/api/report?") && req.method === "GET") {
|
|
1031
|
+
const params = new URL(req.url, "http://localhost").searchParams;
|
|
1032
|
+
const filePath = params.get("path");
|
|
1033
|
+
if (!filePath) { res.writeHead(404, { "Content-Type": "application/json" }); res.end('{"error":"File not found"}'); return; }
|
|
1034
|
+
const resolved = path.resolve(filePath.startsWith("/") ? filePath : path.join(REPO_PATH, filePath));
|
|
1035
|
+
const allowed = [path.join(os.homedir(), ".claude"), HARNESS_ROOT, REPO_PATH];
|
|
1036
|
+
if (!allowed.some((a) => resolved.startsWith(a)) || !fs.existsSync(resolved)) { res.writeHead(403, { "Content-Type": "application/json" }); res.end('{"error":"Access denied"}'); return; }
|
|
1037
|
+
try { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ path: resolved, content: fs.readFileSync(resolved, "utf-8") })); }
|
|
1038
|
+
catch (e) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (req.url === "/api/cli-status" && req.method === "GET") {
|
|
1043
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1044
|
+
res.end(JSON.stringify({ available: cliAvailable, executing: activeExec ? activeExec.taskId : null, nested: !!process.env.CLAUDECODE }));
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ── Orchestrator chat (optional) ──
|
|
1049
|
+
const CHAT_FILE = path.join(KANBAN_DIR, "chat-history.json");
|
|
1050
|
+
if (req.url === "/api/chat/history" && req.method === "GET") {
|
|
1051
|
+
try { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(fs.existsSync(CHAT_FILE) ? JSON.parse(fs.readFileSync(CHAT_FILE, "utf-8")) : [])); }
|
|
1052
|
+
catch { res.writeHead(200, { "Content-Type": "application/json" }); res.end("[]"); }
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (req.url === "/api/chat/history" && req.method === "PUT") {
|
|
1056
|
+
try { const body = await parseBody(req); fs.writeFileSync(CHAT_FILE, JSON.stringify(body.messages || [], null, 2)); res.writeHead(200, { "Content-Type": "application/json" }); res.end('{"ok":true}'); }
|
|
1057
|
+
catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (req.url === "/api/chat/history" && req.method === "DELETE") { try { fs.unlinkSync(CHAT_FILE); } catch {} res.writeHead(200, { "Content-Type": "application/json" }); res.end('{"ok":true}'); return; }
|
|
1061
|
+
if (req.url === "/api/orchestrator" && req.method === "GET") {
|
|
1062
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ prompt: readOrchestratorPrompt(), history: readOrchestratorHistory(20), path: ORCHESTRATOR_FILE })); return;
|
|
1063
|
+
}
|
|
1064
|
+
if (req.url === "/api/orchestrator" && req.method === "PUT") {
|
|
1065
|
+
try { const body = await parseBody(req); if (body.prompt) fs.writeFileSync(ORCHESTRATOR_FILE, body.prompt); res.writeHead(200, { "Content-Type": "application/json" }); res.end('{"ok":true}'); }
|
|
1066
|
+
catch (e) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (req.url === "/api/chat" && req.method === "POST") {
|
|
1070
|
+
if (!cliAvailable) { res.writeHead(503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Claude CLI not available" })); return; }
|
|
1071
|
+
try {
|
|
1072
|
+
const chatBody = await parseBody(req);
|
|
1073
|
+
const chatMessage = chatBody.message || "";
|
|
1074
|
+
const chatHistory = chatBody.history || [];
|
|
1075
|
+
const chatModel = chatBody.model || "sonnet";
|
|
1076
|
+
let chatPrompt = buildChatSystemPrompt(readAllTasks(), PROJECT_NAME);
|
|
1077
|
+
if (chatHistory.length > 0) {
|
|
1078
|
+
chatPrompt += "## Conversation so far:\n";
|
|
1079
|
+
for (let ci = Math.max(0, chatHistory.length - 10); ci < chatHistory.length; ci++) {
|
|
1080
|
+
const ch = chatHistory[ci];
|
|
1081
|
+
chatPrompt += (ch.role === "user" ? "[User]" : "[Assistant]") + ": " + ch.content + "\n\n";
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
chatPrompt += "[User]: " + chatMessage;
|
|
1085
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
1086
|
+
const chatEnv = Object.assign({}, process.env);
|
|
1087
|
+
delete chatEnv.ANTHROPIC_API_KEY;
|
|
1088
|
+
const chatProc = spawn("claude", ["-p", "--verbose", "--output-format", "stream-json", "--model", chatModel, "--no-session-persistence"], { cwd: REPO_PATH, env: chatEnv, stdio: ["pipe", "pipe", "pipe"] });
|
|
1089
|
+
chatProc.stdin.write(chatPrompt); chatProc.stdin.end();
|
|
1090
|
+
let chatBuf = "", rawOut = "", fullResponse = "";
|
|
1091
|
+
appendOrchestratorHistory("user", chatMessage);
|
|
1092
|
+
chatProc.stdout.on("data", (data) => {
|
|
1093
|
+
const chunk = data.toString(); rawOut += chunk; chatBuf += chunk;
|
|
1094
|
+
const lines = chatBuf.split("\n"); chatBuf = lines.pop();
|
|
1095
|
+
for (const ln of lines) {
|
|
1096
|
+
if (!ln.trim()) continue;
|
|
1097
|
+
try {
|
|
1098
|
+
const json = JSON.parse(ln);
|
|
1099
|
+
let text = "";
|
|
1100
|
+
if (json.type === "assistant" && json.subtype === "text") text = json.text || "";
|
|
1101
|
+
else if (json.type === "assistant" && json.message && json.message.content) json.message.content.forEach((c) => { if (c.type === "text") text += c.text; });
|
|
1102
|
+
else if (json.type === "content_block_delta" && json.delta) text = json.delta.text || "";
|
|
1103
|
+
if (text) { fullResponse += text; res.write("data: " + JSON.stringify({ type: "chat", chunk: text }) + "\n\n"); }
|
|
1104
|
+
} catch {}
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
let chatErr = "";
|
|
1108
|
+
chatProc.stderr.on("data", (data) => { chatErr += data.toString(); });
|
|
1109
|
+
chatProc.on("close", (code) => {
|
|
1110
|
+
try {
|
|
1111
|
+
if (!rawOut.trim() || code !== 0) res.write("data: " + JSON.stringify({ type: "chat_debug", rawOut: rawOut.slice(0, 2000), stderr: chatErr.slice(0, 2000), exitCode: code }) + "\n\n");
|
|
1112
|
+
if (chatErr) res.write("data: " + JSON.stringify({ type: "chat_error", error: chatErr.trim(), exitCode: code }) + "\n\n");
|
|
1113
|
+
res.write("data: " + JSON.stringify({ type: "chat_done" }) + "\n\n"); res.end();
|
|
1114
|
+
if (fullResponse) { appendOrchestratorHistory("orchestrator", fullResponse); extractAndSaveLearnings(fullResponse); }
|
|
1115
|
+
} catch {}
|
|
1116
|
+
});
|
|
1117
|
+
req.on("close", () => { try { chatProc.kill(); } catch {} });
|
|
1118
|
+
} catch (e) { if (!res.headersSent) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); } }
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (req.url === "/api/exec/stop" && req.method === "POST") {
|
|
1122
|
+
const stoppedId = stopExec();
|
|
1123
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ stopped: !!stoppedId, taskId: stoppedId }));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Static (CSS / playbooks / etc.)
|
|
1128
|
+
if (req.method === "GET" && serveStatic(req, res)) return;
|
|
1129
|
+
|
|
1130
|
+
// HTML dashboard
|
|
1131
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1132
|
+
res.end(getHTML());
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
checkClaudeCLI();
|
|
1136
|
+
watchTasks();
|
|
1137
|
+
server.listen(PORT, () => {
|
|
1138
|
+
console.log("");
|
|
1139
|
+
console.log(" " + PROJECT_NAME + " · kanban-system");
|
|
1140
|
+
console.log(" ─────────────────────────");
|
|
1141
|
+
console.log(" http://localhost:" + PORT);
|
|
1142
|
+
console.log(" Tasks: " + TASKS_DIR);
|
|
1143
|
+
console.log(" Manual: " + KANBAN_DIR);
|
|
1144
|
+
console.log(" App repo:" + REPO_PATH);
|
|
1145
|
+
console.log(" Config: " + (config.configSource || "(none — using defaults)"));
|
|
1146
|
+
console.log(" CLI: " + (cliAvailable ? "ready" : process.env.CLAUDECODE ? "unavailable (nested session)" : "not found"));
|
|
1147
|
+
if (TELEGRAM.botToken && TELEGRAM.chatId) console.log(" Telegram: configured (chat=" + TELEGRAM.chatId + ")");
|
|
1148
|
+
else console.log(" Telegram: not configured (set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID)");
|
|
1149
|
+
console.log("");
|
|
1150
|
+
initSlackBot();
|
|
1151
|
+
startTelegramPoller();
|
|
1152
|
+
});
|