@balpal4495/quorum 3.3.3 → 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.
- package/README.md +73 -0
- package/bin/__tests__/ingest.test.js +220 -0
- package/bin/__tests__/mcp-server.test.js +739 -0
- package/bin/__tests__/mcp-tools.test.js +525 -0
- package/bin/commands/bootstrap.js +65 -0
- package/bin/commands/ingest-git.js +192 -0
- package/bin/commands/ingest-url.js +224 -0
- package/bin/commands/ingest.js +212 -0
- package/bin/commands/serve.js +52 -0
- package/bin/mcp/server.js +301 -0
- package/bin/mcp/tools.js +454 -0
- package/bin/quorum.js +51 -0
- package/bin/shared/chronicle.js +40 -0
- package/bin/ui/app.html +676 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|