conductor-board 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -6,10 +6,10 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <meta name="theme-color" content="#0a0a0f" />
8
8
  <title>Agent Conductor — Board</title>
9
- <script type="module" crossorigin src="./assets/index-Dauby4vm.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-DV9PNUB3.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="./assets/motion-Dmvx5jlk.js">
11
11
  <link rel="modulepreload" crossorigin href="./assets/yaml-NA7d4LV6.js">
12
- <link rel="stylesheet" crossorigin href="./assets/index-nvl4ljRP.css">
12
+ <link rel="stylesheet" crossorigin href="./assets/index-D0azgMCk.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-board",
3
- "version": "1.3.0",
3
+ "version": "2.1.0",
4
4
  "description": "Gated workflows for AI agents — live Kanban board included",
5
5
  "license": "MIT",
6
6
  "author": "mettafive",
package/server/server.js CHANGED
@@ -200,6 +200,22 @@ function archiveIfDone(historyDir, snapshot, archived) {
200
200
  const status = snapshot.status;
201
201
  if (!status || (status.status !== "done" && status.status !== "failed")) return null;
202
202
 
203
+ // Mandatory suggestions: a successful run must capture what it learned before
204
+ // it's saved to history. A done run with no suggestions doesn't archive (the
205
+ // loop has to feed itself) — failed runs are exempt.
206
+ if (status.status === "done") {
207
+ const sug = status.suggestions;
208
+ if (!Array.isArray(sug) || sug.length === 0) {
209
+ if (!status._noSuggestionsWarned) {
210
+ console.warn(
211
+ `[conductor-board] "${status.workflow || "workflow"}" is done but has no suggestions — not archiving until it captures 3–5. (spec §9.2)`,
212
+ );
213
+ status._noSuggestionsWarned = true;
214
+ }
215
+ return null;
216
+ }
217
+ }
218
+
203
219
  const runId =
204
220
  status.run_id ||
205
221
  (status.started_at ? safeId(status.started_at).replace(/-\d+Z$/, "") : null);
@@ -240,6 +256,113 @@ function archiveIfDone(historyDir, snapshot, archived) {
240
256
  }
241
257
  }
242
258
 
