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