@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
package/dist/server.js
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import "./env.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execP = promisify(execFile);
|
|
6
|
+
import { cors } from "hono/cors";
|
|
7
|
+
import { serve } from "@hono/node-server";
|
|
8
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { loadConfig, saveProjects, validateProjectPath, CONFIG_FILE } from "./config.js";
|
|
12
|
+
import { boardOf, docOf, docsIndex, phaseTasksOf } from "./seam.js";
|
|
13
|
+
import { DocWriteError, sha256, writeDoc } from "./docwrite.js";
|
|
14
|
+
import { agents, agentsAsync, rooms, roomMessages, roomMessageCount, statusStream, scopes, prefixOf } from "./bus.js";
|
|
15
|
+
import { capturePane } from "./tmux.js";
|
|
16
|
+
import { spawnAgent, suggestId, fleetStatus, FLEET_TEMPLATES, HARNESS_CMD, ROLE_SKILL } from "./provision.js";
|
|
17
|
+
import { doctor, status, list, playbookList } from "./groundwork.js";
|
|
18
|
+
import { groundworkSkills, playbookCards } from "./skills.js";
|
|
19
|
+
import { extensionsOf } from "./extensions.js";
|
|
20
|
+
import { parseWorkDoc, workstreamsExtensionsOf } from "@davidbalzan/groundwork-seam";
|
|
21
|
+
import { EventEngine } from "./events.js";
|
|
22
|
+
import { docGit, docsStatus, lastDocsCommit } from "./gitinfo.js";
|
|
23
|
+
import { Notifier } from "./notify.js";
|
|
24
|
+
import { Runs } from "./runs.js";
|
|
25
|
+
import { BusClient, AGENT_ID, busWrites, seamWrites, saveSwitches, SWITCHES_FILE } from "./mcp.js";
|
|
26
|
+
import { addQueueItem, reverifyFact, scopeAllows } from "./seamwrite.js";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { saveUpload, uploadPath, UPLOAD_DIR } from "./uploads.js";
|
|
29
|
+
import { answered, markAnswered, isAnswered } from "./answered.js";
|
|
30
|
+
import { requireAuth, login, logout, isAuthed, authEnabled, tokenFile } from "./auth.js";
|
|
31
|
+
import { knowledgeIndex, knowledgeDoc, knowledgePath } from "./knowledge.js";
|
|
32
|
+
import { requiresReport, groundworkCli, REQUIRES } from "./kitresolve.js";
|
|
33
|
+
let cfg = loadConfig();
|
|
34
|
+
const port = Number(process.env.PORT ?? 8790);
|
|
35
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const RULES = process.env.GROUNDWORK_CONSOLE_RULES ?? [path.join(process.env.HOME ?? "", ".groundwork-console", "rules.json"), path.resolve(HERE, "../groundwork-console.rules.json"), path.resolve(HERE, "../../../groundwork-console.rules.json")].find((f) => existsSync(f)) ?? path.resolve(HERE, "../../../groundwork-console.rules.json");
|
|
37
|
+
const PKG = JSON.parse(readFileSync(path.resolve(HERE, "../package.json"), "utf8"));
|
|
38
|
+
const runs = new Runs();
|
|
39
|
+
const bus = new BusClient(cfg.kitPath, cfg.coordDir, "groundwork-console", process.env.AGENT_COORD_MCP);
|
|
40
|
+
const events = new EventEngine(cfg, RULES, runs, (args, log) => bus.call("send_message", args, log));
|
|
41
|
+
const notifier = new Notifier(cfg, runs, events);
|
|
42
|
+
const busDavid = new BusClient(cfg.kitPath, cfg.coordDir, "david", process.env.AGENT_COORD_MCP);
|
|
43
|
+
const clientFor = (id) => (id === "david" ? busDavid : bus);
|
|
44
|
+
const PUBLIC_DIR = path.resolve(HERE, "../public");
|
|
45
|
+
const root = new Hono();
|
|
46
|
+
const app = new Hono().basePath("/api");
|
|
47
|
+
app.use("*", cors({ origin: (o) => o ?? "*", credentials: true }));
|
|
48
|
+
app.use("*", requireAuth());
|
|
49
|
+
app.get("/auth/status", (c) => c.json({ enabled: authEnabled, authed: isAuthed(c) }));
|
|
50
|
+
app.post("/auth/login", async (c) => { const b = (await c.req.json().catch(() => ({}))); if (!b.token || !login(c, b.token))
|
|
51
|
+
return c.json({ error: "wrong token — it's in ~/.groundwork-console/token (or GROUNDWORK_CONSOLE_TOKEN)" }, 401); return c.json({ ok: true }); });
|
|
52
|
+
app.post("/auth/logout", (c) => { logout(c); return c.json({ ok: true }); });
|
|
53
|
+
// One-click sign-in link printed by the bin (`groundwork-console` opens it): sets the cookie and redirects to the app. Token is the secret either way; the API is loopback by default.
|
|
54
|
+
app.get("/auth/login", (c) => { const t = c.req.query("token") ?? ""; if (!login(c, t))
|
|
55
|
+
return c.json({ error: "wrong token" }, 401); return c.redirect(c.req.query("next") ?? "/"); });
|
|
56
|
+
const project = (name) => cfg.projects.find((p) => p.name === name);
|
|
57
|
+
app.get("/health", (c) => c.json({ ok: true, readOnly: !busWrites.enabled, busWrites: busWrites.enabled, version: PKG.version, requires: requiresReport(cfg.kitPath), config: CONFIG_FILE }));
|
|
58
|
+
app.get("/admin/bus-writes", (c) => c.json(busWrites));
|
|
59
|
+
app.get("/admin/seam-writes", (c) => c.json(seamWrites));
|
|
60
|
+
app.post("/admin/seam-writes", async (c) => {
|
|
61
|
+
const b = (await c.req.json());
|
|
62
|
+
const r = await runs.exec({ kind: "cli", label: `seam writes ${b.enabled ? "ON" : "OFF"}`, sideEffects: true, args: { enabled: b.enabled } }, async (log) => { seamWrites.enabled = !!b.enabled; seamWrites.changedAt = Date.now(); seamWrites.by = "console"; saveSwitches(); log(`persisted → ${SWITCHES_FILE}`); log(`seam writes now ${seamWrites.enabled ? "ENABLED — Add item / Re-verify write through the seam" : "disabled"}`); return { ...seamWrites }; });
|
|
63
|
+
return c.json(r);
|
|
64
|
+
});
|
|
65
|
+
/* ---- v0.2 seam writes (through @davidbalzan/groundwork-seam only; round-trip guarded; scope-checked) ---- */
|
|
66
|
+
app.post("/actions/queue-add/:name", async (c) => {
|
|
67
|
+
const p = project(c.req.param("name"));
|
|
68
|
+
if (!p)
|
|
69
|
+
return c.json({ error: "unknown project" }, 404);
|
|
70
|
+
if (!seamWrites.enabled)
|
|
71
|
+
return c.json({ error: "seam writes are off (header → seam)" }, 403);
|
|
72
|
+
const b = (await c.req.json());
|
|
73
|
+
if (!["P1", "P2", "P3"].includes(b.priority) || !b.text?.trim())
|
|
74
|
+
return c.json({ error: "priority P1|P2|P3 and text required" }, 400);
|
|
75
|
+
const sc = scopeAllows(scopes(cfg.coordDir), "docs/QUEUE.md");
|
|
76
|
+
if (!sc.ok)
|
|
77
|
+
return c.json({ error: sc.reason }, 403);
|
|
78
|
+
const r = await runs.exec({ kind: "seam", label: `queue add (${b.priority}) · ${p.name}`, project: p.name, sideEffects: true, args: { priority: b.priority, text: b.text.slice(0, 200) } }, async (log) => { log(`scope: ${sc.reason}`); return addQueueItem(p.path, b, log); });
|
|
79
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
80
|
+
});
|
|
81
|
+
app.post("/actions/fact-reverify/:name", async (c) => {
|
|
82
|
+
const p = project(c.req.param("name"));
|
|
83
|
+
if (!p)
|
|
84
|
+
return c.json({ error: "unknown project" }, 404);
|
|
85
|
+
if (!seamWrites.enabled)
|
|
86
|
+
return c.json({ error: "seam writes are off (header → seam)" }, 403);
|
|
87
|
+
const b = (await c.req.json());
|
|
88
|
+
if (!b.id || !b.method?.trim())
|
|
89
|
+
return c.json({ error: "id and method required" }, 400);
|
|
90
|
+
const sc = scopeAllows(scopes(cfg.coordDir), "docs/FACTS.md");
|
|
91
|
+
if (!sc.ok)
|
|
92
|
+
return c.json({ error: sc.reason }, 403);
|
|
93
|
+
const r = await runs.exec({ kind: "seam", label: `re-verify fact ${b.id} · ${p.name}`, project: p.name, sideEffects: true, args: { id: b.id, method: b.method } }, async (log) => { log(`scope: ${sc.reason}`); return reverifyFact(p.path, { id: b.id, claim: b.claim, by: b.by ?? "david", method: b.method }, log); });
|
|
94
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
95
|
+
});
|
|
96
|
+
app.post("/admin/bus-writes", async (c) => {
|
|
97
|
+
const b = (await c.req.json());
|
|
98
|
+
const r = await runs.exec({ kind: "cli", label: `bus writes ${b.enabled ? "ON" : "OFF"}`, sideEffects: true, args: { enabled: b.enabled } }, async (log) => { busWrites.enabled = !!b.enabled; busWrites.changedAt = Date.now(); busWrites.by = "console"; saveSwitches(); log(`persisted → ${SWITCHES_FILE}`); log(`bus writes now ${busWrites.enabled ? "ENABLED — groundwork-console + david may send_message" : "disabled"}`); return { ...busWrites }; });
|
|
99
|
+
return c.json(r);
|
|
100
|
+
});
|
|
101
|
+
/* Send a message on the bus. Rooms require a playbook prefix (room rules); DMs are free text. `as` picks the identity. */
|
|
102
|
+
app.post("/actions/send", async (c) => {
|
|
103
|
+
const b = (await c.req.json());
|
|
104
|
+
const who = b.as === "groundwork-console" ? "groundwork-console" : "david";
|
|
105
|
+
if (!b.text?.trim())
|
|
106
|
+
return c.json({ error: "text required" }, 400);
|
|
107
|
+
if (!b.room && !b.to)
|
|
108
|
+
return c.json({ error: "room or to required" }, 400);
|
|
109
|
+
const PREFIX_RE = /^(BLOCKER|RISK|DONE|FYI|AGENT_ACTION|GO|SCOPE CHANGE|VERDICT|DAVID_DECISION)\b/;
|
|
110
|
+
if (b.room && !PREFIX_RE.test(b.text))
|
|
111
|
+
return c.json({ error: "room posts need a prefix (FYI / AGENT_ACTION / DAVID_DECISION / BLOCKER / RISK / DONE / GO / SCOPE CHANGE / VERDICT)" }, 400);
|
|
112
|
+
const target = b.to ? `→ ${b.to}` : `#${b.room.replace(/^#/, "")}`;
|
|
113
|
+
const r = await runs.exec({ kind: "bus", label: `send as ${who} ${target}`, sideEffects: true, args: { as: who, room: b.room, to: b.to, kind: b.kind, text: b.text.slice(0, 200) } }, async (log) => {
|
|
114
|
+
const out = await clientFor(who).call("send_message", { ...(b.to ? { to: b.to } : { room: b.room.replace(/^#/, "") }), text: b.text, ...(b.kind ? { kind: b.kind } : {}) }, log);
|
|
115
|
+
// Answered bookkeeping (console-owned, until the bus has inReplyTo): explicit ids from the UI, plus — for a DM as David — every open DAVID_DECISION DM from that agent.
|
|
116
|
+
if (who === "david") {
|
|
117
|
+
const ids = [...(b.answers ?? [])];
|
|
118
|
+
if (b.to) {
|
|
119
|
+
const f = path.join(cfg.coordDir, "inbox", "david.jsonl");
|
|
120
|
+
if (existsSync(f))
|
|
121
|
+
for (const l of readFileSync(f, "utf8").split("\n").filter(Boolean)) {
|
|
122
|
+
try {
|
|
123
|
+
const m = JSON.parse(l);
|
|
124
|
+
if (m.from === b.to && typeof m.text === "string" && m.text.startsWith("DAVID_DECISION") && m.id)
|
|
125
|
+
ids.push(m.id);
|
|
126
|
+
}
|
|
127
|
+
catch { /* skip */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const n = markAnswered(ids, { by: "david", to: b.to ?? b.room, note: b.text.slice(0, 80) });
|
|
131
|
+
if (n)
|
|
132
|
+
log(`answered: ${n} decision packet${n === 1 ? "" : "s"} marked (console-owned ~/.groundwork-console/answered.json)`);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
});
|
|
136
|
+
return c.json(r, r.status === "ok" ? 200 : 403);
|
|
137
|
+
});
|
|
138
|
+
/* Uploads — pasted images for DMs. Path + served URL both go into the message text as `[image: <path> · <url>]`.
|
|
139
|
+
Gated like DMs (bus writes) since the only purpose is to send them; the store is console-owned (~/.groundwork-console/uploads). */
|
|
140
|
+
app.post("/uploads", async (c) => {
|
|
141
|
+
if (!busWrites.enabled)
|
|
142
|
+
return c.json({ error: "bus writes are off — uploads are only for DMs" }, 403);
|
|
143
|
+
const form = await c.req.formData().catch(() => null);
|
|
144
|
+
const f = form?.get("file");
|
|
145
|
+
if (!(f instanceof File))
|
|
146
|
+
return c.json({ error: "multipart field 'file' required" }, 400);
|
|
147
|
+
try {
|
|
148
|
+
const apiBase = process.env.GROUNDWORK_CONSOLE_PUBLIC_API ?? `http://localhost:${port}/api`; // the API's own port — stable for agents; the web dev proxy is not
|
|
149
|
+
const u = saveUpload(Buffer.from(await f.arrayBuffer()), f.type, apiBase, new Date().toISOString().slice(0, 10));
|
|
150
|
+
return c.json(u);
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
return c.json({ error: e.message }, 400);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
app.get("/uploads/:name", (c) => {
|
|
157
|
+
const u = uploadPath(c.req.param("name"));
|
|
158
|
+
if (!u)
|
|
159
|
+
return c.json({ error: "not found" }, 404);
|
|
160
|
+
return c.body(readFileSync(u.path), 200, { "Content-Type": u.mime, "Content-Length": String(u.bytes), "Cache-Control": "public, max-age=31536000, immutable" });
|
|
161
|
+
});
|
|
162
|
+
app.get("/uploads", (c) => c.json({ dir: UPLOAD_DIR }));
|
|
163
|
+
/* Global knowledge repo (cross-project ADRs + notes) — resolved like `groundwork knowledge path`. Read-only. */
|
|
164
|
+
app.get("/knowledge", (c) => c.json(knowledgeIndex()));
|
|
165
|
+
app.get("/knowledge/doc/*", async (c) => {
|
|
166
|
+
const rel = decodeURIComponent(c.req.path.split("/knowledge/doc/")[1] ?? "");
|
|
167
|
+
const d = knowledgeDoc(rel);
|
|
168
|
+
if (!d)
|
|
169
|
+
return c.json({ error: "not found" }, 404);
|
|
170
|
+
const root = knowledgePath().path;
|
|
171
|
+
const git = await docGit(root, d.path);
|
|
172
|
+
return c.json({ path: d.path, source: d.source, git, sha: sha256(d.source), root });
|
|
173
|
+
});
|
|
174
|
+
app.get("/kit", (c) => {
|
|
175
|
+
const p = path.join(cfg.kitPath, "kit.json");
|
|
176
|
+
if (existsSync(p))
|
|
177
|
+
return c.json({ ...JSON.parse(readFileSync(p, "utf8")), source: "kit checkout", requires: requiresReport(cfg.kitPath) });
|
|
178
|
+
// No kit checkout on this machine: synthesize the manifest from what is installed (npm) so the Home strip / Release still say something true.
|
|
179
|
+
const r = requiresReport(cfg.kitPath);
|
|
180
|
+
return c.json({ name: "groundwork-kit", kitVersion: null, source: "installed packages", packages: { seam: { name: "@davidbalzan/groundwork-seam", version: r.seam.installed }, groundwork: { name: "@davidbalzan/groundwork", version: r.groundwork.installed }, playbook: { name: "agent-coordination", version: r.playbook.installed }, mcp: { name: "agent-coord-mcp", version: r.mcp.installed } }, requires: r, schemas: ["queue.v1", "done.v1", "facts.v1", "workstreams.v1"], consoleRequires: REQUIRES });
|
|
181
|
+
});
|
|
182
|
+
app.get("/projects", (c) => {
|
|
183
|
+
const sc = scopes(cfg.coordDir);
|
|
184
|
+
const kitGw = (() => { try {
|
|
185
|
+
return JSON.parse(readFileSync(path.join(cfg.kitPath, "packages/groundwork/package.json"), "utf8")).version;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
} })();
|
|
190
|
+
const liveAgents = agents(cfg.coordDir).filter((a) => a.liveness !== "dead");
|
|
191
|
+
return c.json(cfg.projects.map((p) => {
|
|
192
|
+
const b = boardOf(p.path);
|
|
193
|
+
const fleet = fleetStatus(liveAgents.filter((a) => a.project === p.room || a.project === p.name));
|
|
194
|
+
const seamM = ["WORKSTREAMS.md", "QUEUE.md", "DONE.md", "FACTS.md"].map((f) => path.join(p.path, "docs", f)).filter(existsSync).map((f) => statSync(f).mtimeMs);
|
|
195
|
+
const roomM = p.room ? (rooms(cfg.coordDir).find((r) => r.room === p.room)?.lastTs ?? 0) : 0;
|
|
196
|
+
const lastActive = Math.max(0, ...seamM, roomM) || null;
|
|
197
|
+
const lastActiveSource = lastActive === roomM ? "bus" : lastActive ? "seam" : null;
|
|
198
|
+
const markerPath = path.join(p.path, "docs/.groundwork/VERSION");
|
|
199
|
+
const versionMarker = existsSync(markerPath) ? readFileSync(markerPath, "utf8").trim() : null;
|
|
200
|
+
return { name: p.name, path: p.path, room: p.room, files: b.files, schema: b.workstreams.schema, versionMarker, kitGroundwork: kitGw, behind: !!versionMarker && !!kitGw && versionMarker !== kitGw, lastActive, lastActiveSource, fleet, counts: { streams: b.workstreams.rows.filter((r) => !("malformed" in r && r.malformed)).length, queue: b.queue.filter((q) => !q.done).length, done: b.done.length, staleFacts: b.facts?.entries.filter((e) => e.stale).length ?? 0, malformed: b.workstreams.rows.filter((r) => "malformed" in r).length }, phases: b.phases.length, phaseStrip: b.phases.map((ph) => ({ n: ph.n, state: ph.state, done: ph.done, total: ph.total })), scopesDeclared: Object.keys(sc).length };
|
|
201
|
+
}));
|
|
202
|
+
});
|
|
203
|
+
app.get("/projects/:name/board", (c) => {
|
|
204
|
+
const p = project(c.req.param("name"));
|
|
205
|
+
if (!p)
|
|
206
|
+
return c.json({ error: "unknown project" }, 404);
|
|
207
|
+
return c.json({ ...boardOf(p.path), scopes: scopes(cfg.coordDir) });
|
|
208
|
+
});
|
|
209
|
+
app.get("/projects/:name/phases/:phase/tasks", (c) => {
|
|
210
|
+
const p = project(c.req.param("name"));
|
|
211
|
+
if (!p)
|
|
212
|
+
return c.json({ error: "unknown project" }, 404);
|
|
213
|
+
const t = phaseTasksOf(path.join(p.path, "docs"), c.req.param("phase"));
|
|
214
|
+
return t ? c.json(t) : c.json({ error: "no such phase" }, 404);
|
|
215
|
+
});
|
|
216
|
+
app.get("/projects/:name/docs", async (c) => {
|
|
217
|
+
const p = project(c.req.param("name"));
|
|
218
|
+
if (!p)
|
|
219
|
+
return c.json({ error: "unknown project" }, 404);
|
|
220
|
+
const [status, last] = await Promise.all([docsStatus(p.path), lastDocsCommit(p.path)]);
|
|
221
|
+
return c.json({ files: docsIndex(p.path), status, lastCommit: last });
|
|
222
|
+
});
|
|
223
|
+
app.get("/projects/:name/docs/*", async (c) => {
|
|
224
|
+
const p = project(c.req.param("name"));
|
|
225
|
+
if (!p)
|
|
226
|
+
return c.json({ error: "unknown project" }, 404);
|
|
227
|
+
const rel = decodeURIComponent(c.req.path.split(`/docs/`)[1] ?? "");
|
|
228
|
+
const src = docOf(p.path, rel);
|
|
229
|
+
if (src === null)
|
|
230
|
+
return c.json({ error: "not found" }, 404);
|
|
231
|
+
const git = await docGit(p.path, `docs/${rel}`);
|
|
232
|
+
return c.json({ path: `docs/${rel}`, source: src, git, sha: sha256(src) });
|
|
233
|
+
});
|
|
234
|
+
app.put("/projects/:name/docs/*", async (c) => {
|
|
235
|
+
const p = project(c.req.param("name"));
|
|
236
|
+
if (!p)
|
|
237
|
+
return c.json({ error: "unknown project" }, 404);
|
|
238
|
+
if (!seamWrites.enabled)
|
|
239
|
+
return c.json({ error: "seam writes are off (header → seam)" }, 403);
|
|
240
|
+
const rel = decodeURIComponent(c.req.path.split(`/docs/`)[1] ?? "");
|
|
241
|
+
const b = (await c.req.json().catch(() => ({})));
|
|
242
|
+
if (typeof b.content !== "string")
|
|
243
|
+
return c.json({ error: "content (string) required" }, 400);
|
|
244
|
+
if (!b.ifMatch || typeof b.ifMatch !== "string")
|
|
245
|
+
return c.json({ error: "ifMatch (sha256 of last GET) required" }, 400);
|
|
246
|
+
if (b.content.length > 2_000_000)
|
|
247
|
+
return c.json({ error: "content exceeds 2 MB" }, 400);
|
|
248
|
+
const content = b.content;
|
|
249
|
+
const ifMatch = b.ifMatch;
|
|
250
|
+
const r = await runs.exec({ kind: "seam", label: `docs write ${rel}`, project: p.name, sideEffects: true, args: { file: rel, bytes: Buffer.byteLength(content, "utf8") } }, async (log) => {
|
|
251
|
+
const sc = scopeAllows(scopes(cfg.coordDir), `docs/${rel}`);
|
|
252
|
+
log(`scope: ${sc.reason}`);
|
|
253
|
+
if (!sc.ok)
|
|
254
|
+
throw new DocWriteError(sc.reason, 403);
|
|
255
|
+
return writeDoc(p.path, rel, content, ifMatch, log);
|
|
256
|
+
});
|
|
257
|
+
const result = r.result;
|
|
258
|
+
if (r.status !== "ok") {
|
|
259
|
+
const err = r.error ?? "write failed";
|
|
260
|
+
const status = err.startsWith("conflict:") ? 409 : err === "not found" ? 404 : /scope owner is|seam writes are off/.test(err) ? 403 : 400;
|
|
261
|
+
const sha = status === 409 ? sha256(docOf(p.path, rel) ?? "") : undefined;
|
|
262
|
+
return c.json({ error: err, sha, run: r }, status);
|
|
263
|
+
}
|
|
264
|
+
return c.json({ sha: result?.sha, run: r });
|
|
265
|
+
});
|
|
266
|
+
app.get("/projects/:name/doctor", async (c) => {
|
|
267
|
+
const p = project(c.req.param("name"));
|
|
268
|
+
if (!p)
|
|
269
|
+
return c.json({ error: "unknown project" }, 404);
|
|
270
|
+
return c.json(await doctor(cfg.kitPath, p.path));
|
|
271
|
+
});
|
|
272
|
+
app.get("/projects/:name/status", async (c) => {
|
|
273
|
+
const p = project(c.req.param("name"));
|
|
274
|
+
if (!p)
|
|
275
|
+
return c.json({ error: "unknown project" }, 404);
|
|
276
|
+
return c.json(await status(cfg.kitPath, p.path));
|
|
277
|
+
});
|
|
278
|
+
app.get("/projects/:name/skills", async (c) => {
|
|
279
|
+
const p = project(c.req.param("name"));
|
|
280
|
+
if (!p)
|
|
281
|
+
return c.json({ error: "unknown project" }, 404);
|
|
282
|
+
const [gl, pl] = await Promise.all([list(cfg.kitPath, p.path), playbookList(cfg.kitPath)]);
|
|
283
|
+
const sync = groundworkSkills(cfg.kitPath, p.path);
|
|
284
|
+
const cards = playbookCards(cfg.kitPath);
|
|
285
|
+
return c.json({
|
|
286
|
+
groundwork: { versionMarker: gl.version || sync.versionMarker, kitVersion: sync.kitVersion, driftedVersion: (gl.version || sync.versionMarker) !== sync.kitVersion, error: gl.error,
|
|
287
|
+
skills: gl.skills.map((s) => ({ ...s, tier: s.set, inSync: sync.skills.find((x) => x.name === s.name)?.inSync ?? null })) },
|
|
288
|
+
playbook: { kitVersion: pl.version || cards.kitVersion, globalDir: cards.globalDir, homes: cards.homes, legacyDirs: cards.legacyDirs, error: pl.error,
|
|
289
|
+
cards: pl.cards.map((k) => { const x = cards.cards.find((y) => y.name === k.name); const kh = k.harness; return { ...k, inSync: x?.inSync ?? null, slices: x?.slices ?? 0, legacyEssay: x?.legacyEssay ?? false, homes: x?.homes ?? {}, mirrors: { cursor: kh.cursor, vscode: kh.vscode, opencode: kh.opencode, grok: kh.grok ?? x?.homes?.grok?.installed ?? false } }; }) },
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
app.get("/projects/:name/release", (c) => {
|
|
293
|
+
const p = project(c.req.param("name"));
|
|
294
|
+
if (!p)
|
|
295
|
+
return c.json({ error: "unknown project" }, 404);
|
|
296
|
+
const ws = path.join(p.path, "docs/WORKSTREAMS.md");
|
|
297
|
+
const src = existsSync(ws) ? readFileSync(ws, "utf8") : null;
|
|
298
|
+
const room = p.room ? roomMessages(cfg.coordDir, p.room, 200) : [];
|
|
299
|
+
const verdicts = room.filter((m) => typeof m.text === "string" && /^(VERDICT|DONE)\b/.test(m.text)).slice(-20).reverse();
|
|
300
|
+
const ext = src ? workstreamsExtensionsOf(parseWorkDoc(src)) : null;
|
|
301
|
+
const toSec = (title, rows) => ({ title, table: rows.length ? { headers: Object.keys(rows[0]), rows } : undefined, raw: "" });
|
|
302
|
+
const extensions = ext ? [toSec("Open PRs", ext.openPrs), toSec("Cutover Gates", ext.cutoverGates), toSec("Needs David", ext.needsDavid), toSec("Risks", ext.risks), toSec("Rooms", ext.rooms), { title: "Decisions Recorded", bullets: ext.decisions, raw: "" }].filter((s) => s.table || s.bullets?.length) : extensionsOf(src);
|
|
303
|
+
return c.json({ extensions, telemetry: verdicts, source: ext ? "seam workstreamsExtensionsOf" : "interim parser" });
|
|
304
|
+
});
|
|
305
|
+
app.get("/needs-david", (c) => {
|
|
306
|
+
const out = [];
|
|
307
|
+
for (const p of cfg.projects) {
|
|
308
|
+
const ws = path.join(p.path, "docs/WORKSTREAMS.md");
|
|
309
|
+
const wsSrc = existsSync(ws) ? readFileSync(ws, "utf8") : null;
|
|
310
|
+
const rows = wsSrc ? workstreamsExtensionsOf(parseWorkDoc(wsSrc)).needsDavid : [];
|
|
311
|
+
const wsTs = existsSync(ws) ? statSync(ws).mtimeMs : undefined; // best available age for a table row: when the board file last changed
|
|
312
|
+
for (const r of rows) {
|
|
313
|
+
const [title, ...rest] = Object.values(r);
|
|
314
|
+
if (title)
|
|
315
|
+
out.push({ project: p.name, source: "WORKSTREAMS ## Needs David", title, detail: rest.filter(Boolean).join(" · "), ts: wsTs });
|
|
316
|
+
}
|
|
317
|
+
if (p.room)
|
|
318
|
+
for (const m of roomMessages(cfg.coordDir, p.room, 100))
|
|
319
|
+
if (typeof m.text === "string" && m.text.startsWith("DAVID_DECISION"))
|
|
320
|
+
out.push({ project: p.name, source: `#${p.room} DAVID_DECISION`, title: m.text.replace(/^DAVID_DECISION:?\s*/, "").slice(0, 200), ts: m.ts, from: m.from, msgId: m.id, room: p.room });
|
|
321
|
+
}
|
|
322
|
+
// DAVID_DECISION DMs (inbox/david.jsonl) — the aide's decision packets arrive here, not in a room.
|
|
323
|
+
const inboxFile = path.join(cfg.coordDir, "inbox", "david.jsonl");
|
|
324
|
+
if (existsSync(inboxFile)) {
|
|
325
|
+
const ag = agents(cfg.coordDir);
|
|
326
|
+
for (const l of readFileSync(inboxFile, "utf8").split("\n").filter(Boolean)) {
|
|
327
|
+
let m;
|
|
328
|
+
try {
|
|
329
|
+
m = JSON.parse(l);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (typeof m.text !== "string" || !m.text.startsWith("DAVID_DECISION"))
|
|
335
|
+
continue;
|
|
336
|
+
const from = String(m.from ?? "");
|
|
337
|
+
const ap = ag.find((a) => a.agentId === from)?.project;
|
|
338
|
+
const proj = cfg.projects.find((p) => p.name === ap || p.room === ap)?.name ?? ap ?? "—";
|
|
339
|
+
out.push({ project: proj, source: `DM from ${from} · DAVID_DECISION`, title: m.text.replace(/^DAVID_DECISION:?\s*/, "").slice(0, 200), ts: m.ts, from, msgId: m.id });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return c.json(out.filter((n) => !isAnswered(n.msgId)).sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0)));
|
|
343
|
+
});
|
|
344
|
+
app.get("/events", (c) => c.json({ config: events.config, recent: events.recent(Number(c.req.query("limit") ?? 100)) }));
|
|
345
|
+
app.post("/events/config", async (c) => {
|
|
346
|
+
const b = (await c.req.json());
|
|
347
|
+
if (b.dryRun === false && b.confirm !== "LIVE")
|
|
348
|
+
return c.json({ error: "confirm must be LIVE to turn dryRun off" }, 400);
|
|
349
|
+
const r = await runs.exec({ kind: "cli", label: b.rule ? `rule ${b.rule} ${b.enabled ? "enabled" : "disabled"}` : `events dryRun ${b.dryRun ? "ON" : "OFF"}`, sideEffects: true, args: b }, async (log) => {
|
|
350
|
+
if (typeof b.dryRun === "boolean") {
|
|
351
|
+
events.setDryRun(b.dryRun);
|
|
352
|
+
log(`dryRun=${b.dryRun}`);
|
|
353
|
+
}
|
|
354
|
+
if (b.rule) {
|
|
355
|
+
events.setRuleEnabled(b.rule, !!b.enabled);
|
|
356
|
+
log(`rule ${b.rule} enabled=${!!b.enabled}`);
|
|
357
|
+
}
|
|
358
|
+
log(`live sends: ${events.config.live ? "YES (writes on + dryRun off) for enabled rules" : "no"}`);
|
|
359
|
+
return events.config;
|
|
360
|
+
});
|
|
361
|
+
return c.json(r, r.status === "ok" ? 200 : 400);
|
|
362
|
+
});
|
|
363
|
+
app.get("/events/stream", (c) => {
|
|
364
|
+
const stream = new ReadableStream({
|
|
365
|
+
start(controller) {
|
|
366
|
+
const enc = new TextEncoder();
|
|
367
|
+
const send = (e) => controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
368
|
+
send({ type: "hello", config: events.config });
|
|
369
|
+
const off = events.subscribe(send);
|
|
370
|
+
const ka = setInterval(() => controller.enqueue(enc.encode(": keepalive\n\n")), 25_000);
|
|
371
|
+
c.req.raw.signal.addEventListener("abort", () => { off(); clearInterval(ka); try {
|
|
372
|
+
controller.close();
|
|
373
|
+
}
|
|
374
|
+
catch { /* closed */ } });
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" } });
|
|
378
|
+
});
|
|
379
|
+
/* ---- Project registry (console-owned config; writing it is allowed) ---- */
|
|
380
|
+
/* Path completion for Add project: directories under the typed prefix (home-relative), flagged with docs/ + registered. Read-only, never lists files. */
|
|
381
|
+
app.get("/projects/browse", (c) => {
|
|
382
|
+
const raw = c.req.query("prefix") ?? "~/";
|
|
383
|
+
const home = process.env.HOME ?? "";
|
|
384
|
+
const expand = (p) => (p.startsWith("~") ? path.join(home, p.slice(1)) : p);
|
|
385
|
+
const abs = expand(raw);
|
|
386
|
+
const dir = raw.endsWith("/") ? abs : path.dirname(abs);
|
|
387
|
+
const part = raw.endsWith("/") ? "" : path.basename(abs).toLowerCase();
|
|
388
|
+
if (!existsSync(dir))
|
|
389
|
+
return c.json({ dirs: [] });
|
|
390
|
+
const registered = new Set(cfg.projects.map((p) => p.path));
|
|
391
|
+
const dirs = readdirSync(dir, { withFileTypes: true })
|
|
392
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name.toLowerCase().startsWith(part))
|
|
393
|
+
.slice(0, 40)
|
|
394
|
+
.map((e) => { const full = path.join(dir, e.name); const shown = raw.startsWith("~") ? "~" + full.slice(home.length) : full; return { path: shown + "/", name: e.name, hasDocs: existsSync(path.join(full, "docs")), isGit: existsSync(path.join(full, ".git")), registered: registered.has(full) }; })
|
|
395
|
+
.sort((a, b) => Number(b.hasDocs) - Number(a.hasDocs) || a.name.localeCompare(b.name));
|
|
396
|
+
return c.json({ dirs });
|
|
397
|
+
});
|
|
398
|
+
app.post("/projects/validate", async (c) => { const b = (await c.req.json()); return c.json(validateProjectPath(b.path ?? "")); });
|
|
399
|
+
app.post("/projects", async (c) => {
|
|
400
|
+
const b = (await c.req.json());
|
|
401
|
+
if (!b.name || !/^[a-z0-9][a-z0-9-_]*$/i.test(b.name))
|
|
402
|
+
return c.json({ error: "name: letters, digits, - _" }, 400);
|
|
403
|
+
if (cfg.projects.some((p) => p.name === b.name))
|
|
404
|
+
return c.json({ error: "name already registered" }, 409);
|
|
405
|
+
const v = validateProjectPath(b.path ?? "");
|
|
406
|
+
if (!v.ok)
|
|
407
|
+
return c.json({ error: v.reason }, 400);
|
|
408
|
+
const next = [...cfg.projects, { name: b.name, path: v.path, room: b.room || undefined }];
|
|
409
|
+
const r = await runs.exec({ kind: "cli", label: `add project · ${b.name}`, project: b.name, sideEffects: true, args: { path: v.path, room: b.room } }, async (log) => { saveProjects(next); cfg = loadConfig(); log(`groundwork-console.config.json: +${b.name} (${v.path})${b.room ? ` · room #${b.room}` : ""}`); return { added: b.name, seam: v.hasSeam, isGit: v.isGit }; });
|
|
410
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
411
|
+
});
|
|
412
|
+
app.delete("/projects/:name", async (c) => {
|
|
413
|
+
const name = c.req.param("name");
|
|
414
|
+
if (!cfg.projects.some((p) => p.name === name))
|
|
415
|
+
return c.json({ error: "unknown project" }, 404);
|
|
416
|
+
const r = await runs.exec({ kind: "cli", label: `remove project · ${name}`, project: name, sideEffects: true }, async (log) => { saveProjects(cfg.projects.filter((p) => p.name !== name)); cfg = loadConfig(); log(`groundwork-console.config.json: -${name} (repo untouched)`); return { removed: name }; });
|
|
417
|
+
return c.json(r);
|
|
418
|
+
});
|
|
419
|
+
/* ---- Decommission a fleet + channel: bus writes, gated by GROUNDWORK_CONSOLE_BUS_WRITES ---- */
|
|
420
|
+
app.post("/actions/decommission", async (c) => {
|
|
421
|
+
const b = (await c.req.json());
|
|
422
|
+
const room = b.room;
|
|
423
|
+
if (!room)
|
|
424
|
+
return c.json({ error: "room required" }, 400);
|
|
425
|
+
if (b.confirm !== room)
|
|
426
|
+
return c.json({ error: "confirm must equal the room name" }, 400);
|
|
427
|
+
const dry = b.dryRun ?? !busWrites.enabled;
|
|
428
|
+
const members = (rooms(cfg.coordDir).find((r) => r.room === room)?.members ?? []);
|
|
429
|
+
const proj = cfg.projects.find((p) => p.room === room);
|
|
430
|
+
const agentsOf = agents(cfg.coordDir).filter((a) => members.includes(a.agentId) || a.project === room || (proj && a.project === proj.name)).map((a) => a.agentId);
|
|
431
|
+
const r = await runs.exec({ kind: "mcp", label: `decommission #${room}${dry ? " (dry-run)" : ""}`, project: proj?.name, sideEffects: !dry, args: { room, agents: agentsOf, removeProject: !!b.removeProject, dryRun: dry } }, async (log) => {
|
|
432
|
+
log(`plan: force_unregister ${agentsOf.length} agent(s) → delete_room #${room}${b.removeProject && proj ? ` → remove project ${proj.name} from config` : ""}`);
|
|
433
|
+
if (dry) {
|
|
434
|
+
log("dry-run: bus writes are off; nothing sent");
|
|
435
|
+
return { dryRun: true, wouldUnregister: agentsOf, wouldDeleteRoom: room, wouldRemoveProject: b.removeProject ? proj?.name : null };
|
|
436
|
+
}
|
|
437
|
+
for (const id of agentsOf) {
|
|
438
|
+
await bus.call("force_unregister", { targetAgentId: id }, log);
|
|
439
|
+
}
|
|
440
|
+
await bus.call("delete_room", { room, force: true }, log);
|
|
441
|
+
if (b.removeProject && proj) {
|
|
442
|
+
saveProjects(cfg.projects.filter((p) => p.name !== proj.name));
|
|
443
|
+
cfg = loadConfig();
|
|
444
|
+
log(`config: -${proj.name}`);
|
|
445
|
+
}
|
|
446
|
+
return { unregistered: agentsOf, deletedRoom: room, removedProject: b.removeProject ? proj?.name : null };
|
|
447
|
+
});
|
|
448
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
449
|
+
});
|
|
450
|
+
/* ---- Inbox as david (read-only tool, but needs the david identity → session spawns even with writes off) ---- */
|
|
451
|
+
/* Inbox as david: peek reads (not Runs) with the bus's own unread count (`totalNew` vs david's cursor); marking read advances the cursor (a Run). */
|
|
452
|
+
app.get("/bus/inbox", async (c) => {
|
|
453
|
+
try {
|
|
454
|
+
const out = (await busDavid.call("read_messages", { source: "inbox", peek: true, limit: Number(c.req.query("limit") ?? 100) }));
|
|
455
|
+
// peek returns only unread; add the already-read tail from the file so the view is complete, flagged.
|
|
456
|
+
const file = path.join(cfg.coordDir, "inbox", "david.jsonl");
|
|
457
|
+
const all = existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => { try {
|
|
458
|
+
return JSON.parse(l);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return null;
|
|
462
|
+
} }).filter(Boolean) : [];
|
|
463
|
+
const unreadIds = new Set(out.messages.map((m) => m.id));
|
|
464
|
+
const messages = all.map((m) => ({ ...m, unread: unreadIds.has(m.id) && !isAnswered(m.id), answered: isAnswered(m.id), prefix: prefixOf(String(m.text ?? "")) }));
|
|
465
|
+
// Pending decisions per sender: unread, unanswered DAVID_DECISION DMs — the avatar cue.
|
|
466
|
+
const decisions = {};
|
|
467
|
+
for (const m of messages)
|
|
468
|
+
if (m.unread && m.prefix === "DAVID_DECISION" && typeof m.from === "string")
|
|
469
|
+
decisions[m.from] = (decisions[m.from] ?? 0) + 1;
|
|
470
|
+
return c.json({ ok: true, unread: messages.filter((m) => m.unread).length, messages, decisions });
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
return c.json({ error: e.message }, 500);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
/* DM thread with one agent, both directions: their DMs to david (inbox/david.jsonl) + david's DMs to them (inbox/<agent>.jsonl).
|
|
477
|
+
Read-only file scan (no cursor consumed) — this is a view, the Bus inbox stays the place that marks read. */
|
|
478
|
+
app.get("/bus/answered", (c) => c.json(answered()));
|
|
479
|
+
app.post("/bus/answered", async (c) => { const b = (await c.req.json()); const n = markAnswered(b.ids ?? [], { by: "david", note: b.note }); return c.json({ ok: true, marked: n }); });
|
|
480
|
+
app.get("/bus/dm/:agent", (c) => {
|
|
481
|
+
const agent = c.req.param("agent");
|
|
482
|
+
const limit = Number(c.req.query("limit") ?? 80);
|
|
483
|
+
const readJsonl = (f) => existsSync(f) ? readFileSync(f, "utf8").split("\n").filter(Boolean).map((l) => { try {
|
|
484
|
+
return JSON.parse(l);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
return null;
|
|
488
|
+
} }).filter(Boolean) : [];
|
|
489
|
+
const mine = readJsonl(path.join(cfg.coordDir, "inbox", "david.jsonl")).filter((m) => m.from === agent);
|
|
490
|
+
const theirs = readJsonl(path.join(cfg.coordDir, "inbox", `${agent}.jsonl`)).filter((m) => m.from === "david");
|
|
491
|
+
const messages = [...mine, ...theirs].sort((a, b) => Number(a.ts ?? 0) - Number(b.ts ?? 0)).slice(-limit).map((m) => ({ ...m, prefix: prefixOf(String(m.text ?? "")) }));
|
|
492
|
+
return c.json({ agent, messages, lastFromAgent: mine.length ? Number(mine[mine.length - 1].ts ?? 0) : 0 });
|
|
493
|
+
});
|
|
494
|
+
app.post("/bus/inbox/read", async (c) => {
|
|
495
|
+
const r = await runs.exec({ kind: "mcp", label: "mark inbox read · david", sideEffects: true, args: { source: "inbox" } }, (log) => busDavid.call("read_messages", { source: "inbox", peek: false, limit: 500 }, log));
|
|
496
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
497
|
+
});
|
|
498
|
+
app.get("/bus/unread", async (c) => {
|
|
499
|
+
try {
|
|
500
|
+
const out = (await busDavid.call("read_messages", { source: "inbox", peek: true, limit: 200 }));
|
|
501
|
+
return c.json({ inbox: out.messages.filter((m) => !isAnswered(m.id)).length });
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return c.json({ inbox: 0, error: "inbox unavailable" });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
/* ---- CLI updates (repo / global writes) as shell Runs; playbook has native --dry-run; groundwork update reports git status after ---- */
|
|
508
|
+
app.post("/actions/groundwork-update/:name", async (c) => {
|
|
509
|
+
const p = project(c.req.param("name"));
|
|
510
|
+
if (!p)
|
|
511
|
+
return c.json({ error: "unknown project" }, 404);
|
|
512
|
+
const b = (await c.req.json().catch(() => ({})));
|
|
513
|
+
if (b.confirm !== p.name)
|
|
514
|
+
return c.json({ error: "confirm must equal the project name" }, 400);
|
|
515
|
+
const cli = groundworkCli(cfg.kitPath) ?? path.join(cfg.kitPath, "packages/groundwork/src/cli.mjs");
|
|
516
|
+
const argv = [cli, "update", p.path, ...(b.all ? ["--all"] : []), ...(b.docs ? ["--docs"] : [])];
|
|
517
|
+
const r = await runs.exec({ kind: "shell", label: `groundwork update · ${p.name}${b.all ? " --all" : ""}${b.docs ? " --docs" : ""}`, project: p.name, sideEffects: true, args: { argv: argv.slice(1) } }, async (log) => {
|
|
518
|
+
log(`$ node ${argv.map((a) => a.replace(cfg.kitPath, "<kit>").replace(p.path, "<repo>")).join(" ")}`);
|
|
519
|
+
const out = await execP("node", argv, { timeout: 120_000, maxBuffer: 8 << 20 }).catch((e) => { log(`exit: ${e.message}`); return { stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; });
|
|
520
|
+
for (const l of (out.stdout + out.stderr).replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean).slice(0, 80))
|
|
521
|
+
log(l);
|
|
522
|
+
const st = await execP("git", ["status", "--porcelain"], { cwd: p.path }).then((x) => x.stdout).catch(() => "");
|
|
523
|
+
const changed = st.split("\n").filter(Boolean);
|
|
524
|
+
log(`git status: ${changed.length} path(s) changed`);
|
|
525
|
+
return { changed, cli: "groundwork update" };
|
|
526
|
+
});
|
|
527
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
528
|
+
});
|
|
529
|
+
app.post("/actions/playbook-update", async (c) => {
|
|
530
|
+
const b = (await c.req.json().catch(() => ({})));
|
|
531
|
+
const dry = b.dryRun !== false;
|
|
532
|
+
if (!dry && b.confirm !== "UPDATE")
|
|
533
|
+
return c.json({ error: "confirm must be UPDATE to apply" }, 400);
|
|
534
|
+
const cli = path.join(cfg.kitPath, "packages/coord-playbook/bin/install.mjs");
|
|
535
|
+
const argv = [cli, "update", ...(dry ? ["--dry-run"] : [])];
|
|
536
|
+
const r = await runs.exec({ kind: "shell", label: `agent-coordination update${dry ? " (dry-run)" : ""}`, sideEffects: !dry, args: { argv: argv.slice(1) } }, async (log) => {
|
|
537
|
+
log(`$ node ${argv.map((a) => a.replace(cfg.kitPath, "<kit>")).join(" ")}`);
|
|
538
|
+
const out = await execP("node", argv, { timeout: 120_000, maxBuffer: 8 << 20 }).catch((e) => { log(`exit: ${e.message}`); return { stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; });
|
|
539
|
+
const lines = (out.stdout + out.stderr).replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter(Boolean);
|
|
540
|
+
for (const l of lines.slice(0, 120))
|
|
541
|
+
log(l);
|
|
542
|
+
return { lines: lines.length, dryRun: dry };
|
|
543
|
+
});
|
|
544
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
545
|
+
});
|
|
546
|
+
/* ---- Spawn agent (v0.3): tmux pane + harness + role skill; gated by bus writes; a shell Run ---- */
|
|
547
|
+
app.get("/projects/:name/fleet", async (c) => {
|
|
548
|
+
const p = project(c.req.param("name"));
|
|
549
|
+
if (!p)
|
|
550
|
+
return c.json({ error: "unknown project" }, 404);
|
|
551
|
+
const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.liveness !== "dead" && (a.project === p.room || a.project === p.name));
|
|
552
|
+
return c.json({ ...fleetStatus(live), templates: FLEET_TEMPLATES, window: `fleet-${p.name}` });
|
|
553
|
+
});
|
|
554
|
+
app.post("/actions/spawn-fleet", async (c) => {
|
|
555
|
+
if (!busWrites.enabled)
|
|
556
|
+
return c.json({ error: "bus writes are off (header toggle)" }, 403);
|
|
557
|
+
const b = (await c.req.json());
|
|
558
|
+
const p = project(b.project);
|
|
559
|
+
if (!p)
|
|
560
|
+
return c.json({ error: "unknown project" }, 404);
|
|
561
|
+
const t = FLEET_TEMPLATES.find((x) => x.id === b.template);
|
|
562
|
+
if (!t)
|
|
563
|
+
return c.json({ error: "unknown template" }, 400);
|
|
564
|
+
if (!HARNESS_CMD[b.harness])
|
|
565
|
+
return c.json({ error: "harness invalid" }, 400);
|
|
566
|
+
const ids = () => agents(cfg.coordDir).map((a) => a.agentId);
|
|
567
|
+
const attached = (id) => existsSync(path.join(cfg.coordDir, "transports", `${id}.json`));
|
|
568
|
+
const workers = Math.min(Math.max(b.workers ?? t.workers, 0), 8);
|
|
569
|
+
const plan = [...t.roles.map((role) => ({ role, harness: role === "ci" ? (b.ciHarness ?? b.harness) : b.harness })), ...Array.from({ length: workers }, (_, i) => ({ role: "worker", harness: b.harness, lane: b.lanes?.[i] }))];
|
|
570
|
+
const r = runs.execAsync({ kind: "shell", label: `spawn fleet · ${t.id} · ${p.name} (${plan.length} agents)`, project: p.name, sideEffects: true, args: { template: t.id, harness: b.harness, workers, lanes: b.lanes } }, async (log) => {
|
|
571
|
+
const out = [];
|
|
572
|
+
for (const step of plan) {
|
|
573
|
+
const agentId = suggestId(p.name, step.role, [...ids(), ...out.map((o) => o.agentId)]);
|
|
574
|
+
log(`── ${step.role} → ${agentId} (${step.harness})`);
|
|
575
|
+
const res = await spawnAgent({ project: p.name, repoPath: p.path, room: p.room, role: step.role, harness: step.harness, agentId, lane: step.lane, waitSeconds: b.waitSeconds }, (id) => ids().includes(id), log, attached);
|
|
576
|
+
out.push({ agentId: res.agentId, pane: res.pane, registered: res.registered, attached: res.attached });
|
|
577
|
+
}
|
|
578
|
+
log(`fleet ${t.id}: ${out.filter((o) => o.registered).length}/${out.length} registered · ${out.filter((o) => o.attached).length}/${out.length} attached`);
|
|
579
|
+
return { template: t.id, agents: out };
|
|
580
|
+
});
|
|
581
|
+
return c.json(r);
|
|
582
|
+
});
|
|
583
|
+
app.get("/actions/spawn/options", (c) => {
|
|
584
|
+
const p = c.req.query("project") ? project(c.req.query("project")) : undefined;
|
|
585
|
+
const ids = agents(cfg.coordDir).map((a) => a.agentId);
|
|
586
|
+
return c.json({ harnesses: Object.entries(HARNESS_CMD).map(([k, v]) => ({ id: k, cmd: v })), roles: Object.entries(ROLE_SKILL).map(([k, v]) => ({ id: k, skill: v })), suggest: p ? Object.fromEntries(Object.keys(ROLE_SKILL).map((r) => [r, suggestId(p.name, r, ids)])) : {}, room: p?.room ?? null, repoPath: p?.path ?? null, tmux: !!process.env.TMUX || true });
|
|
587
|
+
});
|
|
588
|
+
app.post("/actions/spawn", async (c) => {
|
|
589
|
+
if (!busWrites.enabled)
|
|
590
|
+
return c.json({ error: "bus writes are off (header toggle)" }, 403);
|
|
591
|
+
const b = (await c.req.json());
|
|
592
|
+
const p = project(b.project);
|
|
593
|
+
if (!p)
|
|
594
|
+
return c.json({ error: "unknown project" }, 404);
|
|
595
|
+
if (!ROLE_SKILL[b.role] || !HARNESS_CMD[b.harness])
|
|
596
|
+
return c.json({ error: "role/harness invalid" }, 400);
|
|
597
|
+
const ids = () => agents(cfg.coordDir).map((a) => a.agentId);
|
|
598
|
+
const attached = (id) => existsSync(path.join(cfg.coordDir, "transports", `${id}.json`));
|
|
599
|
+
const agentId = b.agentId?.trim() || suggestId(p.name, b.role, ids());
|
|
600
|
+
const r = runs.execAsync({ kind: "shell", label: `spawn ${agentId} (${b.role} · ${b.harness}) → fleet-${p.name}`, project: p.name, sideEffects: true, args: { role: b.role, harness: b.harness, agentId, lane: b.lane, model: b.model } }, (log) => spawnAgent({ project: p.name, repoPath: p.path, room: p.room, role: b.role, harness: b.harness, agentId, lane: b.lane, model: b.model, note: b.note, waitSeconds: b.waitSeconds }, (id) => ids().includes(id), log, attached));
|
|
601
|
+
return c.json(r);
|
|
602
|
+
});
|
|
603
|
+
/* ---- Gated bus admin: prune stale, unregister one (writes; require switch + confirm) ---- */
|
|
604
|
+
app.post("/actions/prune", async (c) => {
|
|
605
|
+
const b = (await c.req.json().catch(() => ({})));
|
|
606
|
+
const dry = b.dryRun !== false;
|
|
607
|
+
if (!dry && b.confirm !== "PRUNE")
|
|
608
|
+
return c.json({ error: "confirm must be PRUNE to apply" }, 400);
|
|
609
|
+
const args = { ...(b.olderThanDays ? { olderThanDays: b.olderThanDays } : {}), ...(b.targets?.length ? { targets: b.targets } : {}), ...(b.room ? { room: b.room } : {}), ...(b.removeOrphanInboxes ? { removeOrphanInboxes: true } : {}), ...(b.archiveEmptyRooms ? { archiveEmptyRooms: true } : {}), dryRun: dry };
|
|
610
|
+
const r = await runs.exec({ kind: "mcp", label: `prune bus data${dry ? " (dry-run)" : ""}`, sideEffects: !dry, args }, (log) => bus.call("prune", args, log));
|
|
611
|
+
return c.json(r, r.status === "ok" ? 200 : 403);
|
|
612
|
+
});
|
|
613
|
+
app.post("/actions/unregister-dead", async (c) => {
|
|
614
|
+
const b = (await c.req.json().catch(() => ({})));
|
|
615
|
+
const dead = agents(cfg.coordDir).filter((a) => a.liveness === "dead" && !["groundwork-console", "david"].includes(a.agentId)).map((a) => a.agentId);
|
|
616
|
+
const ids = (b.agentIds?.length ? b.agentIds.filter((id) => dead.includes(id)) : dead);
|
|
617
|
+
if (b.confirm !== "UNREGISTER")
|
|
618
|
+
return c.json({ error: "confirm must be UNREGISTER", wouldUnregister: ids }, 400);
|
|
619
|
+
const r = await runs.exec({ kind: "mcp", label: `unregister ${ids.length} dead agent(s)`, sideEffects: true, args: { agentIds: ids } }, async (log) => { const done = []; for (const id of ids) {
|
|
620
|
+
await bus.call("force_unregister", { targetAgentId: id }, log);
|
|
621
|
+
done.push(id);
|
|
622
|
+
} return { unregistered: done }; });
|
|
623
|
+
return c.json(r, r.status === "ok" ? 200 : 403);
|
|
624
|
+
});
|
|
625
|
+
app.post("/actions/unregister", async (c) => {
|
|
626
|
+
const b = (await c.req.json());
|
|
627
|
+
if (!b.agentId)
|
|
628
|
+
return c.json({ error: "agentId required" }, 400);
|
|
629
|
+
if (b.confirm !== b.agentId)
|
|
630
|
+
return c.json({ error: "confirm must equal agentId" }, 400);
|
|
631
|
+
if (b.agentId === "groundwork-console" || b.agentId === "david")
|
|
632
|
+
return c.json({ error: "refusing to unregister the console's own identities" }, 400);
|
|
633
|
+
const r = await runs.exec({ kind: "mcp", label: `unregister ${b.agentId}`, sideEffects: true, args: { agentId: b.agentId } }, (log) => bus.call("force_unregister", { targetAgentId: b.agentId }, log));
|
|
634
|
+
return c.json(r, r.status === "ok" ? 200 : 403);
|
|
635
|
+
});
|
|
636
|
+
/* ---- Roadmap actions: message the project's aide (plan) or coordinator (kick off) as david ---- */
|
|
637
|
+
const roleAgent = (projectName, room, roleRe) => agents(cfg.coordDir).find((a) => roleRe.test(a.role ?? "") && (a.project === room || a.project === projectName) && a.liveness !== "dead")?.agentId ?? null;
|
|
638
|
+
app.get("/projects/:name/agents", (c) => {
|
|
639
|
+
const p = project(c.req.param("name"));
|
|
640
|
+
if (!p)
|
|
641
|
+
return c.json({ error: "unknown project" }, 404);
|
|
642
|
+
return c.json({ aide: roleAgent(p.name, p.room, /aide|liaison|curator/i), coordinator: roleAgent(p.name, p.room, /coordinator/i), qa: roleAgent(p.name, p.room, /\bqa\b|quality/i) });
|
|
643
|
+
});
|
|
644
|
+
app.post("/actions/phase/:name/:phase/:verb", async (c) => {
|
|
645
|
+
const p = project(c.req.param("name"));
|
|
646
|
+
if (!p)
|
|
647
|
+
return c.json({ error: "unknown project" }, 404);
|
|
648
|
+
const phase = c.req.param("phase");
|
|
649
|
+
const verb = c.req.param("verb");
|
|
650
|
+
const b = (await c.req.json().catch(() => ({})));
|
|
651
|
+
const target = verb === "plan" ? roleAgent(p.name, p.room, /aide|liaison|curator/i) : roleAgent(p.name, p.room, /coordinator/i);
|
|
652
|
+
if (!target)
|
|
653
|
+
return c.json({ error: `no live ${verb === "plan" ? "aide" : "coordinator"} for ${p.name}` }, 409);
|
|
654
|
+
const n = phase.match(/(\d+(?:\.\d+)?)/)?.[1] ?? phase;
|
|
655
|
+
const text = verb === "plan"
|
|
656
|
+
? `AGENT_ACTION: plan ${phase} in ${p.name} — run /plan-phase ${n} (PRODUCTION_ROADMAP → docs/phases/${phase}/PHASE${n}_TASKS.md), then append one queue.v1 item.${b.note ? " Note: " + b.note : ""}`
|
|
657
|
+
: verb === "kickoff" ? `GO: start ${phase} in ${p.name} — route the first tasks to lane owners; board row per stream.${b.note ? " Note: " + b.note : ""}`
|
|
658
|
+
: verb === "audit" ? `AGENT_ACTION: ${phase} in ${p.name} — run /coord-audit and report before the next phase.${b.note ? " Note: " + b.note : ""}`
|
|
659
|
+
: null;
|
|
660
|
+
if (!text)
|
|
661
|
+
return c.json({ error: "verb must be plan | kickoff | audit" }, 400);
|
|
662
|
+
const r = await runs.exec({ kind: "bus", label: `${verb} ${phase} → ${target}`, project: p.name, sideEffects: true, args: { to: target, text: text.slice(0, 200) } }, (log) => busDavid.call("send_message", { to: target, text }, log));
|
|
663
|
+
return c.json(r, r.status === "ok" ? 200 : 403);
|
|
664
|
+
});
|
|
665
|
+
/* ---- Doctor → Fix: route a doctor finding to the role that owns it (live agent required); sent as david ---- */
|
|
666
|
+
const DOCTOR_OWNER = {
|
|
667
|
+
"orphan-wikilinks": { role: "aide", re: /aide|liaison|curator/i, how: "fix the orphan wikilinks (create the target doc or correct the link); docs are your lane" },
|
|
668
|
+
"spec-coverage": { role: "aide", re: /aide|liaison|curator/i, how: "run /plan-phase for the phases without PHASE*_TASKS.md, then append one queue.v1 item each" },
|
|
669
|
+
"phase-progress": { role: "aide", re: /aide|liaison|curator/i, how: "reconcile phase task checkboxes with reality (check-task) or plan the phase" },
|
|
670
|
+
"stack-map-freshness": { role: "coordinator", re: /coordinator/i, how: "route /check-versions to a worker and refresh docs/STACK_MAP.md" },
|
|
671
|
+
"workstreams": { role: "coordinator", re: /coordinator/i, how: "bring docs/WORKSTREAMS.md to workstreams.v1 (6-col Active Streams); the seam refuses the current rows" },
|
|
672
|
+
"facts-freshness": { role: "coordinator", re: /coordinator/i, how: "re-verify or delete the stale facts (set-fact); one writer per fact" },
|
|
673
|
+
"adr-tripwire": { role: "coordinator", re: /coordinator/i, how: "an Accepted ADR is contradicted — run /coord-audit and either amend the ADR or revert the drift" },
|
|
674
|
+
};
|
|
675
|
+
app.get("/projects/:name/doctor/fixers", async (c) => {
|
|
676
|
+
const p = project(c.req.param("name"));
|
|
677
|
+
if (!p)
|
|
678
|
+
return c.json({ error: "unknown project" }, 404);
|
|
679
|
+
const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.liveness !== "dead" && (a.project === p.room || a.project === p.name));
|
|
680
|
+
const out = {};
|
|
681
|
+
for (const [check, o] of Object.entries(DOCTOR_OWNER)) {
|
|
682
|
+
const a = live.find((x) => o.re.test(x.role ?? ""));
|
|
683
|
+
out[check] = { role: o.role, agent: a?.agentId ?? null, how: o.how };
|
|
684
|
+
}
|
|
685
|
+
return c.json({ fixers: out, versionMarker: { self: true, how: "console runs groundwork update (Skills)" } });
|
|
686
|
+
});
|
|
687
|
+
app.post("/actions/doctor-fix/:name", async (c) => {
|
|
688
|
+
const p = project(c.req.param("name"));
|
|
689
|
+
if (!p)
|
|
690
|
+
return c.json({ error: "unknown project" }, 404);
|
|
691
|
+
const b = (await c.req.json());
|
|
692
|
+
const o = DOCTOR_OWNER[b.check];
|
|
693
|
+
if (!o)
|
|
694
|
+
return c.json({ error: "no fixer role for this check" }, 400);
|
|
695
|
+
const live = (await agentsAsync(cfg.coordDir)).filter((a) => a.liveness !== "dead" && (a.project === p.room || a.project === p.name));
|
|
696
|
+
const target = live.find((x) => o.re.test(x.role ?? ""))?.agentId;
|
|
697
|
+
if (!target)
|
|
698
|
+
return c.json({ error: `no live ${o.role} for ${p.name}` }, 409);
|
|
699
|
+
const items = (b.items ?? []).slice(0, 8).map((i) => ` - ${i}`).join("\n");
|
|
700
|
+
const text = `AGENT_ACTION: groundwork doctor · ${b.check} in ${p.name} — ${o.how}.${items ? "\n" + items : ""}${(b.items?.length ?? 0) > 8 ? `\n … +${b.items.length - 8} more (run groundwork doctor --json)` : ""}${b.note ? "\nNote: " + b.note : ""}`;
|
|
701
|
+
const r = await runs.exec({ kind: "bus", label: `fix ${b.check} → ${target}`, project: p.name, sideEffects: true, args: { check: b.check, to: target, items: b.items?.length ?? 0 } }, (log) => busDavid.call("send_message", { to: target, text }, log));
|
|
702
|
+
return c.json(r, r.status === "ok" ? 200 : 403);
|
|
703
|
+
});
|
|
704
|
+
/* ---- Notifications ---- */
|
|
705
|
+
app.get("/notifications", (c) => c.json(notifier.recent(Number(c.req.query("limit") ?? 100))));
|
|
706
|
+
app.get("/notifications/stream", (c) => {
|
|
707
|
+
const stream = new ReadableStream({
|
|
708
|
+
start(controller) {
|
|
709
|
+
const enc = new TextEncoder();
|
|
710
|
+
const send = (e) => { try {
|
|
711
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
712
|
+
}
|
|
713
|
+
catch { /* closed */ } };
|
|
714
|
+
send({ type: "hello" });
|
|
715
|
+
const off = notifier.subscribe(send);
|
|
716
|
+
const ka = setInterval(() => { try {
|
|
717
|
+
controller.enqueue(enc.encode(": keepalive\n\n"));
|
|
718
|
+
}
|
|
719
|
+
catch { /* closed */ } }, 25_000);
|
|
720
|
+
c.req.raw.signal.addEventListener("abort", () => { off(); clearInterval(ka); try {
|
|
721
|
+
controller.close();
|
|
722
|
+
}
|
|
723
|
+
catch { /* closed */ } });
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" } });
|
|
727
|
+
});
|
|
728
|
+
/* ---- Runs: every side-effecting or long-running action ---- */
|
|
729
|
+
app.get("/runs", (c) => c.json({ running: runs.running().length, runs: runs.list(Number(c.req.query("limit") ?? 50)) }));
|
|
730
|
+
app.get("/runs/:id", (c) => { const r = runs.get(c.req.param("id")); return r ? c.json(r) : c.json({ error: "not found" }, 404); });
|
|
731
|
+
app.get("/runs/stream", (c) => {
|
|
732
|
+
const stream = new ReadableStream({
|
|
733
|
+
start(controller) {
|
|
734
|
+
const enc = new TextEncoder();
|
|
735
|
+
const send = (e) => { try {
|
|
736
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
737
|
+
}
|
|
738
|
+
catch { /* closed */ } };
|
|
739
|
+
send({ type: "hello", running: runs.running().length });
|
|
740
|
+
const off = runs.subscribe(send);
|
|
741
|
+
const ka = setInterval(() => { try {
|
|
742
|
+
controller.enqueue(enc.encode(": keepalive\n\n"));
|
|
743
|
+
}
|
|
744
|
+
catch { /* closed */ } }, 25_000);
|
|
745
|
+
c.req.raw.signal.addEventListener("abort", () => { off(); clearInterval(ka); try {
|
|
746
|
+
controller.close();
|
|
747
|
+
}
|
|
748
|
+
catch { /* closed */ } });
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" } });
|
|
752
|
+
});
|
|
753
|
+
/* Actions (allow-listed; each becomes a Run). Read-only build: only read-only MCP tools + read-only CLIs. */
|
|
754
|
+
app.post("/actions/bus-doctor", async (c) => {
|
|
755
|
+
const body = await c.req.json().catch(() => ({}));
|
|
756
|
+
if (body.fix)
|
|
757
|
+
return c.json({ error: "fix=true is a write; disabled in read-only build" }, 403);
|
|
758
|
+
const r = await runs.exec({ kind: "mcp", label: "bus doctor", sideEffects: false, args: { tool: "doctor", agentId: AGENT_ID } }, (log) => bus.call("doctor", {}, log));
|
|
759
|
+
return c.json(r, r.status === "ok" ? 200 : 500);
|
|
760
|
+
});
|
|
761
|
+
app.post("/actions/project-doctor/:name", async (c) => {
|
|
762
|
+
const p = project(c.req.param("name"));
|
|
763
|
+
if (!p)
|
|
764
|
+
return c.json({ error: "unknown project" }, 404);
|
|
765
|
+
const r = await runs.exec({ kind: "cli", label: `groundwork doctor · ${p.name}`, project: p.name, sideEffects: false }, async (log) => { log(`node groundwork/src/cli.mjs doctor ${p.path} --json`); const out = await doctor(cfg.kitPath, p.path); log(`${out.checks.length} checks · ok=${out.ok}`); return out; });
|
|
766
|
+
return c.json(r);
|
|
767
|
+
});
|
|
768
|
+
app.post("/actions/mcp/:tool", async (c) => {
|
|
769
|
+
const tool = c.req.param("tool");
|
|
770
|
+
const args = (await c.req.json().catch(() => ({})));
|
|
771
|
+
const r = await runs.exec({ kind: "mcp", label: `${tool}`, sideEffects: false, args: { tool, ...args } }, (log) => bus.call(tool, args, log));
|
|
772
|
+
return c.json(r, r.status === "ok" ? 200 : 403);
|
|
773
|
+
});
|
|
774
|
+
app.get("/bus/agents", async (c) => c.json(await agentsAsync(cfg.coordDir)));
|
|
775
|
+
/* Monitor: tail of every pane-attached agent (optionally scoped to a project) in one call. */
|
|
776
|
+
app.get("/bus/panes", async (c) => {
|
|
777
|
+
const proj = c.req.query("project");
|
|
778
|
+
const lines = Number(c.req.query("lines") ?? 40);
|
|
779
|
+
const p = proj ? project(proj) : undefined;
|
|
780
|
+
const all = await agentsAsync(cfg.coordDir);
|
|
781
|
+
const scoped = all.filter((a) => a.pane && a.liveness !== "dead" && (!p || a.project === p.name || (p.room && a.project === p.room)));
|
|
782
|
+
const colors = c.req.query("colors") === "1";
|
|
783
|
+
const out = await Promise.all(scoped.map(async (a) => ({ agentId: a.agentId, role: a.role ?? null, project: a.project ?? null, pane: a.pane, command: a.paneCommand, active: a.active, paneActivity: a.paneActivity, liveness: a.liveness, capture: await capturePane(a.pane, lines, colors) })));
|
|
784
|
+
return c.json(out);
|
|
785
|
+
});
|
|
786
|
+
/* Pane viewer: read-only capture of an agent's tmux pane (never sends keys). */
|
|
787
|
+
app.get("/bus/agents/:id/pane", async (c) => {
|
|
788
|
+
const a = (await agentsAsync(cfg.coordDir)).find((x) => x.agentId === c.req.param("id"));
|
|
789
|
+
if (!a)
|
|
790
|
+
return c.json({ error: "unknown agent" }, 404);
|
|
791
|
+
if (!a.pane)
|
|
792
|
+
return c.json({ error: "agent has no tmux pane (transport not tmux-push)" }, 409);
|
|
793
|
+
const cap = await capturePane(a.pane, Number(c.req.query("lines") ?? 200), c.req.query("colors") === "1");
|
|
794
|
+
if (!cap)
|
|
795
|
+
return c.json({ error: "pane not found (session gone?)" }, 410);
|
|
796
|
+
return c.json({ agentId: a.agentId, pane: a.pane, command: a.paneCommand, active: a.active, paneActivity: a.paneActivity, ...cap });
|
|
797
|
+
});
|
|
798
|
+
app.get("/bus/rooms", (c) => c.json(rooms(cfg.coordDir)));
|
|
799
|
+
app.get("/bus/rooms/:room/messages", (c) => {
|
|
800
|
+
const limit = Number(c.req.query("limit") ?? 100);
|
|
801
|
+
const before = c.req.query("before") ? Number(c.req.query("before")) : undefined;
|
|
802
|
+
const room = c.req.param("room");
|
|
803
|
+
const msgs = roomMessages(cfg.coordDir, room, limit, before).map((m) => ({ ...m, prefix: typeof m.text === "string" ? prefixOf(m.text) : null, answered: isAnswered(m.id) }));
|
|
804
|
+
return c.json({ room, total: roomMessageCount(cfg.coordDir, room), messages: msgs, oldestTs: msgs[0]?.ts ?? null });
|
|
805
|
+
});
|
|
806
|
+
app.get("/bus/status", (c) => c.json(statusStream(cfg.coordDir, Number(c.req.query("limit") ?? 50))));
|
|
807
|
+
app.get("/bus/scopes", (c) => c.json(scopes(cfg.coordDir)));
|
|
808
|
+
events.start(15_000);
|
|
809
|
+
notifier.start(15_000);
|
|
810
|
+
/* Packaged mode: the built web UI lives in ../public (copied at prepack) and is served by the same process at / — one port, no Vite. Dev keeps using Vite on :5173 with the /api proxy. */
|
|
811
|
+
root.route("/", app);
|
|
812
|
+
if (existsSync(PUBLIC_DIR)) {
|
|
813
|
+
root.use("/*", serveStatic({ root: PUBLIC_DIR }));
|
|
814
|
+
root.get("*", (c) => c.html(readFileSync(path.join(PUBLIC_DIR, "index.html"), "utf8"))); // hash router → index for everything else
|
|
815
|
+
}
|
|
816
|
+
// Bind loopback only: the console can write to the bus and to docs/ and has no auth yet. Set GROUNDWORK_CONSOLE_HOST=0.0.0.0 to expose (put auth in front first).
|
|
817
|
+
const hostname = process.env.GROUNDWORK_CONSOLE_HOST ?? "127.0.0.1";
|
|
818
|
+
serve({ fetch: root.fetch, port, hostname }, () => { const rq = requiresReport(cfg.kitPath); console.log(`kit resolve: ${rq.paths.kitCheckout ? "checkout " + rq.paths.kitCheckout : "installed packages (no kit checkout)"} · seam ${rq.seam.installed ?? "?"} mcp ${rq.mcp.installed ?? "?"} groundwork ${rq.groundwork.installed ?? "?"} playbook ${rq.playbook.installed ?? "?"} · requires ${rq.ok ? "OK" : "MISMATCH " + JSON.stringify(REQUIRES)}`); console.log(`groundwork-console api → http://${hostname}:${port}/api store=${cfg.coordDir} auth=${authEnabled ? `token (${tokenFile})` : "OFF"}`); });
|