259
+ // ---------------------------------------------------------------------------
260
+ // Insights ledger — a persistent, accumulating memory per workflow.
261
+ //
262
+ // insights.json is the source of truth (structured, with apply/dismiss state);
263
+ // insights.md is a human- and agent-readable view regenerated on every change.
264
+ // Suggestions from each completed run are merged in (deduped) as `open`; the
265
+ // board lets the user apply or dismiss them, which updates both files.
266
+ // ---------------------------------------------------------------------------
267
+
268
+ const insightsPaths = (wf) => ({
269
+ json: path.join(wf.dir, "insights.json"),
270
+ md: path.join(wf.dir, "insights.md"),
271
+ });
272
+
273
+ const insightKey = (s) =>
274
+ `${s.type || "note"}::${s.step || ""}::${String(s.title || "")
275
+ .trim()
276
+ .toLowerCase()
277
+ .replace(/\s+/g, " ")}`;
278
+
279
+ function loadInsights(wf) {
280
+ try {
281
+ const l = JSON.parse(fs.readFileSync(insightsPaths(wf).json, "utf8"));
282
+ if (l && Array.isArray(l.items)) return l;
283
+ } catch {
284
+ /* fresh ledger */
285
+ }
286
+ return { workflow: wf.name, items: [] };
287
+ }
288
+
289
+ function renderInsightsMd(ledger) {
290
+ const of = (st) => ledger.items.filter((i) => i.status === st);
291
+ const line = (i) =>
292
+ `- [${i.type}] ${i.title}${i.confidence ? ` · ${i.confidence}` : ""}${
293
+ i.provenance ? ` · _${i.provenance}_` : ""
294
+ }`;
295
+ const sect = (title, items) =>
296
+ `## ${title}\n${items.length ? items.map(line).join("\n") : "_none yet_"}\n`;
297
+ return (
298
+ `# Conductor insights — ${ledger.workflow}\n\n` +
299
+ `_Accumulated across runs. The agent reads this at the start of each run to carry ` +
300
+ `learnings forward; the board appends new insights and tracks which you apply or dismiss._\n\n` +
301
+ sect("Open", of("open")) +
302
+ `\n` +
303
+ sect("Applied", of("applied")) +
304
+ `\n` +
305
+ sect("Dismissed", of("dismissed"))
306
+ );
307
+ }
308
+
309
+ function saveInsights(wf, ledger) {
310
+ const { json, md } = insightsPaths(wf);
311
+ try {
312
+ fs.mkdirSync(wf.dir, { recursive: true });
313
+ fs.writeFileSync(json, JSON.stringify(ledger, null, 2));
314
+ fs.writeFileSync(md, renderInsightsMd(ledger));
315
+ } catch (e) {
316
+ console.warn(`[conductor-board] could not write insights: ${e.message}`);
317
+ }
318
+ }
319
+
320
+ /** Merge a run's suggestions into the ledger as `open`, deduped by key. */
321
+ function mergeInsights(wf, suggestions, runId, at) {
322
+ if (!Array.isArray(suggestions) || suggestions.length === 0) return false;
323
+ const ledger = loadInsights(wf);
324
+ const have = new Set(ledger.items.map(insightKey));
325
+ let added = 0;
326
+ for (const s of suggestions) {
327
+ if (!s || !s.title) continue;
328
+ const key = insightKey(s);
329
+ if (have.has(key)) continue;
330
+ have.add(key);
331
+ ledger.items.push({
332
+ key,
333
+ type: s.type || "note",
334
+ step: s.step,
335
+ title: s.title,
336
+ rationale: s.rationale,
337
+ current: s.current,
338
+ proposed: s.proposed,
339
+ confidence: s.confidence,
340
+ source_heartbeat: s.source_heartbeat,
341
+ status: "open",
342
+ provenance: `run ${runId || "?"}`,
343
+ first_seen_at: at || new Date().toISOString(),
344
+ });
345
+ added += 1;
346
+ }
347
+ if (added) saveInsights(wf, ledger);
348
+ return added > 0;
349
+ }
350
+
351
+ /** Mark ledger items applied/dismissed/open by key. */
352
+ function decideInsights(wf, keys, status) {
353
+ const ledger = loadInsights(wf);
354
+ let changed = 0;
355
+ for (const it of ledger.items) {
356
+ if (keys.includes(it.key) && it.status !== status) {
357
+ it.status = status;
358
+ it.decided_at = new Date().toISOString();
359
+ changed += 1;
360
+ }
361
+ }
362
+ if (changed) saveInsights(wf, ledger);
363
+ return changed;
364
+ }
365
+
243
366
  function serveStatic(req, res) {
244
367
  let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
245
368
  if (urlPath === "/") urlPath = "/index.html";
@@ -363,7 +486,11 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
363
486
  for (const wf of discoverWorkflows(conductorDir, absStatus, explicitConductor)) {
364
487
  const snap = snapshotFor(wf);
365
488
  sendAll("update", snap);
366
- if (archiveIfDone(wf.historyDir, snap, archivedSetFor(wf))) {
489
+ const rec = archiveIfDone(wf.historyDir, snap, archivedSetFor(wf));
490
+ if (rec) {
491
+ if (mergeInsights(wf, rec.snapshot?.status?.suggestions, rec.run_id, rec.completed_at)) {
492
+ sendAll("insights", { workflow: wf.name, ledger: loadInsights(wf) });
493
+ }
367
494
  sendAll("history", { workflow: wf.name, runs: listHistory(wf.historyDir) });
368
495
  }
369
496
  }
@@ -395,6 +522,12 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
395
522
  runs: listHistory(wf.historyDir),
396
523
  })}\n\n`,
397
524
  );
525
+ res.write(
526
+ `event: insights\ndata: ${JSON.stringify({
527
+ workflow: wf.name,
528
+ ledger: loadInsights(wf),
529
+ })}\n\n`,
530
+ );
398
531
  }
399
532
  };
400
533
 
@@ -408,12 +541,53 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
408
541
  const wfs = () => discoverWorkflows(conductorDir, absStatus, explicitConductor);
409
542
 
410
543
  if (url === "/health") {
544
+ const workflows = {};
545
+ for (const wf of wfs()) {
546
+ let kb = 0;
547
+ let beats = 0;
548
+ let st = null;
549
+ try {
550
+ kb = Math.round(fs.statSync(wf.statusPath).size / 1024);
551
+ } catch {
552
+ /* no status file yet */
553
+ }
554
+ try {
555
+ const s = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
556
+ st = s.status ?? "idle";
557
+ beats = Object.values(s.steps || {}).reduce(
558
+ (n, x) => n + (Array.isArray(x && x.heartbeat) ? x.heartbeat.length : 0),
559
+ 0,
560
+ );
561
+ } catch {
562
+ /* unreadable / not started */
563
+ }
564
+ let archiveLines = 0;
565
+ try {
566
+ archiveLines = fs
567
+ .readFileSync(path.join(wf.dir, "heartbeat-archive.jsonl"), "utf8")
568
+ .split("\n")
569
+ .filter(Boolean).length;
570
+ } catch {
571
+ /* no archive */
572
+ }
573
+ workflows[wf.name] = {
574
+ status: st ?? "idle",
575
+ status_file_kb: kb,
576
+ heartbeat_count: beats,
577
+ archive_lines: archiveLines,
578
+ history_count: listHistory(wf.historyDir).length,
579
+ };
580
+ }
411
581
  return json(res, 200, {
412
582
  status: "ok",
413
583
  version: VERSION,
414
- watching: path.relative(process.cwd(), conductorDir) || conductorDir,
584
+ pid: process.pid,
415
585
  port: server.address()?.port ?? null,
416
- workflows: wfs().map((w) => w.name),
586
+ uptime_seconds: Math.round(process.uptime()),
587
+ memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
588
+ watching: path.relative(process.cwd(), conductorDir) || conductorDir,
589
+ workflows,
590
+ sse_connections: clients.size,
417
591
  });
418
592
  }
419
593
 
@@ -448,23 +622,32 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
448
622
  const wf = findWf(decodeURIComponent(m[1]));
449
623
  if (!wf) return json(res, 404, { error: "workflow not found" });
450
624
  readBody(req).then((bodyStr) => {
451
- let ids;
625
+ let payload;
452
626
  try {
453
- ids = JSON.parse(bodyStr || "{}").suggestions;
627
+ payload = JSON.parse(bodyStr || "{}").suggestions;
454
628
  } catch {
455
629
  return json(res, 400, { error: "invalid request body" });
456
630
  }
457
- if (!Array.isArray(ids)) return json(res, 400, { error: "suggestions must be an array" });
631
+ if (!Array.isArray(payload) || payload.length === 0)
632
+ return json(res, 400, { error: "suggestions must be a non-empty array" });
458
633
  if (!wf.conductorPath || !fs.existsSync(wf.conductorPath))
459
634
  return json(res, 400, { error: "no conductor file for this workflow" });
460
635
 
461
- let status;
462
- try {
463
- status = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
464
- } catch {
465
- return json(res, 500, { error: "could not read status.json" });
636
+ // Accept either a list of ids (resolved against the live status) or full
637
+ // suggestion objects — the latter lets a past run's suggestions apply
638
+ // even after the live status has moved on to another run.
639
+ let chosen;
640
+ if (payload.every((x) => typeof x === "string")) {
641
+ let status;
642
+ try {
643
+ status = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
644
+ } catch {
645
+ return json(res, 500, { error: "could not read status.json" });
646
+ }
647
+ chosen = (status.suggestions || []).filter((s) => payload.includes(s.id));
648
+ } else {
649
+ chosen = payload.filter((x) => x && typeof x === "object");
466
650
  }
467
- const chosen = (status.suggestions || []).filter((s) => ids.includes(s.id));
468
651
  if (chosen.length === 0) return json(res, 400, { error: "no matching suggestions" });
469
652
 
470
653
  const original = fs.readFileSync(wf.conductorPath, "utf8");
@@ -498,6 +681,9 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
498
681
  }
499
682
  return json(res, 500, { error: `write failed, rolled back: ${e.message}` });
500
683
  }
684
+ // record the decision in the persistent ledger and push it to clients
685
+ decideInsights(wf, chosen.map(insightKey), "applied");
686
+ sendAll("insights", { workflow: wf.name, ledger: loadInsights(wf) });
501
687
  return json(res, 200, {
502
688
  ok: true,
503
689
  applied: chosen.map((s) => s.id),
@@ -511,6 +697,80 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
511
697
  const wf = findWf(decodeURIComponent(m[1]));
512
698
  return wf ? json(res, 200, snapshotFor(wf)) : json(res, 404, { error: "not found" });
513
699
  }
700
+ if (req.method === "GET" && (m = url.match(/^\/api\/workflow\/([^/]+)\/insights$/))) {
701
+ const wf = findWf(decodeURIComponent(m[1]));
702
+ return wf ? json(res, 200, loadInsights(wf)) : json(res, 404, { error: "not found" });
703
+ }
704
+ if (req.method === "POST" && (m = url.match(/^\/api\/workflow\/([^/]+)\/insights\/decide$/))) {
705
+ const wf = findWf(decodeURIComponent(m[1]));
706
+ if (!wf) return json(res, 404, { error: "not found" });
707
+ readBody(req).then((bodyStr) => {
708
+ let body;
709
+ try {
710
+ body = JSON.parse(bodyStr || "{}");
711
+ } catch {
712
+ return json(res, 400, { error: "invalid request body" });
713
+ }
714
+ const keys = Array.isArray(body.keys) ? body.keys : [];
715
+ if (!["open", "applied", "dismissed"].includes(body.status))
716
+ return json(res, 400, { error: "status must be open | applied | dismissed" });
717
+ const changed = decideInsights(wf, keys, body.status);
718
+ sendAll("insights", { workflow: wf.name, ledger: loadInsights(wf) });
719
+ return json(res, 200, { ok: true, changed });
720
+ });
721
+ return;
722
+ }
723
+
724
+ // human approval — record the decisions into status.json (§4.4)
725
+ if (req.method === "POST" && (m = url.match(/^\/api\/workflow\/([^/]+)\/approve$/))) {
726
+ const wf = findWf(decodeURIComponent(m[1]));
727
+ if (!wf) return json(res, 404, { error: "not found" });
728
+ readBody(req).then((bodyStr) => {
729
+ let body;
730
+ try {
731
+ body = JSON.parse(bodyStr || "{}");
732
+ } catch {
733
+ return json(res, 400, { error: "invalid request body" });
734
+ }
735
+ const stepId = body.step;
736
+ const decisions = Array.isArray(body.decisions) ? body.decisions : [];
737
+ let status;
738
+ try {
739
+ status = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
740
+ } catch {
741
+ return json(res, 500, { error: "could not read status.json" });
742
+ }
743
+ const step = status.steps && status.steps[stepId];
744
+ if (!step) return json(res, 404, { error: `no such step "${stepId}"` });
745
+
746
+ step.approval = step.approval || {};
747
+ const items = Array.isArray(step.approval.items) ? step.approval.items : [];
748
+ const byLabel = new Map(items.map((i) => [i.label, i]));
749
+ for (const d of decisions) {
750
+ if (!d || !d.label) continue;
751
+ const dec = d.decision === "approved" ? "approved" : "rejected";
752
+ if (byLabel.has(d.label)) byLabel.get(d.label).decision = dec;
753
+ else {
754
+ const it = { label: d.label, decision: dec };
755
+ items.push(it);
756
+ byLabel.set(d.label, it);
757
+ }
758
+ }
759
+ step.approval.items = items;
760
+ step.approval.decided_at = new Date().toISOString();
761
+ const anyRejected = items.some((i) => i.decision === "rejected");
762
+ step.approval.resolution = anyRejected ? "rejected" : "approved";
763
+ step.gate = anyRejected ? "rejected" : "approved";
764
+
765
+ try {
766
+ fs.writeFileSync(wf.statusPath, JSON.stringify(status, null, 2));
767
+ } catch (e) {
768
+ return json(res, 500, { error: `write failed: ${e.message}` });
769
+ }
770
+ return json(res, 200, { ok: true, resolution: step.approval.resolution });
771
+ });
772
+ return;
773
+ }
514
774
  if ((m = url.match(/^\/api\/workflow\/([^/]+)\/history$/))) {
515
775
  const wf = findWf(decodeURIComponent(m[1]));
516
776
  return wf ? json(res, 200, listHistory(wf.historyDir)) : json(res, 404, { error: "not found" });
@@ -537,6 +797,11 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
537
797
  }
538
798
 
539
799
  if (url === "/events") {
800
+ if (clients.size >= 5) {
801
+ // cap concurrent streams so stray tabs can't silently pile up memory
802
+ res.writeHead(429, { "content-type": "text/plain" });
803
+ return res.end("Too many board connections (max 5). Close a tab and reload.");
804
+ }
540
805
  res.writeHead(200, {
541
806
  "content-type": "text/event-stream",
542
807
  "cache-control": "no-cache",