herdr-plugin-amq 0.1.2
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/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/herdr-amq.mjs +74 -0
- package/herdr-plugin.toml +57 -0
- package/package.json +50 -0
- package/skills/herdr-amq/SKILL.md +69 -0
- package/src/actions.mjs +573 -0
- package/src/blobs.mjs +348 -0
- package/src/board.mjs +760 -0
- package/src/bridge.mjs +373 -0
- package/src/briefs.mjs +201 -0
- package/src/config.mjs +146 -0
- package/src/herdr.mjs +215 -0
- package/src/index.mjs +4 -0
- package/src/markdown.mjs +167 -0
- package/src/panes.mjs +68 -0
- package/src/protocol.mjs +347 -0
- package/src/server.mjs +773 -0
- package/src/store.mjs +1063 -0
- package/src/web/app.js +3066 -0
- package/src/web/index.html +727 -0
- package/src/web/style.css +3842 -0
- package/src/worktrees.mjs +217 -0
package/src/board.mjs
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* board.mjs — Swarm Coordination Kanban Board
|
|
3
|
+
*
|
|
4
|
+
* Parses and coordinates tasks from .opencode/bus/STATUS.md and local task state.
|
|
5
|
+
* Supports columns:
|
|
6
|
+
* - backlog (Fila / queued tasks)
|
|
7
|
+
* - in_progress (Em Voo / claimed / active WIP)
|
|
8
|
+
* - blocked (Bloqueios / hazards / awaiting verification)
|
|
9
|
+
* - done (Concluído / resolved / shipped)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { sendAmqMessage } from "./store.mjs";
|
|
16
|
+
|
|
17
|
+
export function findStatusFile(repoRoot) {
|
|
18
|
+
if (!repoRoot) return null;
|
|
19
|
+
const candidates = [
|
|
20
|
+
path.join(repoRoot, ".opencode", "bus", "STATUS.md"),
|
|
21
|
+
path.join(repoRoot, "STATUS.md"),
|
|
22
|
+
path.join(repoRoot, "docs", "STATUS.md"),
|
|
23
|
+
];
|
|
24
|
+
for (const c of candidates) {
|
|
25
|
+
if (fs.existsSync(c)) return c;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Locate the global bus directory for tasks.
|
|
32
|
+
* Defaults to .opencode/bus (or .agent-mail/bus).
|
|
33
|
+
*/
|
|
34
|
+
export function getBusDirectory(repoRoot, amqRoot) {
|
|
35
|
+
if (repoRoot) {
|
|
36
|
+
const opencodeBus = path.join(repoRoot, ".opencode", "bus");
|
|
37
|
+
if (fs.existsSync(opencodeBus)) {
|
|
38
|
+
return opencodeBus;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (amqRoot) {
|
|
42
|
+
return path.join(amqRoot, "bus");
|
|
43
|
+
}
|
|
44
|
+
if (repoRoot) {
|
|
45
|
+
return path.join(repoRoot, ".opencode", "bus");
|
|
46
|
+
}
|
|
47
|
+
return path.join(process.cwd(), ".opencode", "bus");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const STAGE_DIRS = {
|
|
51
|
+
backlog: "backlog",
|
|
52
|
+
in_progress: "doing",
|
|
53
|
+
doing: "doing",
|
|
54
|
+
blocked: "blocked",
|
|
55
|
+
done: "done",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function ensureBusDirectories(busDir) {
|
|
59
|
+
if (!busDir) return;
|
|
60
|
+
const stages = ["backlog", "doing", "blocked", "done"];
|
|
61
|
+
for (const s of stages) {
|
|
62
|
+
const d = path.join(busDir, s);
|
|
63
|
+
if (!fs.existsSync(d)) {
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(d, { recursive: true });
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveStageDir(busDir, stage) {
|
|
72
|
+
const standard = STAGE_DIRS[stage] || "backlog";
|
|
73
|
+
if (standard === "doing") {
|
|
74
|
+
if (busDir && fs.existsSync(path.join(busDir, "in_progress")) && !fs.existsSync(path.join(busDir, "doing"))) {
|
|
75
|
+
return "in_progress";
|
|
76
|
+
}
|
|
77
|
+
return "doing";
|
|
78
|
+
}
|
|
79
|
+
return standard;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Serializes a task object into a Markdown file with frontmatter.
|
|
84
|
+
*/
|
|
85
|
+
export function serializeTaskFile(task) {
|
|
86
|
+
const safeId = task.id || `task_${Date.now()}`;
|
|
87
|
+
const safeTitle = task.title || "(sem título)";
|
|
88
|
+
const safeOwner = canonicalizeOwner(task.owner || "coordinator");
|
|
89
|
+
const rawStatus = task.status === "doing" ? "doing" : (task.status || "backlog");
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
|
|
92
|
+
const lines = [
|
|
93
|
+
"---",
|
|
94
|
+
`id: ${JSON.stringify(safeId)}`,
|
|
95
|
+
`title: ${JSON.stringify(safeTitle)}`,
|
|
96
|
+
`owner: ${JSON.stringify(safeOwner)}`,
|
|
97
|
+
`status: ${JSON.stringify(rawStatus)}`,
|
|
98
|
+
`created: ${JSON.stringify(task.created || now)}`,
|
|
99
|
+
`updated: ${JSON.stringify(task.updated || now)}`,
|
|
100
|
+
`thread: ${JSON.stringify(task.thread || `agboard/${safeId}`)}`,
|
|
101
|
+
`source: "bus"`,
|
|
102
|
+
];
|
|
103
|
+
if (task.priority) lines.push(`priority: ${JSON.stringify(task.priority)}`);
|
|
104
|
+
lines.push("---");
|
|
105
|
+
lines.push("");
|
|
106
|
+
|
|
107
|
+
const body = (task.description || "").trim();
|
|
108
|
+
if (body) {
|
|
109
|
+
lines.push(body);
|
|
110
|
+
lines.push("");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parses a task Markdown file with frontmatter into a task object.
|
|
118
|
+
*/
|
|
119
|
+
export function parseTaskFile(filePath, defaultStage = "backlog") {
|
|
120
|
+
try {
|
|
121
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
122
|
+
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
|
|
123
|
+
let meta = {};
|
|
124
|
+
let body = "";
|
|
125
|
+
|
|
126
|
+
if (match) {
|
|
127
|
+
const frontmatter = match[1];
|
|
128
|
+
body = (match[2] || "").trim();
|
|
129
|
+
|
|
130
|
+
for (const line of frontmatter.split("\n")) {
|
|
131
|
+
const colonIdx = line.indexOf(":");
|
|
132
|
+
if (colonIdx > 0) {
|
|
133
|
+
const key = line.slice(0, colonIdx).trim();
|
|
134
|
+
let val = line.slice(colonIdx + 1).trim();
|
|
135
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
136
|
+
try {
|
|
137
|
+
val = JSON.parse(val);
|
|
138
|
+
} catch {
|
|
139
|
+
val = val.slice(1, -1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
meta[key] = val;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
body = raw.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const baseName = path.basename(filePath, ".md");
|
|
150
|
+
const id = meta.id || baseName;
|
|
151
|
+
const title = meta.title || baseName;
|
|
152
|
+
const owner = canonicalizeOwner(meta.owner || "coordinator");
|
|
153
|
+
const rawStatus = meta.status || defaultStage;
|
|
154
|
+
const status = ["backlog", "in_progress", "doing", "blocked", "done"].includes(rawStatus)
|
|
155
|
+
? (rawStatus === "doing" ? "in_progress" : rawStatus)
|
|
156
|
+
: defaultStage;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
id,
|
|
160
|
+
title,
|
|
161
|
+
owner,
|
|
162
|
+
status,
|
|
163
|
+
description: body || meta.description || "",
|
|
164
|
+
created: meta.created || null,
|
|
165
|
+
updated: meta.updated || null,
|
|
166
|
+
thread: meta.thread || `agboard/${id}`,
|
|
167
|
+
source: "bus",
|
|
168
|
+
filePath,
|
|
169
|
+
};
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getCustomBoardPath(amqRoot) {
|
|
176
|
+
if (!amqRoot) return null;
|
|
177
|
+
return path.join(amqRoot, "board_custom.json");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function loadCustomTasks(amqRoot) {
|
|
181
|
+
const p = getCustomBoardPath(amqRoot);
|
|
182
|
+
if (!p || !fs.existsSync(p)) return [];
|
|
183
|
+
try {
|
|
184
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
185
|
+
const parsed = JSON.parse(raw);
|
|
186
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function saveCustomTasks(amqRoot, tasks) {
|
|
193
|
+
const p = getCustomBoardPath(amqRoot);
|
|
194
|
+
if (!p) return false;
|
|
195
|
+
try {
|
|
196
|
+
fs.writeFileSync(p, JSON.stringify(tasks, null, 2), "utf8");
|
|
197
|
+
return true;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Deduplicate and normalize agent owner strings to clean canonical handles.
|
|
206
|
+
* e.g. "ballistics (fix)" -> "ballistics"
|
|
207
|
+
* "B - Ballistics" -> "ballistics"
|
|
208
|
+
* "player-rig (plano)" -> "player-rig"
|
|
209
|
+
* "range (achado) -> FALSO" -> "range"
|
|
210
|
+
* "coordinator / infra" -> "coordinator"
|
|
211
|
+
* "testkit (dono do verify-all.sh)" -> "testkit"
|
|
212
|
+
*/
|
|
213
|
+
export function canonicalizeOwner(rawOwner = "") {
|
|
214
|
+
if (!rawOwner || typeof rawOwner !== "string") return "coordinator";
|
|
215
|
+
|
|
216
|
+
let s = rawOwner.trim();
|
|
217
|
+
// Strip leading single-letter badge/prefix like "B - " or "B: " or "[B] "
|
|
218
|
+
s = s.replace(/^[a-zA-Z]\s*[-–—:]\s*/i, "");
|
|
219
|
+
s = s.replace(/^\[[a-zA-Z]\]\s*/i, "");
|
|
220
|
+
|
|
221
|
+
// Take first owner if delimited by +, →, ->, or /
|
|
222
|
+
s = s.split(/[+→/,]|->/)[0].trim();
|
|
223
|
+
|
|
224
|
+
// Strip parenthetical annotations: (fix), (plano), (achado..., (dono...
|
|
225
|
+
s = s.replace(/\s*\([^)]*\)?/g, "").trim();
|
|
226
|
+
|
|
227
|
+
// Strip markdown formatting characters
|
|
228
|
+
s = s.replace(/[`*_~:]/g, "").trim();
|
|
229
|
+
|
|
230
|
+
const lower = s.toLowerCase().trim();
|
|
231
|
+
|
|
232
|
+
if (!lower || lower === "todos" || lower === "all" || lower === "infra") {
|
|
233
|
+
return "coordinator";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Canonical swarm mapping
|
|
237
|
+
if (lower.includes("ballistics")) return "ballistics";
|
|
238
|
+
if (lower.includes("player-rig") || lower === "player") return "player-rig";
|
|
239
|
+
if (lower.includes("npc-body") || lower === "npc" || lower === "bot") return "npc-body";
|
|
240
|
+
if (lower.includes("testkit") || lower === "test") return "testkit";
|
|
241
|
+
if (lower.includes("range")) return "range";
|
|
242
|
+
if (lower.includes("spotter")) return "spotter";
|
|
243
|
+
if (lower.includes("inventory-ux") || lower.includes("inventory")) return "inventory-ux";
|
|
244
|
+
if (lower.includes("verifier")) return "verifier";
|
|
245
|
+
if (lower.includes("meta")) return "meta";
|
|
246
|
+
if (lower.includes("qa")) return "qa";
|
|
247
|
+
if (lower.includes("coord")) return "coordinator";
|
|
248
|
+
|
|
249
|
+
return lower;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Classify a table row into one of the 4 Kanban columns
|
|
254
|
+
*/
|
|
255
|
+
export function classifyStatus(itemText = "", statusText = "") {
|
|
256
|
+
const e = (statusText || "").toLowerCase().trim();
|
|
257
|
+
const it = (itemText || "").toLowerCase().trim();
|
|
258
|
+
|
|
259
|
+
// Struck through items are resolved/done
|
|
260
|
+
if (it.startsWith("~~")) return "done";
|
|
261
|
+
|
|
262
|
+
// Blocked: Explicit block, hazard, deadlock, fail
|
|
263
|
+
if (
|
|
264
|
+
it.includes("bloqueio") ||
|
|
265
|
+
e.includes("bloqueio") ||
|
|
266
|
+
e.includes("blocked") ||
|
|
267
|
+
e.includes("hazard") ||
|
|
268
|
+
e.includes("grave") ||
|
|
269
|
+
e.includes("fail") ||
|
|
270
|
+
e.includes("falha") ||
|
|
271
|
+
e.includes("deadlock")
|
|
272
|
+
) {
|
|
273
|
+
return "blocked";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// In Progress: Actively ongoing, WIP, diagnosed, partial phase
|
|
277
|
+
if (
|
|
278
|
+
e.includes("em curso") ||
|
|
279
|
+
e.includes("em andamento") ||
|
|
280
|
+
e.includes("causa achada") ||
|
|
281
|
+
e.includes("diagnosticado") ||
|
|
282
|
+
e.includes("wip") ||
|
|
283
|
+
e.includes("aguarda") ||
|
|
284
|
+
e.includes("claimed") ||
|
|
285
|
+
e.includes("re-validar") ||
|
|
286
|
+
e.includes("plano entregue") ||
|
|
287
|
+
e.startsWith("**fase 1") ||
|
|
288
|
+
e.startsWith("fase 1")
|
|
289
|
+
) {
|
|
290
|
+
return "in_progress";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Done: Completed / resolved / shipped
|
|
294
|
+
if (
|
|
295
|
+
e.startsWith("**done") ||
|
|
296
|
+
e.startsWith("done") ||
|
|
297
|
+
e.includes("resolvido") ||
|
|
298
|
+
e.includes("fechado") ||
|
|
299
|
+
e.includes("sucesso") ||
|
|
300
|
+
e.includes("pushed") ||
|
|
301
|
+
e.includes("pass exit 0")
|
|
302
|
+
) {
|
|
303
|
+
return "done";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Backlog: queued, future, unstarted
|
|
307
|
+
return "backlog";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Parse STATUS.md into structured card items
|
|
313
|
+
*/
|
|
314
|
+
export function parseStatusMd(content) {
|
|
315
|
+
if (!content || typeof content !== "string") return [];
|
|
316
|
+
const tasks = [];
|
|
317
|
+
|
|
318
|
+
// 1. Parse "## EM VOO (claimed)" section
|
|
319
|
+
const emVooMatch = content.match(/## EM VOO \(claimed\)\s*([\s\S]*?)(?=\n>|\n##|\n\| Item)/);
|
|
320
|
+
if (emVooMatch) {
|
|
321
|
+
const lines = emVooMatch[1].split("\n");
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
const trimmed = line.trim();
|
|
324
|
+
if (!trimmed.startsWith("**CLAIMED by")) continue;
|
|
325
|
+
const m = trimmed.match(/\*\*CLAIMED by (\S+)(?: ([^*]+))?\*\*\s*—?\s*([\s\S]*)/);
|
|
326
|
+
if (m) {
|
|
327
|
+
const owner = canonicalizeOwner(m[1]);
|
|
328
|
+
const timestamp = m[2] ? m[2].trim() : "";
|
|
329
|
+
const desc = m[3] ? m[3].trim() : "";
|
|
330
|
+
const titleMatch = desc.match(/^([^—:;.]+)/);
|
|
331
|
+
const title = titleMatch ? titleMatch[1].trim() : `Task claimed by ${owner}`;
|
|
332
|
+
const id = `claimed_${owner}_${crypto.createHash("md5").update(trimmed).digest("hex").slice(0, 8)}`;
|
|
333
|
+
|
|
334
|
+
tasks.push({
|
|
335
|
+
id,
|
|
336
|
+
title: `[EM VOO] ${title}`,
|
|
337
|
+
owner,
|
|
338
|
+
status: "in_progress",
|
|
339
|
+
timestamp,
|
|
340
|
+
description: desc,
|
|
341
|
+
source: "status_em_voo",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 2. Parse Markdown Table: | Item | Dono | Estado | line-by-line
|
|
348
|
+
const lines = content.split("\n");
|
|
349
|
+
let inTable = false;
|
|
350
|
+
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
const trimmed = line.trim();
|
|
353
|
+
if (/^\|\s*Item\s*\|\s*Dono\s*\|\s*Estado\s*\|/i.test(trimmed)) {
|
|
354
|
+
inTable = true;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (!inTable) continue;
|
|
358
|
+
if (/^\|[-:\s|]+\|$/.test(trimmed)) continue; // divider row
|
|
359
|
+
if (!trimmed.startsWith("|")) {
|
|
360
|
+
if (trimmed.startsWith("#")) inTable = false;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const parts = trimmed
|
|
365
|
+
.split("|")
|
|
366
|
+
.map((p) => p.trim())
|
|
367
|
+
.filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
|
|
368
|
+
|
|
369
|
+
if (parts.length >= 3) {
|
|
370
|
+
const itemText = parts[0];
|
|
371
|
+
const ownerText = parts[1];
|
|
372
|
+
const statusText = parts[2];
|
|
373
|
+
|
|
374
|
+
if (itemText === "Item" || itemText.startsWith("---")) continue;
|
|
375
|
+
|
|
376
|
+
const cleanTitle = itemText.replace(/^\*\*|\*\*$/g, "").trim();
|
|
377
|
+
const column = classifyStatus(itemText, statusText);
|
|
378
|
+
const id = `task_${crypto.createHash("md5").update(itemText + ownerText).digest("hex").slice(0, 10)}`;
|
|
379
|
+
|
|
380
|
+
// Extract deduplicated canonical owner
|
|
381
|
+
const primaryOwner = canonicalizeOwner(ownerText);
|
|
382
|
+
|
|
383
|
+
tasks.push({
|
|
384
|
+
id,
|
|
385
|
+
title: cleanTitle,
|
|
386
|
+
rawTitle: itemText,
|
|
387
|
+
owner: primaryOwner,
|
|
388
|
+
owners: ownerText,
|
|
389
|
+
status: column,
|
|
390
|
+
description: statusText,
|
|
391
|
+
source: "status_table",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
return tasks;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Load the complete board from the global bus directories: backlog, doing, blocked, done.
|
|
402
|
+
* Seeds from STATUS.md if bus directories are empty.
|
|
403
|
+
*/
|
|
404
|
+
export function loadBoard(repoRoot, amqRoot) {
|
|
405
|
+
const busDir = getBusDirectory(repoRoot, amqRoot);
|
|
406
|
+
ensureBusDirectories(busDir);
|
|
407
|
+
|
|
408
|
+
const statusFile = findStatusFile(repoRoot);
|
|
409
|
+
const busTasks = [];
|
|
410
|
+
const seenIds = new Set();
|
|
411
|
+
|
|
412
|
+
const stageScanMap = [
|
|
413
|
+
{ dir: "backlog", stage: "backlog" },
|
|
414
|
+
{ dir: "doing", stage: "in_progress" },
|
|
415
|
+
{ dir: "in_progress", stage: "in_progress" },
|
|
416
|
+
{ dir: "blocked", stage: "blocked" },
|
|
417
|
+
{ dir: "done", stage: "done" },
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
for (const { dir, stage } of stageScanMap) {
|
|
421
|
+
const fullDir = path.join(busDir, dir);
|
|
422
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
423
|
+
try {
|
|
424
|
+
const files = fs.readdirSync(fullDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
425
|
+
for (const file of files) {
|
|
426
|
+
const fullPath = path.join(fullDir, file);
|
|
427
|
+
const parsed = parseTaskFile(fullPath, stage);
|
|
428
|
+
if (parsed && !seenIds.has(parsed.id)) {
|
|
429
|
+
seenIds.add(parsed.id);
|
|
430
|
+
busTasks.push(parsed);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} catch {}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// If bus has no tasks yet, but STATUS.md exists: seed the bus!
|
|
437
|
+
if (busTasks.length === 0 && statusFile && fs.existsSync(statusFile)) {
|
|
438
|
+
try {
|
|
439
|
+
const content = fs.readFileSync(statusFile, "utf8");
|
|
440
|
+
const seedTasks = parseStatusMd(content);
|
|
441
|
+
for (const task of seedTasks) {
|
|
442
|
+
const destDir = resolveStageDir(busDir, task.status);
|
|
443
|
+
const taskPath = path.join(busDir, destDir, `${task.id}.md`);
|
|
444
|
+
fs.writeFileSync(taskPath, serializeTaskFile(task), "utf8");
|
|
445
|
+
busTasks.push({ ...task, filePath: taskPath, source: "bus" });
|
|
446
|
+
seenIds.add(task.id);
|
|
447
|
+
}
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.warn(`[board] Failed to seed bus from ${statusFile}: ${err.message}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Also check if any custom overlay tasks exist and migrate them into bus
|
|
454
|
+
const customTasks = loadCustomTasks(amqRoot);
|
|
455
|
+
for (const ct of customTasks) {
|
|
456
|
+
if (!seenIds.has(ct.id)) {
|
|
457
|
+
const destDir = resolveStageDir(busDir, ct.status);
|
|
458
|
+
const taskPath = path.join(busDir, destDir, `${ct.id}.md`);
|
|
459
|
+
try {
|
|
460
|
+
fs.writeFileSync(taskPath, serializeTaskFile(ct), "utf8");
|
|
461
|
+
busTasks.push({ ...ct, filePath: taskPath, source: "bus" });
|
|
462
|
+
seenIds.add(ct.id);
|
|
463
|
+
} catch {}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const columns = {
|
|
468
|
+
backlog: [],
|
|
469
|
+
in_progress: [],
|
|
470
|
+
blocked: [],
|
|
471
|
+
done: [],
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const ownersSet = new Set();
|
|
475
|
+
|
|
476
|
+
for (const task of busTasks) {
|
|
477
|
+
const col = columns[task.status] ? task.status : "backlog";
|
|
478
|
+
columns[col].push(task);
|
|
479
|
+
if (task.owner) ownersSet.add(task.owner);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
columns,
|
|
484
|
+
stats: {
|
|
485
|
+
total: busTasks.length,
|
|
486
|
+
backlog: columns.backlog.length,
|
|
487
|
+
in_progress: columns.in_progress.length,
|
|
488
|
+
blocked: columns.blocked.length,
|
|
489
|
+
done: columns.done.length,
|
|
490
|
+
},
|
|
491
|
+
owners: Array.from(ownersSet).sort(),
|
|
492
|
+
busDir,
|
|
493
|
+
statusFilePath: statusFile || null,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Dispatch automailing notification via AMQ for task life-cycle events
|
|
499
|
+
*/
|
|
500
|
+
export function notifyTaskEvent(amqRoot, eventType, task, opts = {}) {
|
|
501
|
+
if (!amqRoot || !task) return { ok: false, error: "Missing amqRoot or task" };
|
|
502
|
+
|
|
503
|
+
const sender = opts.from || "coordinator";
|
|
504
|
+
let to = [];
|
|
505
|
+
let subject = "";
|
|
506
|
+
let body = "";
|
|
507
|
+
let priority = "normal";
|
|
508
|
+
|
|
509
|
+
switch (eventType) {
|
|
510
|
+
case "assigned": {
|
|
511
|
+
to = [task.owner || "coordinator"];
|
|
512
|
+
subject = `[AGboard] [ASSIGNED] ${task.title}`;
|
|
513
|
+
body = [
|
|
514
|
+
`You have been assigned a task on AGboard:`,
|
|
515
|
+
``,
|
|
516
|
+
`• Task: ${task.title}`,
|
|
517
|
+
`• ID: ${task.id}`,
|
|
518
|
+
`• Assigned Owner: ${task.owner}`,
|
|
519
|
+
`• Status: ${task.status}`,
|
|
520
|
+
task.description ? `• Description: ${task.description}` : "",
|
|
521
|
+
``,
|
|
522
|
+
`To synchronize without editing STATUS.md directly:`,
|
|
523
|
+
` herdr-amq task claim ${task.id} --me ${task.owner}`,
|
|
524
|
+
` herdr-amq task done ${task.id} --me ${task.owner} --proof "<proof>"`,
|
|
525
|
+
` herdr-amq task block ${task.id} --me ${task.owner} --reason "<reason>"`,
|
|
526
|
+
].filter(Boolean).join("\n");
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
case "claimed": {
|
|
531
|
+
to = ["coordinator"];
|
|
532
|
+
subject = `[AGboard] [CLAIMED] ${task.title}`;
|
|
533
|
+
body = [
|
|
534
|
+
`Task claimed by ${sender}:`,
|
|
535
|
+
``,
|
|
536
|
+
`• Task: ${task.title}`,
|
|
537
|
+
`• ID: ${task.id}`,
|
|
538
|
+
`• Status: in_progress`,
|
|
539
|
+
task.description ? `• Details: ${task.description}` : "",
|
|
540
|
+
``,
|
|
541
|
+
`Track or complete via:`,
|
|
542
|
+
` herdr-amq task done ${task.id} --me ${sender} --proof "<evidence>"`,
|
|
543
|
+
].filter(Boolean).join("\n");
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
case "blocked": {
|
|
548
|
+
to = ["coordinator"];
|
|
549
|
+
priority = "urgent";
|
|
550
|
+
subject = `[AGboard] [BLOCKED] ${task.title}`;
|
|
551
|
+
body = [
|
|
552
|
+
`⚠️ TASK BLOCKED by ${sender}:`,
|
|
553
|
+
``,
|
|
554
|
+
`• Task: ${task.title}`,
|
|
555
|
+
`• ID: ${task.id}`,
|
|
556
|
+
`• Reason: ${opts.reason || task.description || "Unspecified blocker"}`,
|
|
557
|
+
``,
|
|
558
|
+
`Needs coordination / unblock review.`,
|
|
559
|
+
].filter(Boolean).join("\n");
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
case "done": {
|
|
564
|
+
to = ["coordinator"];
|
|
565
|
+
subject = `[AGboard] [COMPLETED] ${task.title}`;
|
|
566
|
+
body = [
|
|
567
|
+
`✅ Task completed by ${sender}:`,
|
|
568
|
+
``,
|
|
569
|
+
`• Task: ${task.title}`,
|
|
570
|
+
`• ID: ${task.id}`,
|
|
571
|
+
opts.proof ? `• Evidence / Proof: ${opts.proof}` : "",
|
|
572
|
+
task.description ? `• Details: ${task.description}` : "",
|
|
573
|
+
].filter(Boolean).join("\n");
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
default:
|
|
578
|
+
return { ok: false, error: `Unknown eventType: ${eventType}` };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return sendAmqMessage(amqRoot, {
|
|
582
|
+
from: sender,
|
|
583
|
+
to,
|
|
584
|
+
subject,
|
|
585
|
+
body,
|
|
586
|
+
thread: `agboard/${task.id}`,
|
|
587
|
+
priority,
|
|
588
|
+
kind: eventType === "blocked" ? "status" : "todo",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Add a new task card directly to the global bus stage directory (backlog, doing, blocked, done)
|
|
594
|
+
*/
|
|
595
|
+
export function addBoardTask(
|
|
596
|
+
repoRoot,
|
|
597
|
+
amqRoot,
|
|
598
|
+
{ title, owner = "coordinator", status = "backlog", description = "", notify, from } = {},
|
|
599
|
+
opts = {}
|
|
600
|
+
) {
|
|
601
|
+
if (!title || !title.trim()) {
|
|
602
|
+
return { ok: false, error: "Task title is required" };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const busDir = getBusDirectory(repoRoot, amqRoot);
|
|
606
|
+
ensureBusDirectories(busDir);
|
|
607
|
+
|
|
608
|
+
const cleanOwner = canonicalizeOwner(owner);
|
|
609
|
+
const cleanStatus = ["backlog", "in_progress", "doing", "blocked", "done"].includes(status)
|
|
610
|
+
? (status === "doing" ? "in_progress" : status)
|
|
611
|
+
: "backlog";
|
|
612
|
+
const id = `task_${Date.now()}_${crypto.randomBytes(3).toString("hex")}`;
|
|
613
|
+
const now = new Date().toISOString();
|
|
614
|
+
|
|
615
|
+
const newTask = {
|
|
616
|
+
id,
|
|
617
|
+
title: title.trim(),
|
|
618
|
+
owner: cleanOwner,
|
|
619
|
+
status: cleanStatus,
|
|
620
|
+
description: description.trim(),
|
|
621
|
+
created: now,
|
|
622
|
+
updated: now,
|
|
623
|
+
thread: `agboard/${id}`,
|
|
624
|
+
source: "bus",
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const stageDir = resolveStageDir(busDir, cleanStatus);
|
|
628
|
+
const filePath = path.join(busDir, stageDir, `${id}.md`);
|
|
629
|
+
fs.writeFileSync(filePath, serializeTaskFile(newTask), "utf8");
|
|
630
|
+
newTask.filePath = filePath;
|
|
631
|
+
|
|
632
|
+
const shouldNotify = (notify !== undefined ? notify : opts.notify) ?? true;
|
|
633
|
+
const sender = from || opts.from || "coordinator";
|
|
634
|
+
|
|
635
|
+
if (shouldNotify && amqRoot && newTask.owner && newTask.owner !== "coordinator") {
|
|
636
|
+
try {
|
|
637
|
+
notifyTaskEvent(amqRoot, "assigned", newTask, { from: sender });
|
|
638
|
+
} catch {}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return { ok: true, task: newTask };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Update a task's status / stage or attributes.
|
|
646
|
+
* Atomically moves the task file between stage directories (backlog, doing, blocked, done).
|
|
647
|
+
*/
|
|
648
|
+
export function updateBoardTask(repoRoot, amqRoot, taskId, updates = {}, opts = {}) {
|
|
649
|
+
if (!taskId) return { ok: false, error: "taskId is required" };
|
|
650
|
+
|
|
651
|
+
const busDir = getBusDirectory(repoRoot, amqRoot);
|
|
652
|
+
ensureBusDirectories(busDir);
|
|
653
|
+
|
|
654
|
+
const stageDirs = ["backlog", "doing", "in_progress", "blocked", "done"];
|
|
655
|
+
let existingPath = null;
|
|
656
|
+
let currentStage = "backlog";
|
|
657
|
+
let existingTask = null;
|
|
658
|
+
|
|
659
|
+
for (const s of stageDirs) {
|
|
660
|
+
const candidate = path.join(busDir, s, `${taskId}.md`);
|
|
661
|
+
if (fs.existsSync(candidate)) {
|
|
662
|
+
existingPath = candidate;
|
|
663
|
+
currentStage = s === "doing" ? "in_progress" : s;
|
|
664
|
+
existingTask = parseTaskFile(candidate, currentStage);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (!existingTask) {
|
|
670
|
+
const board = loadBoard(repoRoot, amqRoot);
|
|
671
|
+
for (const [col, list] of Object.entries(board.columns)) {
|
|
672
|
+
const match = list.find((t) => t.id === taskId);
|
|
673
|
+
if (match) {
|
|
674
|
+
existingTask = match;
|
|
675
|
+
currentStage = col;
|
|
676
|
+
existingPath = match.filePath || null;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (!existingTask) {
|
|
683
|
+
return { ok: false, error: "Task not found" };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const oldTask = { ...existingTask };
|
|
687
|
+
const targetStatus = updates.status
|
|
688
|
+
? (updates.status === "doing" ? "in_progress" : updates.status)
|
|
689
|
+
: existingTask.status;
|
|
690
|
+
|
|
691
|
+
const now = new Date().toISOString();
|
|
692
|
+
const updatedTask = {
|
|
693
|
+
...existingTask,
|
|
694
|
+
...updates,
|
|
695
|
+
owner: updates.owner ? canonicalizeOwner(updates.owner) : existingTask.owner,
|
|
696
|
+
status: targetStatus,
|
|
697
|
+
updated: now,
|
|
698
|
+
source: "bus",
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const destStageDir = resolveStageDir(busDir, targetStatus);
|
|
702
|
+
const newFilePath = path.join(busDir, destStageDir, `${taskId}.md`);
|
|
703
|
+
|
|
704
|
+
fs.writeFileSync(newFilePath, serializeTaskFile(updatedTask), "utf8");
|
|
705
|
+
updatedTask.filePath = newFilePath;
|
|
706
|
+
|
|
707
|
+
if (existingPath && existingPath !== newFilePath && fs.existsSync(existingPath)) {
|
|
708
|
+
try {
|
|
709
|
+
fs.unlinkSync(existingPath);
|
|
710
|
+
} catch {}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const shouldNotify = (updates.notify !== undefined ? updates.notify : opts.notify) ?? true;
|
|
714
|
+
const sender = updates.from || opts.from || updatedTask.owner || "coordinator";
|
|
715
|
+
|
|
716
|
+
if (shouldNotify && amqRoot && oldTask) {
|
|
717
|
+
try {
|
|
718
|
+
if (updates.owner && updates.owner !== oldTask.owner && updates.owner !== "coordinator") {
|
|
719
|
+
notifyTaskEvent(amqRoot, "assigned", updatedTask, { from: sender });
|
|
720
|
+
} else if (updatedTask.status === "in_progress" && oldTask.status !== "in_progress") {
|
|
721
|
+
notifyTaskEvent(amqRoot, "claimed", updatedTask, { from: sender });
|
|
722
|
+
} else if (updatedTask.status === "blocked" && oldTask.status !== "blocked") {
|
|
723
|
+
notifyTaskEvent(amqRoot, "blocked", updatedTask, { from: sender, reason: updates.reason || opts.reason || updates.description });
|
|
724
|
+
} else if (updatedTask.status === "done" && oldTask.status !== "done") {
|
|
725
|
+
notifyTaskEvent(amqRoot, "done", updatedTask, { from: sender, proof: updates.proof || opts.proof || updates.description });
|
|
726
|
+
}
|
|
727
|
+
} catch {}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return { ok: true, taskId, updates, task: updatedTask };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Delete a task directly from the global bus
|
|
735
|
+
*/
|
|
736
|
+
export function deleteBoardTask(repoRoot, amqRoot, taskId) {
|
|
737
|
+
if (!taskId) return { ok: false, error: "taskId is required" };
|
|
738
|
+
|
|
739
|
+
const busDir = getBusDirectory(repoRoot, amqRoot);
|
|
740
|
+
const stageDirs = ["backlog", "doing", "in_progress", "blocked", "done"];
|
|
741
|
+
let deleted = false;
|
|
742
|
+
|
|
743
|
+
for (const s of stageDirs) {
|
|
744
|
+
const candidate = path.join(busDir, s, `${taskId}.md`);
|
|
745
|
+
if (fs.existsSync(candidate)) {
|
|
746
|
+
try {
|
|
747
|
+
fs.unlinkSync(candidate);
|
|
748
|
+
deleted = true;
|
|
749
|
+
} catch {}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const customTasks = loadCustomTasks(amqRoot);
|
|
754
|
+
const filtered = customTasks.filter((t) => t.id !== taskId);
|
|
755
|
+
if (filtered.length !== customTasks.length) {
|
|
756
|
+
saveCustomTasks(amqRoot, filtered);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return { ok: true, taskId, deleted };
|
|
760
|
+
}
|