@hjr15/blaze-board 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +141 -0
  2. package/LICENSE +21 -0
  3. package/README.md +111 -0
  4. package/package.json +38 -0
  5. package/scripts/cli.mjs +31 -0
  6. package/scripts/commit-or-queue.mjs +22 -0
  7. package/scripts/commit-runner.mjs +40 -0
  8. package/scripts/config.mjs +135 -0
  9. package/scripts/edit-runner.mjs +18 -0
  10. package/scripts/edit.mjs +85 -0
  11. package/scripts/event-bus.mjs +16 -0
  12. package/scripts/log-runner.mjs +38 -0
  13. package/scripts/log.mjs +44 -0
  14. package/scripts/loops/groomer.mjs +215 -0
  15. package/scripts/migrate/audit.mjs +92 -0
  16. package/scripts/migrate/jira-client.mjs +26 -0
  17. package/scripts/migrate/jira-import.mjs +96 -0
  18. package/scripts/migrate/map.mjs +110 -0
  19. package/scripts/migrate/merge.mjs +103 -0
  20. package/scripts/migrate/normalize.mjs +60 -0
  21. package/scripts/migrate/report.mjs +67 -0
  22. package/scripts/migrate/restructure.mjs +49 -0
  23. package/scripts/migrate-runner.mjs +51 -0
  24. package/scripts/model/.gitkeep +0 -0
  25. package/scripts/model/ids.mjs +32 -0
  26. package/scripts/model/index.mjs +63 -0
  27. package/scripts/model/move-plan.mjs +25 -0
  28. package/scripts/model/rollup.mjs +55 -0
  29. package/scripts/model/rules.mjs +64 -0
  30. package/scripts/model/schema.mjs +30 -0
  31. package/scripts/model/ticket.mjs +136 -0
  32. package/scripts/model/time.mjs +38 -0
  33. package/scripts/model/workflows.mjs +54 -0
  34. package/scripts/move-runner.mjs +18 -0
  35. package/scripts/move.mjs +56 -0
  36. package/scripts/new-runner.mjs +43 -0
  37. package/scripts/new.mjs +54 -0
  38. package/scripts/pending-ledger.mjs +36 -0
  39. package/scripts/reconcile.mjs +181 -0
  40. package/scripts/reindex.mjs +22 -0
  41. package/scripts/resolve-runner.mjs +17 -0
  42. package/scripts/resolve.mjs +21 -0
  43. package/scripts/rollup-runner.mjs +53 -0
  44. package/scripts/serve-commit.mjs +18 -0
  45. package/scripts/serve.mjs +658 -0
  46. package/scripts/supervisor.mjs +192 -0
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // supervisor.mjs — boots the Blaze app: serves the board + activity feed and runs
3
+ // the loops. All loop effects go through git on the board repo.
4
+ import { createServer } from "node:http";
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { loadConfig, listProjects, resolveRoots } from "./config.mjs";
9
+ import { pageHtml, contentHash } from "./serve.mjs";
10
+ import { createBus } from "./event-bus.mjs";
11
+ import { reconcile } from "./reconcile.mjs";
12
+ import { groomOnce } from "./loops/groomer.mjs";
13
+ import { execFileSync } from "node:child_process";
14
+
15
+ const today = () => new Date().toISOString().slice(0, 10);
16
+
17
+ // ---- the control strip + activity feed injected into the board page ----
18
+ const CONTROLS_HTML = `
19
+ <section id="blaze-app">
20
+ <div class="ctl-strip">
21
+ <strong>Loops</strong>
22
+ <span class="ctl-group" data-loop="reconcile">reconcile
23
+ <button data-act="start">▶</button><button data-act="stop">⏸</button><button data-act="run">run</button>
24
+ </span>
25
+ <span class="ctl-group" data-loop="groomer">groomer
26
+ <button data-act="start">▶</button><button data-act="stop">⏸</button><button data-act="run">run</button>
27
+ </span>
28
+ <span id="conn" class="sub">● live</span>
29
+ </div>
30
+ <ol id="activity" class="activity"></ol>
31
+ </section>
32
+ <style>
33
+ #blaze-app { padding: 0 20px 8px; }
34
+ .ctl-strip { display:flex; align-items:center; gap:12px; flex-wrap:wrap;
35
+ padding:8px 10px; background:#161b22; border:1px solid #21262d; border-radius:8px; }
36
+ .ctl-group { color:#adbac7; font-size:12px; }
37
+ .ctl-strip button { appearance:none; border:0; cursor:pointer; font:inherit; font-size:11px;
38
+ margin-left:3px; padding:2px 8px; border-radius:6px; color:var(--charcoal); background:var(--blaze-orange); }
39
+ .ctl-strip button:hover { background:var(--blaze-red); color:var(--neutral); }
40
+ #conn { margin-left:auto; color:var(--blaze-orange); }
41
+ .activity { list-style:none; margin:8px 0 0; padding:0; max-height:180px; overflow:auto;
42
+ font-size:12px; font-family:ui-monospace, monospace; }
43
+ .activity li { padding:4px 8px; border-bottom:1px solid #21262d; color:#adbac7; display:flex; gap:8px; }
44
+ .activity .revert { margin-left:auto; cursor:pointer; color:var(--blaze-orange); background:none; border:0; font:inherit; }
45
+ </style>`;
46
+
47
+ const ACTIVITY_SCRIPT = `
48
+ <script>
49
+ const act = document.getElementById("activity");
50
+ const conn = document.getElementById("conn");
51
+ function line(e) {
52
+ const li = document.createElement("li");
53
+ let txt = e.type;
54
+ if (e.type === "reconcile") txt = e.id + ": " + e.from + " → " + e.to;
55
+ else if (e.type === "groom") txt = e.error ? ("groom " + e.id + " failed: " + e.error)
56
+ : e.noop ? ("groom " + e.id + ": no change") : ("groom " + e.id + " (" + (e.files||[]).length + " file)");
57
+ else if (e.type === "status") txt = e.loop + " " + e.state;
58
+ else if (e.type === "error") txt = (e.loop||"") + " error: " + e.message;
59
+ li.innerHTML = "<span>" + (e.ts||"") + "</span><span>" + txt + "</span>";
60
+ if (e.type === "groom" && e.sha) {
61
+ const b = document.createElement("button");
62
+ b.className = "revert"; b.textContent = "↩ revert";
63
+ b.onclick = () => fetch("/control/revert", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ sha: e.sha }) });
64
+ li.appendChild(b);
65
+ }
66
+ act.prepend(li);
67
+ while (act.children.length > 100) act.removeChild(act.lastChild);
68
+ }
69
+ const es = new EventSource("/events");
70
+ es.onmessage = (m) => { try { line(JSON.parse(m.data)); } catch {} };
71
+ es.onerror = () => { conn.textContent = "● offline"; };
72
+ es.onopen = () => { conn.textContent = "● live"; };
73
+ document.querySelectorAll(".ctl-group").forEach((g) =>
74
+ g.querySelectorAll("button").forEach((b) =>
75
+ b.addEventListener("click", () =>
76
+ fetch("/control/" + g.dataset.loop + "/" + b.dataset.act, { method: "POST" }))));
77
+ </script>`;
78
+
79
+ export function createApp(cfg, { root = resolveRoots().dataRoot } = {}) {
80
+ const bus = createBus();
81
+
82
+ const loops = { reconcile: { timer: null, busy: false }, groomer: { timer: null, busy: false } };
83
+
84
+ function runReconcile() {
85
+ if (!listProjects(cfg).length || loops.reconcile.busy) return;
86
+ loops.reconcile.busy = true;
87
+ try {
88
+ const r = reconcile({ fetch: true, commit: true, push: true });
89
+ if (r && r.ok && r.changes) {
90
+ for (const c of r.changes) bus.publish({ type: "reconcile", id: c.id, from: c.from, to: c.to, moved: c.moved, ts: today() });
91
+ } else if (r && !r.ok) {
92
+ bus.publish({ type: "error", loop: "reconcile", message: r.error, ts: today() });
93
+ }
94
+ } catch (e) {
95
+ bus.publish({ type: "error", loop: "reconcile", message: e.message, ts: today() });
96
+ } finally {
97
+ loops.reconcile.busy = false;
98
+ }
99
+ }
100
+
101
+ function runGroomer() {
102
+ if (loops.groomer.busy) return;
103
+ loops.groomer.busy = true;
104
+ try {
105
+ let agentsMd = "";
106
+ try { agentsMd = readFileSync(join(root, "AGENTS.md"), "utf8"); } catch {}
107
+ const evt = groomOnce({ root, cfg, agentsMd, today: today() });
108
+ if (evt) bus.publish(evt);
109
+ } catch (e) {
110
+ bus.publish({ type: "error", loop: "groomer", message: e.message, ts: today() });
111
+ } finally {
112
+ loops.groomer.busy = false;
113
+ }
114
+ }
115
+
116
+ function startLoop(name) {
117
+ const fn = name === "reconcile" ? runReconcile : runGroomer;
118
+ if (loops[name].timer) return;
119
+ fn();
120
+ loops[name].timer = setInterval(fn, cfg.loops[name].intervalSec * 1000);
121
+ bus.publish({ type: "status", loop: name, state: "started", ts: today() });
122
+ }
123
+
124
+ function stopLoop(name) {
125
+ if (loops[name].timer) { clearInterval(loops[name].timer); loops[name].timer = null; }
126
+ bus.publish({ type: "status", loop: name, state: "stopped", ts: today() });
127
+ }
128
+
129
+ const server = createServer((req, res) => {
130
+ if (req.url === "/api/hash") {
131
+ res.writeHead(200, { "content-type": "text/plain" });
132
+ res.end(contentHash());
133
+ return;
134
+ }
135
+ if (req.url === "/events") {
136
+ res.writeHead(200, {
137
+ "content-type": "text/event-stream",
138
+ "cache-control": "no-cache",
139
+ connection: "keep-alive",
140
+ });
141
+ res.write(": connected\n\n");
142
+ const off = bus.subscribe((evt) => res.write(`data: ${JSON.stringify(evt)}\n\n`));
143
+ const hb = setInterval(() => res.write(": hb\n\n"), 15000);
144
+ req.on("close", () => { clearInterval(hb); off(); });
145
+ return;
146
+ }
147
+ if (req.url === "/" || req.url === "") {
148
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
149
+ res.end(pageHtml({ afterHeader: CONTROLS_HTML, beforeBodyEnd: ACTIVITY_SCRIPT }));
150
+ return;
151
+ }
152
+ const ctl = req.url && req.url.match(/^\/control\/(reconcile|groomer)\/(start|stop|run)$/);
153
+ if (ctl && req.method === "POST") {
154
+ const [, name, action] = ctl;
155
+ if (action === "start") startLoop(name);
156
+ else if (action === "stop") stopLoop(name);
157
+ else (name === "reconcile" ? runReconcile : runGroomer)();
158
+ res.writeHead(204); res.end();
159
+ return;
160
+ }
161
+ if (req.url === "/control/revert" && req.method === "POST") {
162
+ let body = "";
163
+ req.on("data", (c) => (body += c));
164
+ req.on("end", () => {
165
+ try {
166
+ const { sha } = JSON.parse(body || "{}");
167
+ execFileSync("git", ["-C", root, "revert", "--no-edit", sha]);
168
+ bus.publish({ type: "status", loop: "groomer", state: `reverted ${sha.slice(0, 7)}`, ts: today() });
169
+ } catch (e) {
170
+ bus.publish({ type: "error", loop: "groomer", message: `revert failed: ${e.message}`, ts: today() });
171
+ }
172
+ res.writeHead(204); res.end();
173
+ });
174
+ return;
175
+ }
176
+ res.writeHead(404, { "content-type": "text/plain" });
177
+ res.end("not found");
178
+ });
179
+
180
+ return { server, bus, startLoop, stopLoop, runReconcile, runGroomer };
181
+ }
182
+
183
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
184
+ const cfg = loadConfig({ root: resolveRoots().dataRoot });
185
+ const app = createApp(cfg);
186
+ const port = Number(process.env.PORT) || cfg.port;
187
+ app.server.listen(port, "127.0.0.1", () => {
188
+ console.log(`${cfg.boardTitle} app → http://localhost:${port}`);
189
+ if (cfg.loops.reconcile.enabled && listProjects(cfg).length) app.startLoop("reconcile");
190
+ if (cfg.loops.groomer.enabled) app.startLoop("groomer");
191
+ });
192
+ }