@balpal4495/quorum 3.3.3 → 3.5.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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Quorum HTTP server — MCP + Web UI on a single port.
3
+ *
4
+ * Routes:
5
+ * POST /mcp MCP Streamable HTTP (JSON-RPC 2.0)
6
+ * GET / Web UI
7
+ * GET /api/entries All committed entries (+ ?q= search)
8
+ * GET /api/proposals Pending proposals
9
+ * GET /api/coverage Sentinel coverage report
10
+ * GET /api/growth Memory health report
11
+ * POST /api/proposals/:id/commit Human-gate: approve a proposal
12
+ * DELETE /api/proposals/:id Reject / delete a proposal
13
+ *
14
+ * MCP also exposes resources:
15
+ * chronicle://summary chronicle://proposals
16
+ * chronicle://coverage chronicle://growth
17
+ * chronicle://entry/{id}
18
+ */
19
+ import http from "http"
20
+ import path from "path"
21
+ import { fileURLToPath } from "url"
22
+ import { promises as fs } from "fs"
23
+ import { readCommitted, readProposals, findChronicleDir } from "../shared/chronicle.js"
24
+ import {
25
+ MCP_TOOLS,
26
+ findRelevant,
27
+ toolBrief,
28
+ toolCoverage,
29
+ toolGrowth,
30
+ toolCompass,
31
+ commitProposal,
32
+ deleteProposal,
33
+ updateProposal,
34
+ setLLM,
35
+ } from "./tools.js"
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
38
+ const UI_PATH = path.join(__dirname, "../ui/app.html")
39
+
40
+ // ── JSON-RPC helpers ──────────────────────────────────────────────────────────
41
+
42
+ function rpcOk(id, result) { return { jsonrpc: "2.0", id, result } }
43
+ function rpcErr(id, code, message) { return { jsonrpc: "2.0", id, error: { code, message } } }
44
+
45
+ async function readBody(req) {
46
+ return new Promise((resolve, reject) => {
47
+ const chunks = []
48
+ req.on("data", c => chunks.push(c))
49
+ req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())) } catch (e) { reject(e) } })
50
+ req.on("error", reject)
51
+ })
52
+ }
53
+
54
+ // ── MCP JSON-RPC dispatcher ───────────────────────────────────────────────────
55
+
56
+ const TOOL_MAP = Object.fromEntries(MCP_TOOLS.map(t => [t.name, t]))
57
+
58
+ async function handleMCP(body, defaultProjectRoot) {
59
+ const { method, params = {}, id } = body
60
+
61
+ if (method === "initialize") {
62
+ return rpcOk(id, {
63
+ protocolVersion: "2024-11-05",
64
+ capabilities: {
65
+ tools: { listChanged: false },
66
+ resources: { subscribe: false, listChanged: false },
67
+ },
68
+ serverInfo: { name: "quorum", version: "1.0.0" },
69
+ })
70
+ }
71
+
72
+ if (method === "notifications/initialized") {
73
+ return null // no response for notifications
74
+ }
75
+
76
+ if (method === "tools/list") {
77
+ return rpcOk(id, {
78
+ tools: MCP_TOOLS.map(t => ({
79
+ name: t.name,
80
+ description: t.description,
81
+ inputSchema: t.inputSchema,
82
+ })),
83
+ })
84
+ }
85
+
86
+ if (method === "tools/call") {
87
+ const { name, arguments: args = {} } = params
88
+ const tool = TOOL_MAP[name]
89
+ if (!tool) return rpcErr(id, -32601, `Unknown tool: ${name}`)
90
+
91
+ // Inject default projectRoot if not provided
92
+ const callArgs = { projectRoot: defaultProjectRoot, ...args }
93
+ try {
94
+ const result = await tool.fn(callArgs)
95
+ return rpcOk(id, {
96
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
97
+ })
98
+ } catch (err) {
99
+ return rpcErr(id, -32603, err.message)
100
+ }
101
+ }
102
+
103
+ if (method === "resources/list") {
104
+ return rpcOk(id, {
105
+ resources: [
106
+ {
107
+ uri: "chronicle://summary",
108
+ name: "Chronicle summary",
109
+ description: "Full status summary of all committed Chronicle entries.",
110
+ mimeType: "application/json",
111
+ },
112
+ {
113
+ uri: "chronicle://proposals",
114
+ name: "Pending proposals",
115
+ description: "All Chronicle proposals awaiting human approval.",
116
+ mimeType: "application/json",
117
+ },
118
+ {
119
+ uri: "chronicle://coverage",
120
+ name: "Sentinel coverage",
121
+ description: "Chronicle coverage map for source files in the project.",
122
+ mimeType: "application/json",
123
+ },
124
+ {
125
+ uri: "chronicle://growth",
126
+ name: "Memory health",
127
+ description: "Chronicle health score, entry counts, and guidance.",
128
+ mimeType: "application/json",
129
+ },
130
+ {
131
+ uri: "chronicle://compass",
132
+ name: "Compass product direction",
133
+ description: "Latest Compass map: behaviours, gaps, and opportunities detected from the codebase.",
134
+ mimeType: "application/json",
135
+ },
136
+ {
137
+ uriTemplate: "chronicle://entry/{id}",
138
+ name: "Chronicle entry",
139
+ description: "A single committed Chronicle entry by id or 8-char prefix.",
140
+ mimeType: "application/json",
141
+ },
142
+ ],
143
+ })
144
+ }
145
+
146
+ if (method === "resources/read") {
147
+ const { uri } = params
148
+ if (!uri) return rpcErr(id, -32602, "uri is required")
149
+
150
+ if (uri === "chronicle://summary") {
151
+ const result = await toolBrief({ projectRoot: defaultProjectRoot })
152
+ return rpcOk(id, { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }] })
153
+ }
154
+
155
+ if (uri === "chronicle://proposals") {
156
+ const chronicleDir = await findChronicleDir(defaultProjectRoot)
157
+ if (!chronicleDir) return rpcErr(id, -32603, "No .chronicle/ found")
158
+ const proposals = await readProposals(chronicleDir)
159
+ return rpcOk(id, { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(proposals, null, 2) }] })
160
+ }
161
+
162
+ if (uri === "chronicle://coverage") {
163
+ const result = await toolCoverage({ projectRoot: defaultProjectRoot })
164
+ return rpcOk(id, { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }] })
165
+ }
166
+
167
+ if (uri === "chronicle://growth") {
168
+ const result = await toolGrowth({ projectRoot: defaultProjectRoot })
169
+ return rpcOk(id, { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }] })
170
+ }
171
+
172
+ if (uri === "chronicle://compass") {
173
+ const result = await toolCompass({ subcommand: "map", projectRoot: defaultProjectRoot })
174
+ return rpcOk(id, { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }] })
175
+ }
176
+
177
+ // chronicle://entry/{id}
178
+ const entryMatch = uri.match(/^chronicle:\/\/entry\/(.+)$/)
179
+ if (entryMatch) {
180
+ const entryId = decodeURIComponent(entryMatch[1])
181
+ const chronicleDir = await findChronicleDir(defaultProjectRoot)
182
+ if (!chronicleDir) return rpcErr(id, -32603, "No .chronicle/ found")
183
+ const entries = await readCommitted(chronicleDir)
184
+ const entry = entries.find(e => e.id === entryId || (e.id ?? "").startsWith(entryId))
185
+ if (!entry) return rpcErr(id, -32602, `Entry not found: ${entryId}`)
186
+ return rpcOk(id, { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(entry, null, 2) }] })
187
+ }
188
+
189
+ return rpcErr(id, -32602, `Unknown resource URI: ${uri}`)
190
+ }
191
+
192
+ if (method === "ping") {
193
+ return rpcOk(id, {})
194
+ }
195
+
196
+ return rpcErr(id, -32601, `Method not found: ${method}`)
197
+ }
198
+
199
+ // ── REST API helpers ──────────────────────────────────────────────────────────
200
+
201
+ function json(res, status, data) {
202
+ const body = JSON.stringify(data)
203
+ res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) })
204
+ res.end(body)
205
+ }
206
+
207
+ function setCORS(res) {
208
+ res.setHeader("Access-Control-Allow-Origin", "*")
209
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
210
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type")
211
+ }
212
+
213
+ // ── Server factory ────────────────────────────────────────────────────────────
214
+
215
+ export async function createServer({ projectRoot, chronicleDir, llm = null }) {
216
+ // Wire LLM into tools module so advisor/check/compass MCP tools work
217
+ setLLM(llm)
218
+ let uiHtml
219
+ try {
220
+ uiHtml = await fs.readFile(UI_PATH, "utf8")
221
+ } catch {
222
+ uiHtml = "<html><body><pre>UI not found. Run from the quorum package directory.</pre></body></html>"
223
+ }
224
+
225
+ const server = http.createServer(async (req, res) => {
226
+ setCORS(res)
227
+ const url = new URL(req.url, `http://localhost`)
228
+ const { pathname } = url
229
+
230
+ // Preflight
231
+ if (req.method === "OPTIONS") {
232
+ res.writeHead(204)
233
+ return res.end()
234
+ }
235
+
236
+ try {
237
+ // ── MCP endpoint ────────────────────────────────────────────────────────
238
+ if (pathname === "/mcp" && req.method === "POST") {
239
+ let body
240
+ try { body = await readBody(req) } catch {
241
+ const errBody = JSON.stringify(rpcErr(null, -32700, "Parse error"))
242
+ res.writeHead(400, { "content-type": "application/json" })
243
+ return res.end(errBody)
244
+ }
245
+
246
+ // Support both single request and batch
247
+ const isBatch = Array.isArray(body)
248
+ const requests = isBatch ? body : [body]
249
+ const responses = (await Promise.all(requests.map(r => handleMCP(r, projectRoot)))).filter(Boolean)
250
+ const responseBody = JSON.stringify(isBatch ? responses : responses[0] ?? {})
251
+ res.writeHead(200, { "content-type": "application/json" })
252
+ return res.end(responseBody)
253
+ }
254
+
255
+ // ── REST: entries ───────────────────────────────────────────────────────
256
+ if (pathname === "/api/entries" && req.method === "GET") {
257
+ const q = url.searchParams.get("q")
258
+ const entries = await readCommitted(chronicleDir)
259
+ const results = q ? findRelevant(entries, q, 20) : entries
260
+ return json(res, 200, results)
261
+ }
262
+
263
+ // ── REST: proposals list ────────────────────────────────────────────────
264
+ if (pathname === "/api/proposals" && req.method === "GET") {
265
+ const proposals = await readProposals(chronicleDir)
266
+ return json(res, 200, proposals)
267
+ }
268
+
269
+ // ── REST: coverage ──────────────────────────────────────────────────────
270
+ if (pathname === "/api/coverage" && req.method === "GET") {
271
+ const result = await toolCoverage({ projectRoot })
272
+ return json(res, 200, result)
273
+ }
274
+
275
+ // ── REST: growth ────────────────────────────────────────────────────────
276
+ if (pathname === "/api/growth" && req.method === "GET") {
277
+ const result = await toolGrowth({ projectRoot })
278
+ return json(res, 200, result)
279
+ }
280
+
281
+ // ── REST: commit proposal (human-gate) ──────────────────────────────────
282
+ const commitMatch = pathname.match(/^\/api\/proposals\/([^/]+)\/commit$/)
283
+ if (commitMatch && req.method === "POST") {
284
+ try {
285
+ const result = await commitProposal(commitMatch[1], chronicleDir)
286
+ return json(res, 200, result)
287
+ } catch (err) {
288
+ return json(res, 404, { error: err.message })
289
+ }
290
+ }
291
+
292
+ // ── REST: reject/delete proposal ────────────────────────────────────────
293
+ const proposalMatch = pathname.match(/^\/api\/proposals\/([^/]+)$/)
294
+ if (proposalMatch && req.method === "DELETE") {
295
+ try {
296
+ const result = await deleteProposal(proposalMatch[1], chronicleDir)
297
+ return json(res, 200, result)
298
+ } catch (err) {
299
+ return json(res, 404, { error: err.message })
300
+ }
301
+ }
302
+
303
+ // ── REST: edit/patch proposal ───────────────────────────────────────────
304
+ if (proposalMatch && req.method === "PATCH") {
305
+ try {
306
+ const body = await readBody(req)
307
+ const result = await updateProposal(proposalMatch[1], body, chronicleDir)
308
+ return json(res, 200, result)
309
+ } catch (err) {
310
+ return json(res, 400, { error: err.message })
311
+ }
312
+ }
313
+
314
+ // ── REST: compass ───────────────────────────────────────────────────────
315
+ if (pathname === "/api/compass" && req.method === "GET") {
316
+ const subcommand = new URL(req.url, "http://localhost").searchParams.get("subcommand") ?? "map"
317
+ const result = await toolCompass({ subcommand, projectRoot })
318
+ return json(res, 200, result)
319
+ }
320
+
321
+ // ── Web UI ──────────────────────────────────────────────────────────────
322
+ if ((pathname === "/" || pathname === "/index.html") && req.method === "GET") {
323
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
324
+ return res.end(uiHtml)
325
+ }
326
+
327
+ // 404
328
+ json(res, 404, { error: "Not found" })
329
+ } catch (err) {
330
+ json(res, 500, { error: err.message })
331
+ }
332
+ })
333
+
334
+ return server
335
+ }