@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
package/bin/mcp/tools.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quorum MCP tool definitions — pure logic, no HTTP.
|
|
3
|
+
* Shared between the MCP JSON-RPC handler and the REST API used by the UI.
|
|
4
|
+
*
|
|
5
|
+
* Tool naming follows Keep's pattern: all tools share a consistent prefix.
|
|
6
|
+
* Tools marked [TODO] are stubs — LLM-powered tools are out of scope for the
|
|
7
|
+
* current stateless HTTP server and require a future quorum serve --llm design.
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fs } from "fs"
|
|
10
|
+
import path from "path"
|
|
11
|
+
import { fileURLToPath } from "url"
|
|
12
|
+
import { randomUUID } from "crypto"
|
|
13
|
+
import { findChronicleDir, readCommitted, readProposals, updateSummary } from "../shared/chronicle.js"
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
|
|
17
|
+
// ── BM25-lite search ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function tokenize(text) {
|
|
20
|
+
return text.toLowerCase().split(/\W+/).filter(t => t.length > 2)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scoreEntry(query, entry) {
|
|
24
|
+
const qTokens = new Set(tokenize(query))
|
|
25
|
+
const text = [
|
|
26
|
+
entry.key_insight ?? "",
|
|
27
|
+
entry.decision ?? "",
|
|
28
|
+
entry.topic ?? "",
|
|
29
|
+
...(entry.affected_areas ?? []),
|
|
30
|
+
...(entry.scope ?? []),
|
|
31
|
+
].join(" ")
|
|
32
|
+
const eTokens = tokenize(text)
|
|
33
|
+
const overlap = eTokens.filter(t => qTokens.has(t)).length
|
|
34
|
+
return overlap / Math.sqrt(qTokens.size * eTokens.length + 1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function findRelevant(entries, query, limit = 8) {
|
|
38
|
+
return entries
|
|
39
|
+
.map(e => ({ entry: e, score: scoreEntry(query, e) }))
|
|
40
|
+
.filter(({ score }) => score > 0)
|
|
41
|
+
.sort((a, b) => b.score - a.score)
|
|
42
|
+
.slice(0, limit)
|
|
43
|
+
.map(({ entry }) => entry)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── File-tree coverage ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const IGNORED_DIRS = new Set(["node_modules", "dist", ".git", ".chronicle", "coverage", "__tests__"])
|
|
49
|
+
const TEST_SUFFIXES = [".test.ts", ".spec.ts", ".test.js", ".spec.js"]
|
|
50
|
+
const EXTENSIONS = [".ts", ".js"]
|
|
51
|
+
|
|
52
|
+
async function walkFiles(dir) {
|
|
53
|
+
const results = []
|
|
54
|
+
async function recurse(current) {
|
|
55
|
+
let entries
|
|
56
|
+
try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { return }
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
|
|
60
|
+
await recurse(path.join(current, entry.name))
|
|
61
|
+
}
|
|
62
|
+
} else if (EXTENSIONS.some(ext => entry.name.endsWith(ext))) {
|
|
63
|
+
if (TEST_SUFFIXES.some(s => entry.name.endsWith(s))) continue
|
|
64
|
+
results.push(path.join(current, entry.name))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
await recurse(dir)
|
|
69
|
+
return results
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isCovered(relativePath, entries) {
|
|
73
|
+
const normalised = relativePath.replace(/\\/g, "/")
|
|
74
|
+
const entryIds = []
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const hits = (entry.affected_areas ?? []).some(area => {
|
|
77
|
+
const normArea = area.replace(/\\/g, "/")
|
|
78
|
+
return normalised.includes(normArea) || normArea.includes(normalised)
|
|
79
|
+
})
|
|
80
|
+
if (hits) entryIds.push(entry.id)
|
|
81
|
+
}
|
|
82
|
+
return { covered: entryIds.length > 0, entryIds }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Tool functions ────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve project root: explicit arg > cwd.
|
|
89
|
+
* Returns { projectRoot, chronicleDir } or throws if no .chronicle found.
|
|
90
|
+
*/
|
|
91
|
+
async function resolve(projectRoot) {
|
|
92
|
+
const root = projectRoot ?? process.cwd()
|
|
93
|
+
const chronicleDir = await findChronicleDir(root)
|
|
94
|
+
if (!chronicleDir) throw new Error(`No .chronicle/ found from ${root}. Run quorum init first.`)
|
|
95
|
+
return { projectRoot: root, chronicleDir }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function toolQuery({ topic, projectRoot } = {}) {
|
|
99
|
+
if (!topic) throw new Error("topic is required")
|
|
100
|
+
const { chronicleDir } = await resolve(projectRoot)
|
|
101
|
+
const entries = await readCommitted(chronicleDir)
|
|
102
|
+
const results = findRelevant(entries, topic, 8)
|
|
103
|
+
return { query: topic, count: results.length, entries: results }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function toolBrief({ projectRoot } = {}) {
|
|
107
|
+
const { chronicleDir } = await resolve(projectRoot)
|
|
108
|
+
const entries = await readCommitted(chronicleDir)
|
|
109
|
+
const byStatus = { validated: 0, open: 0, refuted: 0, other: 0 }
|
|
110
|
+
for (const e of entries) {
|
|
111
|
+
const k = e.status === "validated" || e.status === "open" || e.status === "refuted"
|
|
112
|
+
? e.status : "other"
|
|
113
|
+
byStatus[k]++
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
total: entries.length,
|
|
117
|
+
byStatus,
|
|
118
|
+
entries: entries.slice(0, 50).map(e => ({
|
|
119
|
+
id: (e.id ?? "").slice(0, 8),
|
|
120
|
+
topic: e.topic,
|
|
121
|
+
decision: e.decision ?? e.key_insight,
|
|
122
|
+
status: e.status,
|
|
123
|
+
confidence: e.confidence,
|
|
124
|
+
affected_areas: e.affected_areas,
|
|
125
|
+
timestamp: e.timestamp,
|
|
126
|
+
})),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function toolStage({ entry, projectRoot } = {}) {
|
|
131
|
+
if (!entry) throw new Error("entry object is required")
|
|
132
|
+
const required = ["topic", "decision"]
|
|
133
|
+
for (const k of required) {
|
|
134
|
+
if (!entry[k]) throw new Error(`entry.${k} is required`)
|
|
135
|
+
}
|
|
136
|
+
const { chronicleDir } = await resolve(projectRoot)
|
|
137
|
+
const proposalId = randomUUID()
|
|
138
|
+
const proposal = {
|
|
139
|
+
schema_version: 2,
|
|
140
|
+
topic: entry.topic,
|
|
141
|
+
decision: entry.decision,
|
|
142
|
+
key_insight: entry.key_insight ?? entry.decision,
|
|
143
|
+
affected_areas: entry.affected_areas ?? [],
|
|
144
|
+
scope: entry.scope ?? [],
|
|
145
|
+
alternatives_considered: entry.alternatives_considered ?? [],
|
|
146
|
+
rejected_reason: entry.rejected_reason ?? [],
|
|
147
|
+
status: entry.status ?? "open",
|
|
148
|
+
confidence: entry.confidence ?? 0.7,
|
|
149
|
+
source_module: entry.source_module ?? "mcp",
|
|
150
|
+
evidence_cited: entry.evidence_cited ?? [],
|
|
151
|
+
work_ref: entry.work_ref ?? null,
|
|
152
|
+
}
|
|
153
|
+
const proposalPath = path.join(chronicleDir, "proposals", `${proposalId}.json`)
|
|
154
|
+
await fs.mkdir(path.join(chronicleDir, "proposals"), { recursive: true })
|
|
155
|
+
await fs.writeFile(proposalPath, JSON.stringify(proposal, null, 2), "utf8")
|
|
156
|
+
return { proposalId, topic: proposal.topic }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function toolPending({ projectRoot } = {}) {
|
|
160
|
+
const { chronicleDir } = await resolve(projectRoot)
|
|
161
|
+
const proposals = await readProposals(chronicleDir)
|
|
162
|
+
return {
|
|
163
|
+
count: proposals.length,
|
|
164
|
+
proposals: proposals.map(p => ({
|
|
165
|
+
id: p.proposalId,
|
|
166
|
+
topic: p.topic,
|
|
167
|
+
decision: p.decision ?? p.key_insight,
|
|
168
|
+
status: p.status,
|
|
169
|
+
confidence: p.confidence,
|
|
170
|
+
affected_areas: p.affected_areas,
|
|
171
|
+
})),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function toolCoverage({ projectRoot } = {}) {
|
|
176
|
+
const { projectRoot: root, chronicleDir } = await resolve(projectRoot)
|
|
177
|
+
const [entries, files] = await Promise.all([
|
|
178
|
+
readCommitted(chronicleDir),
|
|
179
|
+
walkFiles(root),
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
const coverageByFile = files.map(absolute => {
|
|
183
|
+
const relative = path.relative(root, absolute).replace(/\\/g, "/")
|
|
184
|
+
const { covered, entryIds } = isCovered(relative, entries)
|
|
185
|
+
return { file: relative, covered, entryIds }
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const coveredFiles = coverageByFile.filter(f => f.covered)
|
|
189
|
+
const percentage = files.length === 0 ? 0
|
|
190
|
+
: Math.round((coveredFiles.length / files.length) * 100)
|
|
191
|
+
|
|
192
|
+
return { percentage, totalFiles: files.length, coveredFiles: coveredFiles.length, coverageByFile }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function toolGrowth({ projectRoot } = {}) {
|
|
196
|
+
const { chronicleDir } = await resolve(projectRoot)
|
|
197
|
+
const [entries, proposals] = await Promise.all([
|
|
198
|
+
readCommitted(chronicleDir),
|
|
199
|
+
readProposals(chronicleDir),
|
|
200
|
+
])
|
|
201
|
+
|
|
202
|
+
const byStatus = { validated: 0, open: 0, refuted: 0, other: 0 }
|
|
203
|
+
let totalConfidence = 0
|
|
204
|
+
for (const e of entries) {
|
|
205
|
+
const k = e.status === "validated" || e.status === "open" || e.status === "refuted"
|
|
206
|
+
? e.status : "other"
|
|
207
|
+
byStatus[k]++
|
|
208
|
+
totalConfidence += e.confidence ?? 0
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const avgConfidence = entries.length > 0
|
|
212
|
+
? Math.round((totalConfidence / entries.length) * 100) / 100
|
|
213
|
+
: 0
|
|
214
|
+
|
|
215
|
+
// Rough health score: reward validated entries, penalise refuted + pending
|
|
216
|
+
const health = entries.length === 0 ? 0 : Math.max(0, Math.min(100, Math.round(
|
|
217
|
+
(byStatus.validated / entries.length) * 100
|
|
218
|
+
- (byStatus.refuted / entries.length) * 20
|
|
219
|
+
- (proposals.length / Math.max(1, entries.length)) * 10
|
|
220
|
+
)))
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
health,
|
|
224
|
+
entries: { total: entries.length, byStatus, avgConfidence },
|
|
225
|
+
proposals: { pending: proposals.length },
|
|
226
|
+
hint: health >= 80 ? "Chronicle is healthy."
|
|
227
|
+
: health >= 50 ? "Chronicle is growing — consider committing pending proposals."
|
|
228
|
+
: "Chronicle needs attention — validate open entries and reduce pending proposals.",
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Path to the modules README for quorum_help
|
|
233
|
+
const HELP_PATH = path.join(__dirname, "../../modules/README.md")
|
|
234
|
+
|
|
235
|
+
export async function toolHelp({ topic } = {}) {
|
|
236
|
+
let readme
|
|
237
|
+
try {
|
|
238
|
+
readme = await fs.readFile(HELP_PATH, "utf8")
|
|
239
|
+
} catch {
|
|
240
|
+
return { topic, content: "Quorum help text not found. See https://github.com/balpal4495/Quorum for documentation." }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!topic || topic === "index") {
|
|
244
|
+
// Return the first 100 lines as an index
|
|
245
|
+
const lines = readme.split("\n").slice(0, 100).join("\n")
|
|
246
|
+
return { topic: "index", content: lines }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Find the heading that best matches the topic and return its section
|
|
250
|
+
const lines = readme.split("\n")
|
|
251
|
+
const needle = topic.toLowerCase()
|
|
252
|
+
const start = lines.findIndex(l => l.startsWith("#") && l.toLowerCase().includes(needle))
|
|
253
|
+
|
|
254
|
+
if (start === -1) {
|
|
255
|
+
return { topic, content: `No section found for "${topic}". Try quorum_help with topic="index" to browse available topics.` }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Collect lines until the next same-level heading
|
|
259
|
+
const level = (lines[start].match(/^#+/) ?? [""])[0].length
|
|
260
|
+
const end = lines.findIndex((l, i) => i > start && l.startsWith("#".repeat(level)) && l.length > level)
|
|
261
|
+
const section = lines.slice(start, end === -1 ? start + 60 : end).join("\n")
|
|
262
|
+
|
|
263
|
+
return { topic, content: section }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── [TODO] LLM-powered placeholders ──────────────────────────────────────────
|
|
267
|
+
// These tools require a live LLM provider wired into quorum serve (--llm flag).
|
|
268
|
+
// They are registered so AI clients can discover them, but return a clear
|
|
269
|
+
// "not yet available" message rather than silently failing.
|
|
270
|
+
|
|
271
|
+
const TODO_MESSAGE = (name) =>
|
|
272
|
+
`${name} requires an LLM provider. This is planned — run 'quorum ${name.replace("quorum_", "")}' from the CLI for now, or watch for a future 'quorum serve --llm' flag.`
|
|
273
|
+
|
|
274
|
+
export async function toolAdvisor({ question } = {}) {
|
|
275
|
+
if (!question) throw new Error("question is required")
|
|
276
|
+
return { status: "todo", message: TODO_MESSAGE("quorum_advisor") }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function toolCheck({ outcome, design } = {}) {
|
|
280
|
+
if (!outcome && !design) throw new Error("outcome or design is required")
|
|
281
|
+
return { status: "todo", message: TODO_MESSAGE("quorum_check") }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function toolCompass({ subcommand } = {}) {
|
|
285
|
+
return { status: "todo", message: TODO_MESSAGE("quorum_compass") }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Proposal commit (human-gate — UI only, never an MCP AI tool) ──────────────
|
|
289
|
+
|
|
290
|
+
export async function commitProposal(proposalId, chronicleDir) {
|
|
291
|
+
const proposalsDir = path.join(chronicleDir, "proposals")
|
|
292
|
+
const files = await fs.readdir(proposalsDir).catch(() => [])
|
|
293
|
+
const match = files.find(f => f === `${proposalId}.json` || f.startsWith(proposalId))
|
|
294
|
+
if (!match) throw new Error(`Proposal not found: ${proposalId}`)
|
|
295
|
+
|
|
296
|
+
const proposalPath = path.join(proposalsDir, match)
|
|
297
|
+
const raw = await fs.readFile(proposalPath, "utf8")
|
|
298
|
+
const partial = JSON.parse(raw)
|
|
299
|
+
|
|
300
|
+
const entry = { ...partial, id: randomUUID(), timestamp: new Date().toISOString() }
|
|
301
|
+
const committedPath = path.join(chronicleDir, "committed", `${entry.id}.json`)
|
|
302
|
+
await fs.mkdir(path.join(chronicleDir, "committed"), { recursive: true })
|
|
303
|
+
await fs.writeFile(committedPath, JSON.stringify(entry, null, 2), "utf8")
|
|
304
|
+
await fs.unlink(proposalPath)
|
|
305
|
+
await updateSummary(chronicleDir).catch(() => {})
|
|
306
|
+
|
|
307
|
+
return { id: entry.id, topic: entry.topic }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function deleteProposal(proposalId, chronicleDir) {
|
|
311
|
+
const proposalsDir = path.join(chronicleDir, "proposals")
|
|
312
|
+
const files = await fs.readdir(proposalsDir).catch(() => [])
|
|
313
|
+
const match = files.find(f => f === `${proposalId}.json` || f.startsWith(proposalId))
|
|
314
|
+
if (!match) throw new Error(`Proposal not found: ${proposalId}`)
|
|
315
|
+
await fs.unlink(path.join(proposalsDir, match))
|
|
316
|
+
return { deleted: match.replace(".json", "") }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── MCP tool registry ─────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
export const MCP_TOOLS = [
|
|
322
|
+
// ── Core: Chronicle (no LLM) ──
|
|
323
|
+
{
|
|
324
|
+
name: "quorum_query",
|
|
325
|
+
description: "Search Chronicle entries by topic using BM25. Returns the most relevant prior decisions and findings. Always call this before proposing a design.",
|
|
326
|
+
inputSchema: {
|
|
327
|
+
type: "object",
|
|
328
|
+
properties: {
|
|
329
|
+
topic: { type: "string", description: "Topic or keywords to search for" },
|
|
330
|
+
projectRoot: { type: "string", description: "Project root directory (defaults to server cwd)" },
|
|
331
|
+
},
|
|
332
|
+
required: ["topic"],
|
|
333
|
+
},
|
|
334
|
+
fn: toolQuery,
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "quorum_brief",
|
|
338
|
+
description: "Return a full summary of all Chronicle entries — decisions, statuses, and confidence scores.",
|
|
339
|
+
inputSchema: {
|
|
340
|
+
type: "object",
|
|
341
|
+
properties: {
|
|
342
|
+
projectRoot: { type: "string" },
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
fn: toolBrief,
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: "quorum_stage",
|
|
349
|
+
description: "Stage a new Chronicle entry for human review. Returns a proposalId. A human must approve it via the Quorum UI or 'quorum commit <id>'.",
|
|
350
|
+
inputSchema: {
|
|
351
|
+
type: "object",
|
|
352
|
+
properties: {
|
|
353
|
+
entry: {
|
|
354
|
+
type: "object",
|
|
355
|
+
description: "Chronicle entry to propose",
|
|
356
|
+
properties: {
|
|
357
|
+
topic: { type: "string" },
|
|
358
|
+
decision: { type: "string" },
|
|
359
|
+
key_insight: { type: "string" },
|
|
360
|
+
affected_areas: { type: "array", items: { type: "string" } },
|
|
361
|
+
scope: { type: "array", items: { type: "string" } },
|
|
362
|
+
status: { type: "string", enum: ["validated", "open", "refuted"] },
|
|
363
|
+
confidence: { type: "number", minimum: 0, maximum: 1 },
|
|
364
|
+
alternatives_considered: { type: "array", items: { type: "string" } },
|
|
365
|
+
rejected_reason: { type: "array", items: { type: "string" } },
|
|
366
|
+
},
|
|
367
|
+
required: ["topic", "decision"],
|
|
368
|
+
},
|
|
369
|
+
projectRoot: { type: "string" },
|
|
370
|
+
},
|
|
371
|
+
required: ["entry"],
|
|
372
|
+
},
|
|
373
|
+
fn: toolStage,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "quorum_pending",
|
|
377
|
+
description: "List Chronicle proposals awaiting human approval.",
|
|
378
|
+
inputSchema: {
|
|
379
|
+
type: "object",
|
|
380
|
+
properties: { projectRoot: { type: "string" } },
|
|
381
|
+
},
|
|
382
|
+
fn: toolPending,
|
|
383
|
+
},
|
|
384
|
+
// ── Sentinel ──
|
|
385
|
+
{
|
|
386
|
+
name: "quorum_coverage",
|
|
387
|
+
description: "Return Chronicle coverage for source files — which files have Chronicle entries referencing them and which are undocumented.",
|
|
388
|
+
inputSchema: {
|
|
389
|
+
type: "object",
|
|
390
|
+
properties: { projectRoot: { type: "string" } },
|
|
391
|
+
},
|
|
392
|
+
fn: toolCoverage,
|
|
393
|
+
},
|
|
394
|
+
// ── Memory health ──
|
|
395
|
+
{
|
|
396
|
+
name: "quorum_growth",
|
|
397
|
+
description: "Report Chronicle memory health — entry counts by status, average confidence, pending proposals, and a health score with guidance.",
|
|
398
|
+
inputSchema: {
|
|
399
|
+
type: "object",
|
|
400
|
+
properties: { projectRoot: { type: "string" } },
|
|
401
|
+
},
|
|
402
|
+
fn: toolGrowth,
|
|
403
|
+
},
|
|
404
|
+
// ── Documentation ──
|
|
405
|
+
{
|
|
406
|
+
name: "quorum_help",
|
|
407
|
+
description: "Browse Quorum documentation. Call with topic='index' to see all available sections, or topic='<section>' to read a specific section.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: "object",
|
|
410
|
+
properties: {
|
|
411
|
+
topic: { type: "string", description: "Documentation topic, or 'index' for the full list" },
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
fn: toolHelp,
|
|
415
|
+
},
|
|
416
|
+
// ── [TODO] LLM-powered tools ──
|
|
417
|
+
{
|
|
418
|
+
name: "quorum_advisor",
|
|
419
|
+
description: "[TODO] Ask a plain-language question answered from Chronicle using an LLM. Requires 'quorum serve --llm'. Use 'quorum advisor' CLI for now.",
|
|
420
|
+
inputSchema: {
|
|
421
|
+
type: "object",
|
|
422
|
+
properties: {
|
|
423
|
+
question: { type: "string", description: "Plain-language question to answer from Chronicle" },
|
|
424
|
+
},
|
|
425
|
+
required: ["question"],
|
|
426
|
+
},
|
|
427
|
+
fn: toolAdvisor,
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: "quorum_check",
|
|
431
|
+
description: "[TODO] Run instant risk triage on a design against Chronicle evidence. Requires 'quorum serve --llm'. Use 'quorum check' CLI for now.",
|
|
432
|
+
inputSchema: {
|
|
433
|
+
type: "object",
|
|
434
|
+
properties: {
|
|
435
|
+
outcome: { type: "string", description: "Desired outcome" },
|
|
436
|
+
design: { type: "string", description: "Proposed design or approach" },
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
fn: toolCheck,
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "quorum_compass",
|
|
443
|
+
description: "[TODO] Product-direction synthesis — behaviours, pathways, bets, idea scoring. Requires 'quorum serve --llm'. Use 'quorum compass' CLI for now.",
|
|
444
|
+
inputSchema: {
|
|
445
|
+
type: "object",
|
|
446
|
+
properties: {
|
|
447
|
+
subcommand: { type: "string", description: "One of: brief, map, pathways, bets, score, opportunities" },
|
|
448
|
+
goal: { type: "string", description: "Goal for pathways subcommand" },
|
|
449
|
+
idea: { type: "string", description: "Idea to score for score subcommand" },
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
fn: toolCompass,
|
|
453
|
+
},
|
|
454
|
+
]
|
package/bin/quorum.js
CHANGED
|
@@ -31,7 +31,12 @@ ${c.bold("Usage:")}
|
|
|
31
31
|
${c.cyan("quorum growth")} Chronicle learning health and growth rate
|
|
32
32
|
${c.cyan("quorum evolve")} Consolidate and improve Chronicle entries (uses LLM)
|
|
33
33
|
${c.cyan("quorum compass")} <subcommand> Product-direction synthesis (brief, map, pathways, bets, score)
|
|
34
|
+
${c.cyan("quorum serve")} Start web UI + MCP server (default port 3000)
|
|
34
35
|
${c.cyan("quorum sync")} Refresh Quorum instruction blocks after npm update
|
|
36
|
+
${c.cyan("quorum ingest")} <paths...> Ingest files/folders as low-trust evidence
|
|
37
|
+
${c.cyan("quorum ingest-git")} [--since P90D] Ingest git history as low-trust evidence
|
|
38
|
+
${c.cyan("quorum ingest-url")} <url...> Ingest URLs as low-trust evidence
|
|
39
|
+
${c.cyan("quorum bootstrap")} --from-git Cold-start Chronicle from git history
|
|
35
40
|
${c.cyan("quorum migrate-v2")} Migrate from v1 vendored quorum/modules/ to npm package
|
|
36
41
|
${c.cyan("quorum --version")} Print version
|
|
37
42
|
|
|
@@ -69,6 +74,22 @@ ${c.bold("quorum compass")} subcommands:
|
|
|
69
74
|
propose --from-last Stage a Chronicle entry from last artifact
|
|
70
75
|
outcome --entry-id X --result Y Record a bet/pathway outcome
|
|
71
76
|
|
|
77
|
+
${c.bold("quorum ingest")} flags:
|
|
78
|
+
--recurse -r Walk directories recursively
|
|
79
|
+
--propose Also stage evidence as Chronicle proposals
|
|
80
|
+
|
|
81
|
+
${c.bold("quorum ingest-git")} flags:
|
|
82
|
+
--since P90D ISO 8601 duration: P30D, P6M, P1Y [default: P90D]
|
|
83
|
+
--propose Also stage commits as Chronicle proposals
|
|
84
|
+
|
|
85
|
+
${c.bold("quorum ingest-url")} flags:
|
|
86
|
+
--propose Also stage URLs as Chronicle proposals
|
|
87
|
+
|
|
88
|
+
${c.bold("quorum bootstrap")} flags:
|
|
89
|
+
--from-git Bootstrap from git commit history
|
|
90
|
+
--since P90D ISO 8601 duration [default: P90D]
|
|
91
|
+
--propose Also stage evidence as Chronicle proposals
|
|
92
|
+
|
|
72
93
|
${c.bold("Exit codes")} (quorum check):
|
|
73
94
|
0 low / medium risk
|
|
74
95
|
1 high risk
|
|
@@ -155,6 +176,36 @@ async function cli() {
|
|
|
155
176
|
return
|
|
156
177
|
}
|
|
157
178
|
|
|
179
|
+
if (command === "serve") {
|
|
180
|
+
const { run } = await import(path.join(__dirname, "commands/serve.js"))
|
|
181
|
+
await run(rest)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (command === "ingest") {
|
|
186
|
+
const { run } = await import(path.join(__dirname, "commands/ingest.js"))
|
|
187
|
+
await run(rest)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (command === "ingest-git") {
|
|
192
|
+
const { run } = await import(path.join(__dirname, "commands/ingest-git.js"))
|
|
193
|
+
await run(rest)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (command === "ingest-url") {
|
|
198
|
+
const { run } = await import(path.join(__dirname, "commands/ingest-url.js"))
|
|
199
|
+
await run(rest)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (command === "bootstrap") {
|
|
204
|
+
const { run } = await import(path.join(__dirname, "commands/bootstrap.js"))
|
|
205
|
+
await run(rest)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
158
209
|
console.error(`${c.red(`Unknown command: ${command}`)}`)
|
|
159
210
|
console.error(`Run ${c.bold("quorum help")} for usage.`)
|
|
160
211
|
process.exit(1)
|
package/bin/shared/chronicle.js
CHANGED
|
@@ -57,6 +57,46 @@ export async function readCommitted(chronicleDir) {
|
|
|
57
57
|
return results.sort((a, b) => b.timestamp?.localeCompare(a.timestamp ?? "") ?? 0)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Read all evidence records from .chronicle/evidence/.
|
|
62
|
+
* Returns array sorted newest-first by ingested_at.
|
|
63
|
+
*/
|
|
64
|
+
export async function readEvidence(chronicleDir) {
|
|
65
|
+
const dir = path.join(chronicleDir, "evidence")
|
|
66
|
+
let files
|
|
67
|
+
try { files = await fs.readdir(dir) } catch { return [] }
|
|
68
|
+
const results = []
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
if (!file.endsWith(".json")) continue
|
|
71
|
+
try {
|
|
72
|
+
const raw = await fs.readFile(path.join(dir, file), "utf8")
|
|
73
|
+
results.push(JSON.parse(raw))
|
|
74
|
+
} catch { /* skip malformed */ }
|
|
75
|
+
}
|
|
76
|
+
return results.sort((a, b) =>
|
|
77
|
+
(b.ingested_at ?? "").localeCompare(a.ingested_at ?? ""))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read all source records from .chronicle/sources/.
|
|
82
|
+
* Returns array sorted newest-first by ingested_at.
|
|
83
|
+
*/
|
|
84
|
+
export async function readSources(chronicleDir) {
|
|
85
|
+
const dir = path.join(chronicleDir, "sources")
|
|
86
|
+
let files
|
|
87
|
+
try { files = await fs.readdir(dir) } catch { return [] }
|
|
88
|
+
const results = []
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
if (!file.endsWith(".json")) continue
|
|
91
|
+
try {
|
|
92
|
+
const raw = await fs.readFile(path.join(dir, file), "utf8")
|
|
93
|
+
results.push(JSON.parse(raw))
|
|
94
|
+
} catch { /* skip malformed */ }
|
|
95
|
+
}
|
|
96
|
+
return results.sort((a, b) =>
|
|
97
|
+
(b.ingested_at ?? "").localeCompare(a.ingested_at ?? ""))
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
/** entryText mirrors the shared/types.ts entryText helper. */
|
|
61
101
|
export function entryText(entry) {
|
|
62
102
|
return `${entry.key_insight}. ${entry.decision ?? ""}`.trim().replace(/\.\.$/, ".")
|