@davidbalzan/groundwork-console 0.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/bin/groundwork-console.mjs +47 -0
- package/dist/answered.js +34 -0
- package/dist/auth.js +47 -0
- package/dist/bus.js +79 -0
- package/dist/config.js +40 -0
- package/dist/docwrite.js +66 -0
- package/dist/env.js +23 -0
- package/dist/events.js +170 -0
- package/dist/extensions.js +31 -0
- package/dist/gitinfo.js +69 -0
- package/dist/groundwork.js +34 -0
- package/dist/kitresolve.js +79 -0
- package/dist/knowledge.js +72 -0
- package/dist/mcp.js +106 -0
- package/dist/notify.js +85 -0
- package/dist/provision.js +124 -0
- package/dist/runs.js +46 -0
- package/dist/seam.js +125 -0
- package/dist/seamwrite.js +87 -0
- package/dist/server.js +818 -0
- package/dist/skills.js +48 -0
- package/dist/tmux.js +36 -0
- package/dist/uploads.js +40 -0
- package/groundwork-console.rules.json +85 -0
- package/package.json +49 -0
- package/public/assets/abnfDiagram-N423BO3Z-DPhyKd1N.js +1 -0
- package/public/assets/arc-DmkLmHHi.js +1 -0
- package/public/assets/architecture-TIHT7OUA-MgcjoiPc.js +1 -0
- package/public/assets/architectureDiagram-T3A2C74G-BXFkjpze.js +36 -0
- package/public/assets/array-BifhSqXX.js +1 -0
- package/public/assets/blockDiagram-VBNYF7ZC-Ddhca5b4.js +132 -0
- package/public/assets/c4Diagram-5PPSVZJV-D4M2Oz2j.js +10 -0
- package/public/assets/channel-BAs2g7Wn.js +1 -0
- package/public/assets/chunk-2GRJ4B5K-B2cLnEJA.js +1 -0
- package/public/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/public/assets/chunk-4I5QYGJK-CVuqmZe1.js +1 -0
- package/public/assets/chunk-5RXB4S5H-CAj33Qry.js +231 -0
- package/public/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/public/assets/chunk-6Q2QTUOP-B6kTvnZS.js +88 -0
- package/public/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- package/public/assets/chunk-7Z6QIM7H-BCYMaGkK.js +10 -0
- package/public/assets/chunk-GF5L2VYU-eAfQwP-A.js +206 -0
- package/public/assets/chunk-I66GZJ75-DyWmv43M.js +127 -0
- package/public/assets/chunk-JQJVKLGR-xpwVLHiM.js +156 -0
- package/public/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/public/assets/chunk-KBJHAD2P-BsIw_3BM.js +1 -0
- package/public/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
- package/public/assets/chunk-NSK5VX7P-Dy4p7ORp.js +2 -0
- package/public/assets/chunk-QR6OTTB3-D8k2NFkq.js +62 -0
- package/public/assets/chunk-RYQCIY6F-TbGMiTbW.js +1 -0
- package/public/assets/chunk-UBXNYLIW-By3VqDlw.js +1 -0
- package/public/assets/chunk-W5SLKNZC-B6TSWP8U.js +1 -0
- package/public/assets/chunk-WRU74C26-CqCOF3fo.js +70 -0
- package/public/assets/chunk-XXDRQBXY-B6BeKGFV.js +1 -0
- package/public/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/public/assets/classDiagram-JCYQIIEL-D8vKmZUF.js +1 -0
- package/public/assets/classDiagram-v2-OCEON4UE-D8vKmZUF.js +1 -0
- package/public/assets/cose-bilkent-JH36ORCC-2ieLCSa1.js +1 -0
- package/public/assets/cynefin-VYW2F7L2-BfajhBFb.js +1 -0
- package/public/assets/cynefinDiagram-MW4NZA55-DZFY7WjS.js +62 -0
- package/public/assets/cytoscape.esm-C2x7inlt.js +321 -0
- package/public/assets/dagre-Buvkdvvj.js +1 -0
- package/public/assets/dagre-VZM6K2ZE-DECeuEL2.js +4 -0
- package/public/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/public/assets/diagram-7IWD3JNH-DA6fCDep.js +30 -0
- package/public/assets/diagram-B4RE2ZJO-Dj8QoIQR.js +3 -0
- package/public/assets/diagram-LBJQPF4R-9JYAugOO.js +24 -0
- package/public/assets/diagram-Q27KOJAE-ecTfssa6.js +24 -0
- package/public/assets/diagram-UB23O5K3-DOGgwSDm.js +41 -0
- package/public/assets/dist-DTWyzWtG.js +1 -0
- package/public/assets/ebnfDiagram-BXEA7PRR-BGmWK80p.js +1 -0
- package/public/assets/erDiagram-JOGREHBK-CNl1QvhN.js +85 -0
- package/public/assets/eventmodeling-45OFAUF4-GcvHanur.js +1 -0
- package/public/assets/flowDiagram-UKHOOZJN-DdgmWP6g.js +1 -0
- package/public/assets/ganttDiagram-PKOTCBZU-C01y-MjT.js +292 -0
- package/public/assets/gitGraph-TEB2WS4Q-BW4zQmf5.js +1 -0
- package/public/assets/gitGraphDiagram-DS77QQ5N-CnlU6XMJ.js +106 -0
- package/public/assets/graphlib-DS17s2tU.js +1 -0
- package/public/assets/index-C3zhnM_H.css +2 -0
- package/public/assets/index-DrqxoBmk.js +40 -0
- package/public/assets/info-DKCQHKI2--A5cJC9V.js +1 -0
- package/public/assets/infoDiagram-6WML65LV-Cz2UOAMW.js +2 -0
- package/public/assets/init-C-OQMol4.js +1 -0
- package/public/assets/ishikawaDiagram-WSZJBQD7-B4Iqm47_.js +70 -0
- package/public/assets/journeyDiagram-NVQOT4AX-BU0js6EE.js +139 -0
- package/public/assets/kanban-definition-27J2QSJJ-DmeEPzz0.js +89 -0
- package/public/assets/katex-CXMH3UgJ.js +257 -0
- package/public/assets/line-Dxi6NubD.js +1 -0
- package/public/assets/linear-tama2Fqh.js +1 -0
- package/public/assets/map-BaFkSB1l.js +1 -0
- package/public/assets/mermaid-parser.core-bGhrBY9T.js +7 -0
- package/public/assets/mermaid.core-_OoTZaAK.js +42 -0
- package/public/assets/mindmap-definition-FAOFIHXS-BRJt4F9_.js +96 -0
- package/public/assets/ordinal-BDEzSJ7C.js +1 -0
- package/public/assets/packet-7NZHBO7P-DAZsca60.js +1 -0
- package/public/assets/path-fybaL0A-.js +1 -0
- package/public/assets/pegDiagram-VL7TDLO6-2k0rcz9N.js +1 -0
- package/public/assets/pie-RZYD4A2V-ojW1YX_G.js +1 -0
- package/public/assets/pieDiagram-7S7Q4E2Y-CrrciDrS.js +39 -0
- package/public/assets/quadrantDiagram-CIZ2JOQS-Dj5lMd95.js +7 -0
- package/public/assets/radar-I7S5WNFK-flsKqrK_.js +1 -0
- package/public/assets/railroad-3IZDKUUU-BX9H_OcC.js +1 -0
- package/public/assets/railroad-abnf-AHOZXSZD-DD8mz__c.js +1 -0
- package/public/assets/railroad-ebnf-EBAXGLYW-CWer5P7V.js +1 -0
- package/public/assets/railroad-peg-LSFZ7HO6-DDrRJYYw.js +1 -0
- package/public/assets/railroadDiagram-AXF67PYL-BRNOv6Sh.js +1 -0
- package/public/assets/requirementDiagram-LRYGKXZP-BqJbwH3b.js +84 -0
- package/public/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/public/assets/sankeyDiagram-W5VNT64P-BE7fRCAN.js +40 -0
- package/public/assets/sequenceDiagram-SI44F4Z6-Cwt-L1ko.js +162 -0
- package/public/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/public/assets/src-Bc2f8joo.js +1 -0
- package/public/assets/stateDiagram-OKZ733FA-etLHG0qi.js +1 -0
- package/public/assets/stateDiagram-v2-UEYNNEHI-Crgtg4pC.js +1 -0
- package/public/assets/swimlanes-SLNWSIFB-B06NxeYI.js +2 -0
- package/public/assets/swimlanesDiagram-ULZ7WXOC-C8S-XAyy.js +8 -0
- package/public/assets/timeline-definition-Z64GVDOM-BihNR1uq.js +120 -0
- package/public/assets/treeView-QDETBFTQ-CImTfOYf.js +1 -0
- package/public/assets/treemap-6X3UGDF4-DBRjbghn.js +1 -0
- package/public/assets/vennDiagram-T6HMQDX7-HcbmXJRd.js +34 -0
- package/public/assets/wardley-OPB4EBWU-xY5d-6YU.js +1 -0
- package/public/assets/wardleyDiagram-T6FBY63Y-B6KN3TYi.js +78 -0
- package/public/assets/xychartDiagram-ELKLHX3M-AAW8Pwrl.js +7 -0
- package/public/favicon.svg +1 -0
- package/public/fonts/JetBrainsMono-400.woff2 +0 -0
- package/public/fonts/JetBrainsMono-500.woff2 +0 -0
- package/public/fonts/JetBrainsMono-600.woff2 +0 -0
- package/public/fonts/SourceSans3-400.woff2 +0 -0
- package/public/fonts/SourceSans3-500.woff2 +0 -0
- package/public/fonts/SourceSans3-600.woff2 +0 -0
- package/public/fonts/SourceSans3-700.woff2 +0 -0
- package/public/fonts/SourceSerif4-600.woff2 +0 -0
- package/public/icons.svg +24 -0
- package/public/index.html +14 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* groundwork-console — one command: starts the API + serves the built UI on one port, prints the sign-in link, opens the browser.
|
|
3
|
+
Flags: --port 8790 · --host 127.0.0.1 · --no-open · --no-auth · --config <file>
|
|
4
|
+
Subcommands: token (print the token) · doctor (kit requires + paths) · config (print config path) */
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
await import("../dist/env.js"); // legacy KIT_CONSOLE_* env + ~/.kit-console → ~/.groundwork-console move
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const flag = (k, d) => { const i = args.indexOf(k); return i >= 0 ? (args[i + 1] ?? true) : d; };
|
|
14
|
+
const has = (k) => args.includes(k);
|
|
15
|
+
const sub = args.find((a) => !a.startsWith("--") && !["true", "false"].includes(a) && !/^\d+$/.test(a) && !args[args.indexOf(a) - 1]?.startsWith("--"));
|
|
16
|
+
|
|
17
|
+
const port = String(flag("--port", process.env.PORT ?? "8790"));
|
|
18
|
+
const host = String(flag("--host", process.env.GROUNDWORK_CONSOLE_HOST ?? "127.0.0.1"));
|
|
19
|
+
if (flag("--config")) process.env.GROUNDWORK_CONSOLE_CONFIG = String(flag("--config"));
|
|
20
|
+
process.env.PORT = port; process.env.GROUNDWORK_CONSOLE_HOST = host;
|
|
21
|
+
if (has("--no-auth")) process.env.GROUNDWORK_CONSOLE_AUTH = "off";
|
|
22
|
+
if (!process.env.GROUNDWORK_CONSOLE_CONFIG && !existsSync(path.resolve(process.cwd(), "groundwork-console.config.json"))) process.env.GROUNDWORK_CONSOLE_HOME_CONFIG = "1"; // packaged: prefer ~/.groundwork-console/config.json
|
|
23
|
+
|
|
24
|
+
const pkg = JSON.parse(readFileSync(path.join(here, "../package.json"), "utf8"));
|
|
25
|
+
const tokenFile = path.join(homedir(), ".groundwork-console", "token");
|
|
26
|
+
|
|
27
|
+
if (sub === "token") { const { loadToken } = await import("../dist/auth.js"); console.log(loadToken()); process.exit(0); }
|
|
28
|
+
if (sub === "config") { const { CONFIG_FILE } = await import("../dist/config.js"); console.log(CONFIG_FILE); process.exit(0); }
|
|
29
|
+
if (sub === "doctor") {
|
|
30
|
+
const { requiresReport } = await import("../dist/kitresolve.js"); const { loadConfig, CONFIG_FILE } = await import("../dist/config.js");
|
|
31
|
+
const cfg = loadConfig(); const r = requiresReport(cfg.kitPath);
|
|
32
|
+
console.log(`groundwork-console ${pkg.version} · config ${CONFIG_FILE} · store ${cfg.coordDir}`);
|
|
33
|
+
for (const k of ["seam", "mcp", "groundwork", "playbook"]) console.log(` ${r[k].ok === false ? "✗" : r[k].ok ? "✓" : "?"} ${k.padEnd(10)} ${String(r[k].installed ?? "missing").padEnd(8)} needs ${r[k].required}${r.paths[k] ? ` ← ${r.paths[k]}` : ""}`);
|
|
34
|
+
console.log(` kit checkout: ${r.paths.kitCheckout ?? "none (using installed packages)"}`);
|
|
35
|
+
if (!r.ok) { console.log("\nfix: npm i -g agent-coord-mcp @davidbalzan/groundwork agent-coordination"); process.exit(1); }
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
if (sub && sub !== "start") { console.error(`unknown command: ${sub}\nusage: groundwork-console [start] [--port N] [--host H] [--no-open] [--no-auth] [--config file] | token | doctor | config`); process.exit(2); }
|
|
39
|
+
|
|
40
|
+
await import("../dist/server.js");
|
|
41
|
+
const { loadToken, authEnabled } = await import("../dist/auth.js");
|
|
42
|
+
const { CONFIG_FILE } = await import("../dist/config.js");
|
|
43
|
+
const base = `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
|
|
44
|
+
const token = authEnabled ? loadToken() : null;
|
|
45
|
+
const url = token ? `${base}/api/auth/login?token=${encodeURIComponent(token)}&next=/` : `${base}/`;
|
|
46
|
+
console.log(`\n groundwork-console ${pkg.version}\n UI ${base}/\n config ${CONFIG_FILE}\n token ${authEnabled ? tokenFile : "auth off"}\n sign in ${url}\n`);
|
|
47
|
+
if (!has("--no-open") && host !== "0.0.0.0") { try { const { default: open } = await import("open"); await open(url); } catch { /* headless: link is printed */ } }
|
package/dist/answered.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/* Console-owned "answered" set for DAVID_DECISION packets (room posts + DMs), keyed by message id.
|
|
5
|
+
Until the bus has inReplyTo (queued P3), this is how the UI stops showing a packet as open once David replied from the console.
|
|
6
|
+
Persisted in ~/.groundwork-console/answered.json — never touches ~/agent-coord. */
|
|
7
|
+
const FILE = path.join(homedir(), ".groundwork-console", "answered.json");
|
|
8
|
+
let cache = null;
|
|
9
|
+
export function answered() {
|
|
10
|
+
if (cache)
|
|
11
|
+
return cache;
|
|
12
|
+
try {
|
|
13
|
+
cache = existsSync(FILE) ? JSON.parse(readFileSync(FILE, "utf8")) : {};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
cache = {};
|
|
17
|
+
}
|
|
18
|
+
return cache;
|
|
19
|
+
}
|
|
20
|
+
export function markAnswered(ids, meta) {
|
|
21
|
+
const a = answered();
|
|
22
|
+
let n = 0;
|
|
23
|
+
for (const id of ids)
|
|
24
|
+
if (id && !a[id]) {
|
|
25
|
+
a[id] = { ts: Date.now(), ...meta };
|
|
26
|
+
n++;
|
|
27
|
+
}
|
|
28
|
+
if (n) {
|
|
29
|
+
mkdirSync(path.dirname(FILE), { recursive: true });
|
|
30
|
+
writeFileSync(FILE, JSON.stringify(a, null, 2));
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
export const isAnswered = (id) => typeof id === "string" && !!answered()[id];
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
6
|
+
/* Simple auth: one shared token (David alone). GROUNDWORK_CONSOLE_TOKEN env, else ~/.groundwork-console/token (generated once, 0600, printed at boot).
|
|
7
|
+
Login = POST /api/auth/login {token} → httpOnly cookie (30 d). Every other /api/* needs the cookie or `Authorization: Bearer <token>`.
|
|
8
|
+
GROUNDWORK_CONSOLE_AUTH=off disables (loopback dev). No users, no OAuth — see UX.md non-goals; GitHub OAuth is the step when a second human appears. */
|
|
9
|
+
const TOKEN_FILE = path.join(homedir(), ".groundwork-console", "token");
|
|
10
|
+
export const COOKIE = "gwc_session";
|
|
11
|
+
export const authEnabled = process.env.GROUNDWORK_CONSOLE_AUTH !== "off";
|
|
12
|
+
export function loadToken() {
|
|
13
|
+
if (process.env.GROUNDWORK_CONSOLE_TOKEN)
|
|
14
|
+
return process.env.GROUNDWORK_CONSOLE_TOKEN.trim();
|
|
15
|
+
if (existsSync(TOKEN_FILE)) {
|
|
16
|
+
const t = readFileSync(TOKEN_FILE, "utf8").trim();
|
|
17
|
+
if (t)
|
|
18
|
+
return t;
|
|
19
|
+
}
|
|
20
|
+
const t = randomBytes(24).toString("base64url");
|
|
21
|
+
mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
|
|
22
|
+
writeFileSync(TOKEN_FILE, t + "\n", { mode: 0o600 });
|
|
23
|
+
return t;
|
|
24
|
+
}
|
|
25
|
+
const TOKEN = loadToken();
|
|
26
|
+
export const tokenFile = TOKEN_FILE;
|
|
27
|
+
const same = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
|
|
28
|
+
const PUBLIC = [/^\/api\/health$/, /^\/api\/auth\/(login|status)$/, /^\/api\/uploads\/[\w.-]+$/]; // uploads: served by unguessable hash for HTTP-node agents
|
|
29
|
+
export function requireAuth() {
|
|
30
|
+
return async (c, next) => {
|
|
31
|
+
if (!authEnabled || PUBLIC.some((re) => re.test(c.req.path)))
|
|
32
|
+
return next();
|
|
33
|
+
const cookie = getCookie(c, COOKIE);
|
|
34
|
+
const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "");
|
|
35
|
+
if ((cookie && same(cookie, TOKEN)) || (bearer && same(bearer, TOKEN)))
|
|
36
|
+
return next();
|
|
37
|
+
return c.json({ error: "unauthorized", login: "/api/auth/login" }, 401);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function login(c, token) {
|
|
41
|
+
if (!same(token.trim(), TOKEN))
|
|
42
|
+
return false;
|
|
43
|
+
setCookie(c, COOKIE, TOKEN, { httpOnly: true, sameSite: "Lax", path: "/", maxAge: 30 * 24 * 3600, secure: c.req.url.startsWith("https://") });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
export function logout(c) { deleteCookie(c, COOKIE, { path: "/" }); }
|
|
47
|
+
export function isAuthed(c) { const v = getCookie(c, COOKIE); return !authEnabled || (!!v && same(v, TOKEN)); }
|
package/dist/bus.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* Read-only views over the agent-coord store (~/agent-coord). Mirrors what the MCP tools return, without going through the MCP. */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { listPanes } from "./tmux.js";
|
|
5
|
+
const readJson = (p, fallback) => { try {
|
|
6
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return fallback;
|
|
10
|
+
} };
|
|
11
|
+
const tailJsonl = (p, limit) => {
|
|
12
|
+
if (!existsSync(p))
|
|
13
|
+
return [];
|
|
14
|
+
const lines = readFileSync(p, "utf8").split("\n").filter(Boolean);
|
|
15
|
+
return lines.slice(-limit).map((l) => { try {
|
|
16
|
+
return JSON.parse(l);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { malformed: true, raw: l };
|
|
20
|
+
} });
|
|
21
|
+
};
|
|
22
|
+
export async function agentsAsync(root) {
|
|
23
|
+
const panes = await listPanes();
|
|
24
|
+
return agentsSync(root).map((a) => {
|
|
25
|
+
const pane = a.pane ? panes.find((p) => p.paneId === a.pane || p.target === a.pane) : undefined;
|
|
26
|
+
const paneActivity = pane?.activityTs ?? null;
|
|
27
|
+
const lastSeen = Math.max(a.lastSeen ?? 0, paneActivity ?? 0);
|
|
28
|
+
const age = lastSeen ? Date.now() - lastSeen : Infinity;
|
|
29
|
+
return { ...a, paneActivity, paneCommand: pane?.command ?? null, paneAlive: pane ? !pane.dead : null, lastSeen: lastSeen || null, heartbeatAgeMs: Number.isFinite(age) ? age : null, liveness: age < 15 * 60_000 ? "live" : age < 24 * 3_600_000 ? "idle" : "dead", active: paneActivity ? Date.now() - paneActivity < 60_000 : false };
|
|
30
|
+
}).sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
|
|
31
|
+
}
|
|
32
|
+
export const agents = agentsSync;
|
|
33
|
+
export function agentsSync(root) {
|
|
34
|
+
const reg = readJson(path.join(root, "agents.json"), {});
|
|
35
|
+
const transports = existsSync(path.join(root, "transports")) ? new Set(readdirSync(path.join(root, "transports")).map((f) => f.replace(/\.json$/, ""))) : new Set();
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
return Object.values(reg).map((a) => {
|
|
38
|
+
const tPath = path.join(root, "transports", `${a.agentId}.json`);
|
|
39
|
+
const t = transports.has(a.agentId) ? readJson(tPath, {}) : null;
|
|
40
|
+
// activity = freshest of heartbeat, transport marker mtime, inbox mtime, cursor mtime (the MCP prefers pane activity; we don't have tmux here)
|
|
41
|
+
const mtimes = [tPath, path.join(root, "inbox", `${a.agentId}.jsonl`), path.join(root, "cursors", `${a.agentId}.json`)].filter(existsSync).map((f) => statSync(f).mtimeMs);
|
|
42
|
+
const lastSeen = Math.max(a.lastHeartbeat ?? 0, ...mtimes);
|
|
43
|
+
const age = lastSeen ? now - lastSeen : Infinity;
|
|
44
|
+
return { ...a, transport: t?.transport ?? t?.kind ?? (t ? "tmux-push" : null), pane: t?.tmuxTarget ?? null, liveness: age < 15 * 60_000 ? "live" : age < 24 * 3_600_000 ? "idle" : "dead", heartbeatAgeMs: Number.isFinite(age) ? age : null, lastSeen: lastSeen || null };
|
|
45
|
+
}).sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
|
|
46
|
+
}
|
|
47
|
+
export function rooms(root) {
|
|
48
|
+
const meta = readJson(path.join(root, "rooms.json"), {});
|
|
49
|
+
const dir = path.join(root, "rooms");
|
|
50
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".jsonl")) : [];
|
|
51
|
+
return files.map((f) => {
|
|
52
|
+
const name = f.replace(/\.jsonl$/, "");
|
|
53
|
+
const p = path.join(dir, f);
|
|
54
|
+
const count = readFileSync(p, "utf8").split("\n").filter(Boolean).length;
|
|
55
|
+
return { room: name, ...(meta[name] ?? {}), messageCount: count, lastTs: statSync(p).mtimeMs };
|
|
56
|
+
}).sort((a, b) => b.lastTs - a.lastTs);
|
|
57
|
+
}
|
|
58
|
+
export function roomMessages(root, room, limit = 100, before) {
|
|
59
|
+
const p = path.join(root, "rooms", `${path.basename(room)}.jsonl`);
|
|
60
|
+
if (before === undefined)
|
|
61
|
+
return tailJsonl(p, limit);
|
|
62
|
+
// history page: the `limit` messages before timestamp `before`
|
|
63
|
+
if (!existsSync(p))
|
|
64
|
+
return [];
|
|
65
|
+
const all = readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => { try {
|
|
66
|
+
return JSON.parse(l);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { malformed: true, raw: l };
|
|
70
|
+
} });
|
|
71
|
+
const older = all.filter((m) => typeof m.ts === "number" && m.ts < before);
|
|
72
|
+
return older.slice(-limit);
|
|
73
|
+
}
|
|
74
|
+
export function roomMessageCount(root, room) { const p = path.join(root, "rooms", `${path.basename(room)}.jsonl`); return existsSync(p) ? readFileSync(p, "utf8").split("\n").filter(Boolean).length : 0; }
|
|
75
|
+
export function statusStream(root, limit = 50) { return tailJsonl(path.join(root, "status.jsonl"), limit); }
|
|
76
|
+
export function scopes(root) { return readJson(path.join(root, "scopes.json"), {}); }
|
|
77
|
+
/* prefix classification — same table as seam/protocol/prefixes.ts (byte-0, case-sensitive) */
|
|
78
|
+
export const PREFIXES = ["DAVID_DECISION", "SCOPE CHANGE", "AGENT_ACTION", "BLOCKER", "VERDICT", "RISK", "DONE", "FYI", "GO"];
|
|
79
|
+
export function prefixOf(text) { return PREFIXES.find((p) => text.startsWith(p + ":") || text.startsWith(p + " ")) ?? null; }
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const expand = (p) => (p.startsWith("~") ? path.join(homedir(), p.slice(1)) : p);
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
/* Config resolution (ergonomics first):
|
|
8
|
+
1. GROUNDWORK_CONSOLE_CONFIG (explicit)
|
|
9
|
+
2. ./groundwork-console.config.json in the current directory (dev checkout of this repo)
|
|
10
|
+
3. ~/.groundwork-console/config.json — created on first run with a starter (no projects; Add project in the UI). */
|
|
11
|
+
const HOME_CFG = path.join(homedir(), ".groundwork-console", "config.json");
|
|
12
|
+
const repoCfg = path.resolve(here, "../../../groundwork-console.config.json");
|
|
13
|
+
const CONFIG_PATH = process.env.GROUNDWORK_CONSOLE_CONFIG ?? (existsSync(path.resolve(process.cwd(), "groundwork-console.config.json")) ? path.resolve(process.cwd(), "groundwork-console.config.json") : existsSync(repoCfg) && !process.env.GROUNDWORK_CONSOLE_HOME_CONFIG ? repoCfg : HOME_CFG);
|
|
14
|
+
const STARTER = { $comment: "Groundwork Console config. Paths are absolute or ~-relative. Add projects from the UI (Home → Add project) or here.", kitPath: "~/workspace/groundwork-kit", coordDir: "~/agent-coord", projects: [] };
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
17
|
+
mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
18
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(STARTER, null, 2) + "\n");
|
|
19
|
+
}
|
|
20
|
+
const raw = { ...STARTER, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) };
|
|
21
|
+
return {
|
|
22
|
+
kitPath: expand(raw.kitPath ?? ""),
|
|
23
|
+
coordDir: expand(process.env.AGENT_COORD_DIR ?? raw.coordDir ?? "~/agent-coord"),
|
|
24
|
+
projects: (raw.projects ?? []).map((p) => ({ ...p, path: expand(p.path) })),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export const CONFIG_FILE = CONFIG_PATH;
|
|
28
|
+
export function saveProjects(projects) {
|
|
29
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
30
|
+
const home = homedir();
|
|
31
|
+
raw.projects = projects.map((p) => ({ ...p, path: p.path.startsWith(home) ? "~" + p.path.slice(home.length) : p.path }));
|
|
32
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2) + "\n");
|
|
33
|
+
}
|
|
34
|
+
export function validateProjectPath(p) {
|
|
35
|
+
const full = expand(p);
|
|
36
|
+
if (!existsSync(full))
|
|
37
|
+
return { ok: false, reason: "path does not exist" };
|
|
38
|
+
const docs = path.join(full, "docs");
|
|
39
|
+
return { ok: true, path: full, hasDocs: existsSync(docs), hasSeam: ["WORKSTREAMS.md", "QUEUE.md", "DONE.md", "FACTS.md"].filter((f) => existsSync(path.join(docs, f))), isGit: existsSync(path.join(full, ".git")) };
|
|
40
|
+
}
|
package/dist/docwrite.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Free-text writes of non-seam docs/*.md. Seam files (QUEUE/DONE/WORKSTREAMS/FACTS) stay Board-only.
|
|
2
|
+
Bytes are written as typed — no line-ending normalisation. If-Match is sha256 of the last GET body. */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
export const SEAM_BASENAMES = new Set(["QUEUE.md", "DONE.md", "WORKSTREAMS.md", "FACTS.md"]);
|
|
7
|
+
export function sha256(s) {
|
|
8
|
+
return createHash("sha256").update(s, "utf8").digest("hex");
|
|
9
|
+
}
|
|
10
|
+
export class DocWriteError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
sha;
|
|
13
|
+
constructor(message, status, sha) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.sha = sha;
|
|
17
|
+
this.name = "DocWriteError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function resolveDoc(projectPath, rel) {
|
|
21
|
+
const safe = path.normalize(rel).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
|
|
22
|
+
if (!safe || !safe.toLowerCase().endsWith(".md"))
|
|
23
|
+
return null;
|
|
24
|
+
if (safe.split(/[/\\]/).some((s) => s === ".." || s === "" || s === "."))
|
|
25
|
+
return null;
|
|
26
|
+
const docsRoot = path.resolve(projectPath, "docs");
|
|
27
|
+
const full = path.resolve(docsRoot, safe);
|
|
28
|
+
if (full !== docsRoot && !full.startsWith(docsRoot + path.sep))
|
|
29
|
+
return null;
|
|
30
|
+
return { rel: safe.split(path.sep).join("/"), full };
|
|
31
|
+
}
|
|
32
|
+
export function writeDoc(projectPath, rel, content, ifMatch, log) {
|
|
33
|
+
const resolved = resolveDoc(projectPath, rel);
|
|
34
|
+
if (!resolved)
|
|
35
|
+
throw new DocWriteError("path not under docs/ or not a .md file", 400);
|
|
36
|
+
if (SEAM_BASENAMES.has(resolved.rel))
|
|
37
|
+
throw new DocWriteError("QUEUE/DONE/WORKSTREAMS/FACTS are seam files — write them on the Board, not as free text", 400);
|
|
38
|
+
if (!existsSync(resolved.full))
|
|
39
|
+
throw new DocWriteError("not found", 404);
|
|
40
|
+
const src = readFileSync(resolved.full, "utf8");
|
|
41
|
+
const diskSha = sha256(src);
|
|
42
|
+
if (!ifMatch)
|
|
43
|
+
throw new DocWriteError("ifMatch (sha256 of last GET) required", 400);
|
|
44
|
+
if (diskSha !== ifMatch)
|
|
45
|
+
throw new DocWriteError("conflict: file changed on disk", 409, diskSha);
|
|
46
|
+
log(`disk crlf=${src.includes("\r\n")} incoming crlf=${content.includes("\r\n")} — writing bytes as typed`);
|
|
47
|
+
const tmp = `${resolved.full}.tmp-${process.pid}-${Date.now()}`;
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(tmp, content, "utf8");
|
|
50
|
+
renameSync(tmp, resolved.full);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(tmp);
|
|
55
|
+
}
|
|
56
|
+
catch { /* leftover tmp */ }
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
const after = readFileSync(resolved.full, "utf8");
|
|
60
|
+
if (after !== content)
|
|
61
|
+
throw new Error("post-write verification failed: disk !== content");
|
|
62
|
+
const sha = sha256(content);
|
|
63
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
64
|
+
log(`wrote docs/${resolved.rel} (${bytes} bytes, sha ${sha.slice(0, 12)})`);
|
|
65
|
+
return { file: `docs/${resolved.rel}`, bytes, sha };
|
|
66
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/* Rename compatibility (kit-console → groundwork-console, 2026-08-18). Import this FIRST from server.ts and the bin.
|
|
5
|
+
1. Legacy env vars: KIT_CONSOLE_* are honoured as GROUNDWORK_CONSOLE_* when the new name is unset.
|
|
6
|
+
2. State dir: ~/.kit-console is moved once to ~/.groundwork-console (token, config, switches, answered, uploads, events-state) so nothing has to be re-entered. */
|
|
7
|
+
for (const [k, v] of Object.entries(process.env))
|
|
8
|
+
if (k.startsWith("KIT_CONSOLE_") && v !== undefined) {
|
|
9
|
+
const nk = "GROUNDWORK_CONSOLE_" + k.slice("KIT_CONSOLE_".length);
|
|
10
|
+
if (process.env[nk] === undefined)
|
|
11
|
+
process.env[nk] = v;
|
|
12
|
+
}
|
|
13
|
+
const OLD = path.join(homedir(), ".kit-console"), NEW = path.join(homedir(), ".groundwork-console");
|
|
14
|
+
if (existsSync(OLD) && !existsSync(NEW)) {
|
|
15
|
+
try {
|
|
16
|
+
renameSync(OLD, NEW);
|
|
17
|
+
console.log(`state dir moved: ${OLD} → ${NEW}`);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
console.warn(`could not move ${OLD} → ${NEW}: ${e.message}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export const STATE_DIR = NEW;
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* Mechanical event detection over the seam + bus, with rule evaluation.
|
|
2
|
+
Snapshot per project every tick, diff, emit typed events, keep a ring buffer, SSE fan-out.
|
|
3
|
+
LIVE sends only when: busWrites.enabled && !config.dryRun && rule.enabled. Dedupe by key (persisted), rate-limited. Every send is a Run. */
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { boardOf } from "./seam.js";
|
|
8
|
+
import { roomMessages, agents } from "./bus.js";
|
|
9
|
+
import { busWrites } from "./mcp.js";
|
|
10
|
+
const RING = 500;
|
|
11
|
+
const expand = (p) => (p.startsWith("~") ? path.join(homedir(), p.slice(1)) : p);
|
|
12
|
+
export class EventEngine {
|
|
13
|
+
cfg;
|
|
14
|
+
rulesPath;
|
|
15
|
+
runs;
|
|
16
|
+
send;
|
|
17
|
+
snaps = new Map();
|
|
18
|
+
buffer = [];
|
|
19
|
+
listeners = new Set();
|
|
20
|
+
seq = 0;
|
|
21
|
+
rules;
|
|
22
|
+
state = { sent: {}, window: {} };
|
|
23
|
+
constructor(cfg, rulesPath, runs, send) {
|
|
24
|
+
this.cfg = cfg;
|
|
25
|
+
this.rulesPath = rulesPath;
|
|
26
|
+
this.runs = runs;
|
|
27
|
+
this.send = send;
|
|
28
|
+
this.rules = JSON.parse(readFileSync(rulesPath, "utf8"));
|
|
29
|
+
for (const r of this.rules.rules)
|
|
30
|
+
r.enabled = r.enabled === true;
|
|
31
|
+
this.loadState();
|
|
32
|
+
}
|
|
33
|
+
get config() { return { identity: this.rules.identity, dryRun: this.rules.dryRun, limits: this.rules.limits ?? {}, rules: this.rules.rules, live: !this.rules.dryRun && busWrites.enabled, busWrites: busWrites.enabled }; }
|
|
34
|
+
recent(limit = 100) { return this.buffer.slice(-limit); }
|
|
35
|
+
subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
|
|
36
|
+
/** Console-owned config writes: dryRun + per-rule enabled, persisted to groundwork-console.rules.json. */
|
|
37
|
+
setDryRun(v) { this.rules.dryRun = v; this.saveRules(); }
|
|
38
|
+
setRuleEnabled(id, v) { const r = this.rules.rules.find((x) => x.id === id); if (!r)
|
|
39
|
+
throw new Error("unknown rule"); r.enabled = v; this.saveRules(); }
|
|
40
|
+
saveRules() { writeFileSync(this.rulesPath, JSON.stringify(this.rules, null, 2) + "\n"); }
|
|
41
|
+
get statePath() { return expand(this.rules.stateFile ?? "~/.groundwork-console/events-state.json"); }
|
|
42
|
+
loadState() { try {
|
|
43
|
+
if (existsSync(this.statePath))
|
|
44
|
+
this.state = JSON.parse(readFileSync(this.statePath, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch { /* fresh */ } }
|
|
47
|
+
saveState() { mkdirSync(path.dirname(this.statePath), { recursive: true }); writeFileSync(this.statePath, JSON.stringify(this.state)); }
|
|
48
|
+
start(intervalMs = 15_000) { this.tick(true); setInterval(() => this.tick(false), intervalMs).unref(); }
|
|
49
|
+
snapshot(projectPath, room) {
|
|
50
|
+
const b = boardOf(projectPath);
|
|
51
|
+
const msgs = room ? roomMessages(this.cfg.coordDir, room, 50) : [];
|
|
52
|
+
const lastDecision = [...msgs].reverse().find((m) => typeof m.text === "string" && m.text.startsWith("DAVID_DECISION"));
|
|
53
|
+
const mal = b.workstreams.rows.filter((r) => "malformed" in r);
|
|
54
|
+
return { phases: Object.fromEntries(b.phases.map((p) => [p.id, { done: p.done, total: p.total, state: p.state }])), doneCount: b.done.length, queueIds: b.queue.filter((q) => !q.done).map((q) => q.id), staleFacts: b.facts?.entries.filter((e) => e.stale).map((e) => e.id) ?? [], malformed: mal.length, malformedLine: mal[0]?.line, lastDecisionId: lastDecision?.id ?? null };
|
|
55
|
+
}
|
|
56
|
+
tick(first) {
|
|
57
|
+
for (const p of this.cfg.projects) {
|
|
58
|
+
let cur;
|
|
59
|
+
try {
|
|
60
|
+
cur = this.snapshot(p.path, p.room);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const prev = this.snaps.get(p.name);
|
|
66
|
+
this.snaps.set(p.name, cur);
|
|
67
|
+
const base = { project: p.name, room: p.room ?? "" };
|
|
68
|
+
if (!prev || first) {
|
|
69
|
+
const bl = { ...base, baseline: true };
|
|
70
|
+
for (const f of cur.staleFacts)
|
|
71
|
+
this.emitRaw("state.fact.stale", p.name, { ...bl, fact: f });
|
|
72
|
+
if (cur.malformed > 0)
|
|
73
|
+
this.emitRaw("state.board.malformed", p.name, { ...bl, count: cur.malformed, line: cur.malformedLine ?? "?" });
|
|
74
|
+
if (cur.lastDecisionId)
|
|
75
|
+
this.emitRaw("state.decision.open", p.name, { ...bl, id: cur.lastDecisionId });
|
|
76
|
+
const active = Object.entries(cur.phases).find(([, ph]) => ph.state === "ongoing");
|
|
77
|
+
if (active)
|
|
78
|
+
this.emitRaw("state.phase.active", p.name, { ...bl, phase: active[0], done: active[1].done, total: active[1].total });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const [id, ph] of Object.entries(cur.phases)) {
|
|
82
|
+
const was = prev.phases[id];
|
|
83
|
+
if (was && was.state !== "complete" && ph.state === "complete")
|
|
84
|
+
void this.emit("phase.completed", p.name, { ...base, phase: id, done: ph.done, total: ph.total });
|
|
85
|
+
if (was && was.done === 0 && ph.done > 0)
|
|
86
|
+
void this.emit("phase.started", p.name, { ...base, phase: id, done: ph.done, total: ph.total });
|
|
87
|
+
if (was && ph.done !== was.done)
|
|
88
|
+
void this.emit("phase.progress", p.name, { ...base, phase: id, done: ph.done, total: ph.total, delta: ph.done - was.done });
|
|
89
|
+
}
|
|
90
|
+
if (cur.doneCount > prev.doneCount)
|
|
91
|
+
void this.emit("done.appended", p.name, { ...base, count: cur.doneCount - prev.doneCount });
|
|
92
|
+
for (const id of prev.queueIds.filter((x) => !cur.queueIds.includes(x)))
|
|
93
|
+
void this.emit("queue.item.closed", p.name, { ...base, item: id });
|
|
94
|
+
for (const id of cur.queueIds.filter((x) => !prev.queueIds.includes(x)))
|
|
95
|
+
void this.emit("queue.item.added", p.name, { ...base, item: id });
|
|
96
|
+
for (const f of cur.staleFacts.filter((x) => !prev.staleFacts.includes(x)))
|
|
97
|
+
void this.emit("fact.stale", p.name, { ...base, fact: f, ageDays: ">14", owner: this.coordinatorFor(p.name, p.room) ?? "coordinator" });
|
|
98
|
+
if (cur.malformed > prev.malformed)
|
|
99
|
+
void this.emit("board.malformed", p.name, { ...base, count: cur.malformed, line: cur.malformedLine ?? "?" });
|
|
100
|
+
if (cur.lastDecisionId && cur.lastDecisionId !== prev.lastDecisionId)
|
|
101
|
+
void this.emit("decision.posted", p.name, { ...base, id: cur.lastDecisionId });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** The live coordinator agent for a project/room (role coordinator, project == room or name), else null. */
|
|
105
|
+
coordinatorFor(project, room) {
|
|
106
|
+
const a = agents(this.cfg.coordDir).find((x) => /coordinator/i.test(x.role ?? "") && (x.project === room || x.project === project) && x.liveness !== "dead");
|
|
107
|
+
return a?.agentId ?? null;
|
|
108
|
+
}
|
|
109
|
+
emitRaw(type, project, data) {
|
|
110
|
+
const e = { id: `${Date.now()}-${++this.seq}`, ts: Date.now(), type, project, data, actions: [] };
|
|
111
|
+
this.push(e);
|
|
112
|
+
}
|
|
113
|
+
push(e) { this.buffer.push(e); if (this.buffer.length > RING)
|
|
114
|
+
this.buffer.shift(); this.notify(e); }
|
|
115
|
+
notify(e) { for (const l of this.listeners)
|
|
116
|
+
l(e); }
|
|
117
|
+
async emit(type, project, data) {
|
|
118
|
+
const live = !this.rules.dryRun && busWrites.enabled;
|
|
119
|
+
const actions = this.rules.rules.filter((r) => r.on === type).map((r) => ({
|
|
120
|
+
ruleId: r.id, type: r.action.type, dryRun: !(live && r.enabled),
|
|
121
|
+
to: r.action.to && this.fill(r.action.to, data), room: r.action.room && this.fill(r.action.room, data), prefix: r.action.prefix, text: r.action.text && this.fill(r.action.text, data), note: r.action.note,
|
|
122
|
+
}));
|
|
123
|
+
const e = { id: `${Date.now()}-${++this.seq}`, ts: Date.now(), type, project, data, actions };
|
|
124
|
+
this.push(e);
|
|
125
|
+
for (const a of actions) {
|
|
126
|
+
if (a.type !== "send_message" || a.dryRun)
|
|
127
|
+
continue;
|
|
128
|
+
const rule = this.rules.rules.find((r) => r.id === a.ruleId);
|
|
129
|
+
const key = `${a.ruleId}|${this.fill(rule.key ?? "{project}", data)}`;
|
|
130
|
+
const ttl = (this.rules.limits?.dedupeTtlHours ?? 72) * 3_600_000;
|
|
131
|
+
if (this.state.sent[key] && Date.now() - this.state.sent[key] < ttl) {
|
|
132
|
+
a.skipped = `dedupe (${key})`;
|
|
133
|
+
this.notify(e);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const wk = `${a.ruleId}|${project}`;
|
|
137
|
+
const hourAgo = Date.now() - 3_600_000;
|
|
138
|
+
this.state.window[wk] = (this.state.window[wk] ?? []).filter((t) => t > hourAgo);
|
|
139
|
+
if (this.state.window[wk].length >= (this.rules.limits?.maxPerRulePerProjectPerHour ?? 3)) {
|
|
140
|
+
a.skipped = "rate limit";
|
|
141
|
+
this.notify(e);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// resolve "coordinator" → live coordinator agent id
|
|
145
|
+
let to = a.to;
|
|
146
|
+
if (to === "coordinator") {
|
|
147
|
+
const c = this.coordinatorFor(project, String(data.room ?? ""));
|
|
148
|
+
if (!c) {
|
|
149
|
+
a.skipped = "no live coordinator for project";
|
|
150
|
+
this.notify(e);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
to = c;
|
|
154
|
+
}
|
|
155
|
+
const text = `${a.prefix ? a.prefix + ": " : ""}${a.text ?? ""}`;
|
|
156
|
+
const run = await this.runs.exec({ kind: "bus", label: `rule ${a.ruleId} → ${to ?? "#" + a.room}`, project, sideEffects: true, args: { event: type, to, room: a.room, text: text.slice(0, 200) } }, (log) => this.send({ ...(to ? { to } : { room: a.room }), text }, log));
|
|
157
|
+
a.runId = run.id;
|
|
158
|
+
a.sent = run.status === "ok";
|
|
159
|
+
if (run.status !== "ok")
|
|
160
|
+
a.skipped = run.error ?? "send failed";
|
|
161
|
+
if (a.sent) {
|
|
162
|
+
this.state.sent[key] = Date.now();
|
|
163
|
+
this.state.window[wk].push(Date.now());
|
|
164
|
+
this.saveState();
|
|
165
|
+
}
|
|
166
|
+
this.notify(e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
fill(t, d) { return t.replace(/\{(\w+)\}/g, (_m, k) => String(d[k] ?? `{${k}}`)); }
|
|
170
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Interim parser for the WORKSTREAMS coordinator-extension sections (## Open PRs · Cutover Gates · Needs David · Risks · Rooms · Decisions Recorded).
|
|
2
|
+
The seam reports these as "unknown workstreams grammar" today; asked grok to make the seam own them. Generic: header cells → objects; bullets → strings. */
|
|
3
|
+
const SECTIONS = ["Open PRs", "Cutover Gates", "Needs David", "Risks", "Rooms", "Decisions Recorded"];
|
|
4
|
+
const cells = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
5
|
+
export function extensionsOf(source) {
|
|
6
|
+
if (!source)
|
|
7
|
+
return [];
|
|
8
|
+
const lines = source.split("\n");
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const title of SECTIONS) {
|
|
11
|
+
const start = lines.findIndex((l) => l.trim().toLowerCase().startsWith(`## ${title.toLowerCase()}`));
|
|
12
|
+
if (start < 0)
|
|
13
|
+
continue;
|
|
14
|
+
let end = lines.findIndex((l, i) => i > start && /^## /.test(l));
|
|
15
|
+
if (end < 0)
|
|
16
|
+
end = lines.length;
|
|
17
|
+
const body = lines.slice(start + 1, end);
|
|
18
|
+
const raw = body.join("\n").trim();
|
|
19
|
+
const tbl = body.filter((l) => l.trim().startsWith("|"));
|
|
20
|
+
const sec = { title, raw };
|
|
21
|
+
if (tbl.length >= 2) {
|
|
22
|
+
const headers = cells(tbl[0]);
|
|
23
|
+
sec.table = { headers, rows: tbl.slice(1).filter((l) => !/^\|\s*-{2,}/.test(l.trim()) && !/^\|[\s:|-]+\|$/.test(l.trim())).map((l) => Object.fromEntries(cells(l).map((c, i) => [headers[i] ?? `c${i}`, c]))) };
|
|
24
|
+
}
|
|
25
|
+
const bullets = body.filter((l) => /^\s*[-*] /.test(l)).map((l) => l.replace(/^\s*[-*] /, "").trim());
|
|
26
|
+
if (bullets.length)
|
|
27
|
+
sec.bullets = bullets;
|
|
28
|
+
out.push(sec);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
package/dist/gitinfo.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* Read-only git views for a project's docs: uncommitted hunks (working tree vs HEAD, staged included), last commit touching the file + its hunks, and a docs/ status map. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
async function git(cwd, args) { try {
|
|
6
|
+
const r = await run("git", args, { cwd, maxBuffer: 8 << 20 });
|
|
7
|
+
return r.stdout;
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
const err = e;
|
|
11
|
+
if (err.stdout !== undefined && (err.code === 1))
|
|
12
|
+
return err.stdout;
|
|
13
|
+
return null;
|
|
14
|
+
} }
|
|
15
|
+
export function parseHunks(diff) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let cur = null;
|
|
18
|
+
for (const line of diff.split("\n")) {
|
|
19
|
+
const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
|
|
20
|
+
if (m) {
|
|
21
|
+
cur = { oldStart: +m[1], oldLines: m[2] === undefined ? 1 : +m[2], newStart: +m[3], newLines: m[4] === undefined ? 1 : +m[4], header: m[5].trim(), lines: [] };
|
|
22
|
+
out.push(cur);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (cur && /^[+\- ]/.test(line) && !line.startsWith("+++") && !line.startsWith("---"))
|
|
26
|
+
cur.lines.push(line);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export async function docGit(projectPath, rel) {
|
|
31
|
+
const inside = await git(projectPath, ["rev-parse", "--is-inside-work-tree"]);
|
|
32
|
+
if (!inside?.trim().startsWith("true"))
|
|
33
|
+
return { repo: false, tracked: false, uncommitted: [], untracked: false, lastCommit: null };
|
|
34
|
+
const tracked = (await git(projectPath, ["ls-files", "--error-unmatch", "--", rel])) !== null;
|
|
35
|
+
const untrackedList = (await git(projectPath, ["ls-files", "--others", "--exclude-standard", "--", rel])) ?? "";
|
|
36
|
+
const untracked = !tracked && untrackedList.trim().length > 0;
|
|
37
|
+
const uncommitted = tracked ? parseHunks((await git(projectPath, ["diff", "-U3", "HEAD", "--", rel])) ?? "") : [];
|
|
38
|
+
let lastCommit = null;
|
|
39
|
+
if (tracked) {
|
|
40
|
+
const log = (await git(projectPath, ["log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI", "--", rel]))?.trim();
|
|
41
|
+
if (log) {
|
|
42
|
+
const [sha, short, subject, author, date] = log.split("\x1f");
|
|
43
|
+
const show = (await git(projectPath, ["show", "--format=", "-U3", sha, "--", rel])) ?? "";
|
|
44
|
+
lastCommit = { sha, short, subject, author, date, hunks: parseHunks(show) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { repo: true, tracked, uncommitted, untracked, lastCommit };
|
|
48
|
+
}
|
|
49
|
+
/** docs/ status map: path → "M" | "A" | "??" | "D" (porcelain codes, working tree vs HEAD incl. staged) */
|
|
50
|
+
export async function docsStatus(projectPath) {
|
|
51
|
+
const out = (await git(projectPath, ["status", "--porcelain=v1", "--untracked-files=all", "--", "docs"])) ?? "";
|
|
52
|
+
const map = {};
|
|
53
|
+
for (const l of out.split("\n")) {
|
|
54
|
+
if (!l.trim())
|
|
55
|
+
continue;
|
|
56
|
+
const code = l.slice(0, 2).trim() || "M";
|
|
57
|
+
const p = l.slice(3).trim().replace(/^docs\//, "");
|
|
58
|
+
map[p] = code;
|
|
59
|
+
}
|
|
60
|
+
return map;
|
|
61
|
+
}
|
|
62
|
+
export async function lastDocsCommit(projectPath) {
|
|
63
|
+
const log = (await git(projectPath, ["log", "-1", "--format=%h%x1f%s%x1f%cI", "--", "docs"]))?.trim();
|
|
64
|
+
if (!log)
|
|
65
|
+
return null;
|
|
66
|
+
const [short, subject, date] = log.split("\x1f");
|
|
67
|
+
const files = ((await git(projectPath, ["show", "--format=", "--name-only", short, "--", "docs"])) ?? "").split("\n").filter(Boolean).map((f) => f.replace(/^docs\//, ""));
|
|
68
|
+
return { short, subject, date, files };
|
|
69
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* Groundwork + playbook CLIs, JSON mode (shipped by grok 2026-08-16). Cached briefly; both are read-only commands. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const run = promisify(execFile);
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
async function json(key, ttl, argv, fallback) {
|
|
8
|
+
const hit = cache.get(key);
|
|
9
|
+
if (hit && Date.now() - hit.at < ttl)
|
|
10
|
+
return hit.value;
|
|
11
|
+
let value;
|
|
12
|
+
try {
|
|
13
|
+
const r = await run("node", argv, { timeout: 20_000, maxBuffer: 4 << 20 });
|
|
14
|
+
value = { ...JSON.parse(r.stdout), ranAt: Date.now() };
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
const err = e;
|
|
18
|
+
try {
|
|
19
|
+
value = { ...JSON.parse(err.stdout ?? ""), ranAt: Date.now() };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
value = { ...fallback, ranAt: Date.now(), error: err.message };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
cache.set(key, { at: Date.now(), value });
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
import { groundworkCli, playbookCli } from "./kitresolve.js";
|
|
29
|
+
const gw = (kit) => groundworkCli(kit) ?? path.join(kit, "packages/groundwork/src/cli.mjs");
|
|
30
|
+
const pb = (kit) => playbookCli(kit) ?? path.join(kit, "packages/coord-playbook/bin/install.mjs");
|
|
31
|
+
export const doctor = (kit, proj) => json(`doctor:${proj}`, 30_000, [gw(kit), "doctor", proj, "--json"], { ok: false, exit: 1, checks: [], ranAt: 0 });
|
|
32
|
+
export const status = (kit, proj) => json(`status:${proj}`, 15_000, [gw(kit), "status", proj, "--json"], { streams: [], next: null, queueFound: false, phases: [], ranAt: 0 });
|
|
33
|
+
export const list = (kit, proj) => json(`list:${proj}`, 60_000, [gw(kit), "list", proj, "--json"], { version: "", project: proj, skills: [], ranAt: 0 });
|
|
34
|
+
export const playbookList = (kit) => json("pb:list", 60_000, [pb(kit), "list", "--json"], { version: "", cards: [], ranAt: 0 });
|