@balpal4495/quorum 3.4.0 → 3.6.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 +111 -1
- package/bin/__tests__/mcp-server.test.js +5 -4
- package/bin/__tests__/mcp-tools.test.js +13 -13
- package/bin/commands/llm.js +108 -0
- package/bin/commands/serve.js +23 -4
- package/bin/mcp/server.js +36 -2
- package/bin/mcp/tools.js +80 -15
- package/bin/quorum.js +8 -0
- package/bin/shared/llm.js +244 -9
- package/bin/ui/app.html +416 -3
- package/dist/oracle/adapters/lance-db.d.ts.map +1 -1
- package/dist/oracle/adapters/lance-db.js +2 -1
- package/dist/oracle/adapters/lance-db.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -205,6 +205,79 @@ quorum advisor brief
|
|
|
205
205
|
|
|
206
206
|
---
|
|
207
207
|
|
|
208
|
+
## LLM setup
|
|
209
|
+
|
|
210
|
+
LLM-powered commands (`advisor`, `evolve`, `check`, `compass`, `serve`) auto-detect whichever provider is available. No config file is needed — Quorum picks the first working option from the list below.
|
|
211
|
+
|
|
212
|
+
**Recommended: use a CLI tool you're already logged into.** No API key required.
|
|
213
|
+
|
|
214
|
+
### Option 1 — Claude Code CLI (recommended)
|
|
215
|
+
|
|
216
|
+
If you have [Claude Code](https://claude.ai/code) installed and are logged in, Quorum uses it automatically.
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Verify it's working
|
|
220
|
+
echo "say hi" | claude --print
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Quorum detects the `Claude Code-credentials` keychain entry (macOS) or a session file in `~/.claude/sessions/`. If the command above works, Quorum will use it.
|
|
224
|
+
|
|
225
|
+
### Option 2 — GitHub Copilot CLI
|
|
226
|
+
|
|
227
|
+
If you have the [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-cli) installed and are logged in via VS Code, Quorum uses it automatically.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Verify it's working
|
|
231
|
+
copilot -p "say hi"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Quorum detects `~/.copilot/session-state/` having at least one session (created after first auth). If the command above works, Quorum will use it.
|
|
235
|
+
|
|
236
|
+
### Option 3 — API keys
|
|
237
|
+
|
|
238
|
+
Set any one of these environment variables:
|
|
239
|
+
|
|
240
|
+
| Variable | Provider |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `ANTHROPIC_API_KEY` | Anthropic Claude |
|
|
243
|
+
| `OPENAI_API_KEY` | OpenAI |
|
|
244
|
+
| `GEMINI_API_KEY` | Google Gemini |
|
|
245
|
+
| `OPENAI_BASE_URL` | Any OpenAI-compatible endpoint (Azure, Groq, etc.) |
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Option 4 — Gemini CLI
|
|
252
|
+
|
|
253
|
+
If you have the [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and authenticated, Quorum uses it automatically.
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
# Verify it's working
|
|
257
|
+
gemini -p "say hi"
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Option 5 — Ollama (local, last resort)
|
|
261
|
+
|
|
262
|
+
If Ollama is running at `localhost:11434`, Quorum uses the first available model. Set `OLLAMA_MODEL` to pin a specific model, or `OLLAMA_HOST` for a non-default address.
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
ollama serve
|
|
266
|
+
export OLLAMA_MODEL=llama3.2 # optional
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Checking what was detected
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
quorum serve # startup line shows: LLM: Claude Code CLI
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### No LLM — still useful
|
|
276
|
+
|
|
277
|
+
Without any provider, `advisor query`, `advisor brief`, `check`, and `coverage` all work with no LLM. Commands output Chronicle evidence and a synthesis request that your agent (Claude Code, Copilot, Codex) can answer inline.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
208
281
|
## Upgrading from v1
|
|
209
282
|
|
|
210
283
|
If your project has a `quorum/modules/` folder (the v1 vendored pattern), migrate in one step:
|
|
@@ -380,6 +453,43 @@ Writes to `.chronicle/committed/`, updates `SUMMARY.md`, removes the proposal. A
|
|
|
380
453
|
|
|
381
454
|
---
|
|
382
455
|
|
|
456
|
+
### `quorum serve` — governance UI + MCP server
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
quorum serve # starts on http://localhost:4242
|
|
460
|
+
quorum serve --port 8080 # custom port
|
|
461
|
+
quorum serve --no-llm # disable LLM auto-detection
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Starts a single HTTP server that provides:
|
|
465
|
+
|
|
466
|
+
- **Governance UI** at `/` — browse committed entries, review and approve pending proposals (with inline evidence, Jury breakdown, and Council conditions), track Chronicle health, and run Compass product-direction analysis.
|
|
467
|
+
- **MCP server** at `POST /mcp` — JSON-RPC 2.0 endpoint exposing 10 tools and 6 resources to any MCP-compatible agent.
|
|
468
|
+
- **REST API** — used by the UI and accessible directly:
|
|
469
|
+
|
|
470
|
+
| Route | Description |
|
|
471
|
+
|---|---|
|
|
472
|
+
| `GET /api/entries?q=<query>` | Search committed Chronicle entries |
|
|
473
|
+
| `GET /api/proposals` | List pending proposals |
|
|
474
|
+
| `GET /api/coverage` | Chronicle coverage map |
|
|
475
|
+
| `GET /api/growth` | Chronicle health score |
|
|
476
|
+
| `GET /api/compass?subcommand=map\|brief\|opportunities\|bets` | Compass product direction |
|
|
477
|
+
| `PATCH /api/proposals/:id` | Edit a pending proposal before committing |
|
|
478
|
+
|
|
479
|
+
**MCP resources** (readable by agents via `resources/read`):
|
|
480
|
+
|
|
481
|
+
```
|
|
482
|
+
chronicle://summary chronicle://proposals
|
|
483
|
+
chronicle://coverage chronicle://growth
|
|
484
|
+
chronicle://compass chronicle://entry/{id}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
**MCP tools**: `quorum_query`, `quorum_brief`, `quorum_stage`, `quorum_pending`, `quorum_coverage`, `quorum_growth`, `quorum_help`, `quorum_advisor`\*, `quorum_check`, `quorum_compass`\*
|
|
488
|
+
|
|
489
|
+
\* LLM-powered tools auto-activate when `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY` is set. Without a key they return a `no-llm` status with CLI fallback instructions.
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
383
493
|
### `quorum growth` — is Chronicle actually learning?
|
|
384
494
|
|
|
385
495
|
```bash
|
|
@@ -565,7 +675,7 @@ Refuted entries always elevate risk by at least one level. Citation validation s
|
|
|
565
675
|
|
|
566
676
|
### LLM auto-detection
|
|
567
677
|
|
|
568
|
-
Quorum
|
|
678
|
+
Quorum tries providers in this order: **Claude Code CLI** → **Copilot CLI** → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` → `GEMINI_API_KEY` → `OPENAI_BASE_URL` → Gemini CLI → Ollama. See [LLM setup](#llm-setup) for how to configure each option.
|
|
569
679
|
|
|
570
680
|
---
|
|
571
681
|
|
|
@@ -474,7 +474,7 @@ describe("POST /mcp — tools/call", () => {
|
|
|
474
474
|
expect(typeof result.content).toBe("string")
|
|
475
475
|
})
|
|
476
476
|
|
|
477
|
-
it("quorum_advisor returns
|
|
477
|
+
it("quorum_advisor returns no-llm status when no provider is configured", async () => {
|
|
478
478
|
const { dir } = await makeChronicle(tmpDir)
|
|
479
479
|
srv = await startServer(tmpDir, dir)
|
|
480
480
|
|
|
@@ -485,7 +485,7 @@ describe("POST /mcp — tools/call", () => {
|
|
|
485
485
|
|
|
486
486
|
expect(body.error).toBeUndefined()
|
|
487
487
|
const result = JSON.parse(body.result.content[0].text)
|
|
488
|
-
expect(result.status).toBe("
|
|
488
|
+
expect(result.status).toBe("no-llm")
|
|
489
489
|
})
|
|
490
490
|
|
|
491
491
|
it("returns -32601 for unknown tool name", async () => {
|
|
@@ -555,7 +555,7 @@ describe("unknown route", () => {
|
|
|
555
555
|
// ── MCP Resources ─────────────────────────────────────────────────────────────
|
|
556
556
|
|
|
557
557
|
describe("POST /mcp — resources/list", () => {
|
|
558
|
-
it("returns all
|
|
558
|
+
it("returns all six chronicle:// resources", async () => {
|
|
559
559
|
const { dir } = await makeChronicle(tmpDir)
|
|
560
560
|
srv = await startServer(tmpDir, dir)
|
|
561
561
|
|
|
@@ -570,7 +570,8 @@ describe("POST /mcp — resources/list", () => {
|
|
|
570
570
|
expect(uris).toContain("chronicle://coverage")
|
|
571
571
|
expect(uris).toContain("chronicle://growth")
|
|
572
572
|
expect(uris).toContain("chronicle://entry/{id}")
|
|
573
|
-
expect(uris).
|
|
573
|
+
expect(uris).toContain("chronicle://compass")
|
|
574
|
+
expect(uris).toHaveLength(6)
|
|
574
575
|
})
|
|
575
576
|
|
|
576
577
|
it("each resource has a name, description, and mimeType", async () => {
|
|
@@ -489,37 +489,37 @@ describe("toolHelp", () => {
|
|
|
489
489
|
})
|
|
490
490
|
})
|
|
491
491
|
|
|
492
|
-
// ──
|
|
492
|
+
// ── LLM-powered tools (no-llm fallback when no provider configured) ───────────
|
|
493
493
|
|
|
494
|
-
describe("toolAdvisor
|
|
494
|
+
describe("toolAdvisor", () => {
|
|
495
495
|
it("throws when question is missing", async () => {
|
|
496
496
|
await expect(toolAdvisor({})).rejects.toThrow("question is required")
|
|
497
497
|
})
|
|
498
498
|
|
|
499
|
-
it("returns status=
|
|
499
|
+
it("returns status=no-llm with a message when no provider is set", async () => {
|
|
500
500
|
const result = await toolAdvisor({ question: "what was decided about retries?" })
|
|
501
|
-
expect(result.status).toBe("
|
|
502
|
-
expect(result.message).toMatch(/
|
|
501
|
+
expect(result.status).toBe("no-llm")
|
|
502
|
+
expect(result.message).toMatch(/quorum advisor/i)
|
|
503
503
|
})
|
|
504
504
|
})
|
|
505
505
|
|
|
506
|
-
describe("toolCheck
|
|
506
|
+
describe("toolCheck", () => {
|
|
507
507
|
it("throws when neither outcome nor design is provided", async () => {
|
|
508
508
|
await expect(toolCheck({})).rejects.toThrow("outcome or design is required")
|
|
509
509
|
})
|
|
510
510
|
|
|
511
|
-
it("returns
|
|
511
|
+
it("returns preflight and risk when called with outcome and design", async () => {
|
|
512
512
|
const result = await toolCheck({ outcome: "ship safely", design: "use feature flags" })
|
|
513
|
-
expect(result
|
|
514
|
-
expect(result
|
|
513
|
+
expect(result).toHaveProperty("preflight")
|
|
514
|
+
expect(result).toHaveProperty("risk")
|
|
515
515
|
})
|
|
516
516
|
})
|
|
517
517
|
|
|
518
|
-
describe("toolCompass
|
|
519
|
-
it("returns status=
|
|
518
|
+
describe("toolCompass", () => {
|
|
519
|
+
it("returns status=no-llm with a message when no provider is set", async () => {
|
|
520
520
|
const result = await toolCompass({ subcommand: "brief" })
|
|
521
|
-
expect(result.status).toBe("
|
|
522
|
-
expect(result.message).toMatch(/
|
|
521
|
+
expect(result.status).toBe("no-llm")
|
|
522
|
+
expect(result.message).toMatch(/quorum compass/i)
|
|
523
523
|
})
|
|
524
524
|
})
|
|
525
525
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { probeAll, detectProvider } from "../shared/llm.js"
|
|
2
|
+
import { c } from "../shared/colors.js"
|
|
3
|
+
|
|
4
|
+
function row(detected, name, note, suffix = "") {
|
|
5
|
+
const icon = detected ? c.green("✓") : c.dim("·")
|
|
6
|
+
const label = detected ? c.bold(name) : c.dim(name)
|
|
7
|
+
const right = detected
|
|
8
|
+
? (note ? c.dim(` ${note}`) : "") + (suffix ? ` ${suffix}` : "")
|
|
9
|
+
: (note ? ` ${c.dim(note)}` : "")
|
|
10
|
+
return ` ${icon} ${label.padEnd(26)}${right}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function run(args) {
|
|
14
|
+
const test = args.includes("--test")
|
|
15
|
+
|
|
16
|
+
console.log("")
|
|
17
|
+
console.log(`${c.bold("quorum llm")} ${c.dim("— LLM provider status")}`)
|
|
18
|
+
console.log("")
|
|
19
|
+
|
|
20
|
+
// Run all probes in parallel with active provider detection
|
|
21
|
+
const [providers, active] = await Promise.all([probeAll(), detectProvider()])
|
|
22
|
+
const activeName = active?.name ?? null
|
|
23
|
+
|
|
24
|
+
console.log(" Provider scan")
|
|
25
|
+
console.log(c.dim(" ─────────────────────────────────────────────────────"))
|
|
26
|
+
|
|
27
|
+
// Normalize names for active matching (gatherCandidates uses "Gemini", probeAll uses "Gemini API")
|
|
28
|
+
const norm = n => n.replace(/ API$/, "").replace(/ \(.*?\)$/, "").toLowerCase()
|
|
29
|
+
|
|
30
|
+
for (const p of providers) {
|
|
31
|
+
const isActive = p.detected && !!activeName && norm(p.name) === norm(activeName)
|
|
32
|
+
|
|
33
|
+
const badges = []
|
|
34
|
+
if (isActive) badges.push(c.green("← active"))
|
|
35
|
+
if (p.detected && p.id === "ollama" && p.note) badges.push(c.dim(`(${p.note})`))
|
|
36
|
+
const suffix = badges.join(" ")
|
|
37
|
+
|
|
38
|
+
console.log(row(p.detected, p.name, null, suffix))
|
|
39
|
+
if (!p.detected && p.note) {
|
|
40
|
+
console.log(` ${c.dim(p.note)}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log("")
|
|
45
|
+
|
|
46
|
+
if (!activeName) {
|
|
47
|
+
console.log(` ${c.yellow("No provider detected.")}`)
|
|
48
|
+
console.log("")
|
|
49
|
+
printSetupGuide()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(` Active: ${c.bold(activeName)}`)
|
|
54
|
+
console.log("")
|
|
55
|
+
|
|
56
|
+
if (!test) {
|
|
57
|
+
console.log(c.dim(" Run 'quorum llm --test' to send a live request and verify it works."))
|
|
58
|
+
console.log("")
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Live test ──────────────────────────────────────────────────────────────
|
|
63
|
+
process.stdout.write(` Testing ${c.bold(activeName)}… `)
|
|
64
|
+
const t0 = Date.now()
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = await active.llm([
|
|
68
|
+
{ role: "user", content: "Respond with exactly the word OK and nothing else." },
|
|
69
|
+
])
|
|
70
|
+
const ms = Date.now() - t0
|
|
71
|
+
const ok = /\bOK\b/i.test(result?.trim() ?? "")
|
|
72
|
+
if (ok) {
|
|
73
|
+
console.log(`${c.green("✓")} ${c.dim(`(${ms}ms)`)}`)
|
|
74
|
+
} else {
|
|
75
|
+
console.log(`${c.yellow("✓ (unexpected response)")} ${c.dim(`(${ms}ms)`)}`)
|
|
76
|
+
console.log(c.dim(` → ${(result ?? "").slice(0, 120)}`))
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.log(c.red("✗"))
|
|
80
|
+
console.log(` ${c.red(err.message?.slice(0, 200) ?? String(err))}`)
|
|
81
|
+
console.log("")
|
|
82
|
+
console.log(` ${c.yellow("The detected provider failed.")} Check that you're signed in, then retry.`)
|
|
83
|
+
}
|
|
84
|
+
console.log("")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function printSetupGuide() {
|
|
88
|
+
console.log(" Quickest options:")
|
|
89
|
+
console.log("")
|
|
90
|
+
console.log(` ${c.bold("A")} ${c.bold("Claude Code CLI")} ${c.dim("(no API key needed)")}`)
|
|
91
|
+
console.log(c.dim(" Install and sign in: https://claude.ai/code"))
|
|
92
|
+
console.log(c.dim(" Quorum auto-detects it once you're signed in."))
|
|
93
|
+
console.log("")
|
|
94
|
+
console.log(` ${c.bold("B")} ${c.bold("GitHub Copilot CLI")} ${c.dim("(no API key needed)")}`)
|
|
95
|
+
console.log(c.dim(" Install VS Code + GitHub Copilot Chat extension, then sign in."))
|
|
96
|
+
console.log(c.dim(" Quorum auto-detects it once a session exists."))
|
|
97
|
+
console.log("")
|
|
98
|
+
console.log(` ${c.bold("C")} ${c.bold("API key")} ${c.dim("(Anthropic, OpenAI, or Gemini)")}`)
|
|
99
|
+
console.log(c.dim(" export ANTHROPIC_API_KEY=sk-ant-…"))
|
|
100
|
+
console.log(c.dim(" export OPENAI_API_KEY=sk-…"))
|
|
101
|
+
console.log(c.dim(" export GEMINI_API_KEY=…"))
|
|
102
|
+
console.log("")
|
|
103
|
+
console.log(` ${c.bold("D")} ${c.bold("Ollama")} ${c.dim("(local, free)")}`)
|
|
104
|
+
console.log(c.dim(" brew install ollama && ollama serve && ollama pull llama3.2"))
|
|
105
|
+
console.log("")
|
|
106
|
+
console.log(` ${c.dim("After setup, run 'quorum llm' again to confirm detection.")}`)
|
|
107
|
+
console.log("")
|
|
108
|
+
}
|
package/bin/commands/serve.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { c } from "../shared/colors.js"
|
|
2
2
|
import { findChronicleDir } from "../shared/chronicle.js"
|
|
3
3
|
import { createServer } from "../mcp/server.js"
|
|
4
|
+
import { detectProvider } from "../shared/llm.js"
|
|
4
5
|
|
|
5
6
|
function parseArgs(argv) {
|
|
6
7
|
const portIdx = argv.indexOf("--port")
|
|
@@ -9,11 +10,12 @@ function parseArgs(argv) {
|
|
|
9
10
|
: Number(argv.find(a => /^\d{2,5}$/.test(a)) ?? 3000)
|
|
10
11
|
const hostIdx = argv.indexOf("--host")
|
|
11
12
|
const host = hostIdx !== -1 ? argv[hostIdx + 1] : "localhost"
|
|
12
|
-
|
|
13
|
+
const noLlm = argv.includes("--no-llm")
|
|
14
|
+
return { port: isNaN(port) ? 3000 : port, host, noLlm }
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export async function run(argv) {
|
|
16
|
-
const { port, host } = parseArgs(argv)
|
|
18
|
+
const { port, host, noLlm } = parseArgs(argv)
|
|
17
19
|
|
|
18
20
|
const projectRoot = process.cwd()
|
|
19
21
|
const chronicleDir = await findChronicleDir(projectRoot)
|
|
@@ -23,14 +25,31 @@ export async function run(argv) {
|
|
|
23
25
|
process.exit(1)
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
// Auto-detect LLM provider unless --no-llm is passed
|
|
29
|
+
let llmProvider = null
|
|
30
|
+
let llmName = null
|
|
31
|
+
if (!noLlm) {
|
|
32
|
+
const detected = await detectProvider()
|
|
33
|
+
if (detected) {
|
|
34
|
+
llmProvider = detected.llm
|
|
35
|
+
llmName = detected.name
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const server = await createServer({ projectRoot, chronicleDir, llm: llmProvider })
|
|
27
40
|
|
|
28
41
|
server.listen(port, host, () => {
|
|
29
42
|
const base = `http://${host}:${port}`
|
|
30
43
|
console.log(`\n${c.bold("Quorum")} ${c.dim(`serving ${projectRoot}`)}\n`)
|
|
31
44
|
console.log(` ${c.cyan("UI")} ${c.dim(base + "/")}`)
|
|
32
45
|
console.log(` ${c.cyan("MCP")} ${c.dim(base + "/mcp")}`)
|
|
33
|
-
console.log(` ${c.cyan("Chronicle")} ${c.dim(chronicleDir)}
|
|
46
|
+
console.log(` ${c.cyan("Chronicle")} ${c.dim(chronicleDir)}`)
|
|
47
|
+
if (llmName) {
|
|
48
|
+
console.log(` ${c.cyan("LLM")} ${c.dim(llmName + " (advisor/check/compass enabled)")}`)
|
|
49
|
+
} else {
|
|
50
|
+
console.log(` ${c.cyan("LLM")} ${c.dim("none — LLM tools return CLI fallback hints")}`)
|
|
51
|
+
}
|
|
52
|
+
console.log()
|
|
34
53
|
console.log(c.bold("Claude Desktop") + c.dim(" — add to claude_desktop_config.json:"))
|
|
35
54
|
console.log(c.dim(JSON.stringify({
|
|
36
55
|
mcpServers: { quorum: { type: "streamable-http", url: `${base}/mcp` } }
|
package/bin/mcp/server.js
CHANGED
|
@@ -27,8 +27,11 @@ import {
|
|
|
27
27
|
toolBrief,
|
|
28
28
|
toolCoverage,
|
|
29
29
|
toolGrowth,
|
|
30
|
+
toolCompass,
|
|
30
31
|
commitProposal,
|
|
31
32
|
deleteProposal,
|
|
33
|
+
updateProposal,
|
|
34
|
+
setLLM,
|
|
32
35
|
} from "./tools.js"
|
|
33
36
|
|
|
34
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -124,10 +127,16 @@ async function handleMCP(body, defaultProjectRoot) {
|
|
|
124
127
|
description: "Chronicle health score, entry counts, and guidance.",
|
|
125
128
|
mimeType: "application/json",
|
|
126
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
|
+
},
|
|
127
136
|
{
|
|
128
137
|
uriTemplate: "chronicle://entry/{id}",
|
|
129
138
|
name: "Chronicle entry",
|
|
130
|
-
description: "A single committed Chronicle entry by
|
|
139
|
+
description: "A single committed Chronicle entry by id or 8-char prefix.",
|
|
131
140
|
mimeType: "application/json",
|
|
132
141
|
},
|
|
133
142
|
],
|
|
@@ -160,6 +169,11 @@ async function handleMCP(body, defaultProjectRoot) {
|
|
|
160
169
|
return rpcOk(id, { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }] })
|
|
161
170
|
}
|
|
162
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
|
+
|
|
163
177
|
// chronicle://entry/{id}
|
|
164
178
|
const entryMatch = uri.match(/^chronicle:\/\/entry\/(.+)$/)
|
|
165
179
|
if (entryMatch) {
|
|
@@ -198,7 +212,9 @@ function setCORS(res) {
|
|
|
198
212
|
|
|
199
213
|
// ── Server factory ────────────────────────────────────────────────────────────
|
|
200
214
|
|
|
201
|
-
export async function createServer({ projectRoot, chronicleDir }) {
|
|
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)
|
|
202
218
|
let uiHtml
|
|
203
219
|
try {
|
|
204
220
|
uiHtml = await fs.readFile(UI_PATH, "utf8")
|
|
@@ -284,6 +300,24 @@ export async function createServer({ projectRoot, chronicleDir }) {
|
|
|
284
300
|
}
|
|
285
301
|
}
|
|
286
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
|
+
|
|
287
321
|
// ── Web UI ──────────────────────────────────────────────────────────────
|
|
288
322
|
if ((pathname === "/" || pathname === "/index.html") && req.method === "GET") {
|
|
289
323
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
package/bin/mcp/tools.js
CHANGED
|
@@ -263,26 +263,70 @@ export async function toolHelp({ topic } = {}) {
|
|
|
263
263
|
return { topic, content: section }
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
// ──
|
|
267
|
-
// These
|
|
268
|
-
//
|
|
269
|
-
// "not yet available" message rather than silently failing.
|
|
266
|
+
// ── LLM-powered tools ─────────────────────────────────────────────────────────
|
|
267
|
+
// These are activated when quorum serve detects an LLM provider.
|
|
268
|
+
// Without a provider they return a clear CLI-fallback hint.
|
|
270
269
|
|
|
271
|
-
|
|
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.`
|
|
270
|
+
let _llm = null // set by createTools() at server startup
|
|
273
271
|
|
|
274
|
-
|
|
272
|
+
const NO_LLM = (name) => ({
|
|
273
|
+
status: "no-llm",
|
|
274
|
+
message: `${name} requires an LLM provider. No provider was detected at startup. ` +
|
|
275
|
+
`Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY and restart quorum serve. ` +
|
|
276
|
+
`Alternatively run 'quorum ${name.replace("quorum_", "")}' from the CLI.`,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
export async function toolAdvisor({ question, projectRoot } = {}) {
|
|
275
280
|
if (!question) throw new Error("question is required")
|
|
276
|
-
|
|
281
|
+
if (!_llm) return NO_LLM("quorum_advisor")
|
|
282
|
+
|
|
283
|
+
const { ask } = await import("../../dist/advisor/index.js")
|
|
284
|
+
const { chronicleDir } = await resolve(projectRoot)
|
|
285
|
+
const { createOracleClient } = await import("../../dist/oracle/index.js")
|
|
286
|
+
const { xenovaEmbed } = await import("../../dist/oracle/adapters/xenova-embedder.js")
|
|
287
|
+
const { createLanceDBStore } = await import("../../dist/oracle/adapters/lance-db.js")
|
|
288
|
+
|
|
289
|
+
const store = await createLanceDBStore(chronicleDir)
|
|
290
|
+
const oracle = createOracleClient({ store, embed: xenovaEmbed })
|
|
291
|
+
const result = await ask({ question, oracle, llm: _llm })
|
|
292
|
+
return result
|
|
277
293
|
}
|
|
278
294
|
|
|
279
|
-
export async function toolCheck({ outcome, design } = {}) {
|
|
295
|
+
export async function toolCheck({ outcome, design, projectRoot } = {}) {
|
|
296
|
+
// quorum check is LLM-free — uses the same preflight + risk classifier as the CLI
|
|
280
297
|
if (!outcome && !design) throw new Error("outcome or design is required")
|
|
281
|
-
|
|
298
|
+
const { runPreflight, classifyRisk } = await import("../shared/patterns.js")
|
|
299
|
+
const preflight = runPreflight(outcome ?? "", design ?? "")
|
|
300
|
+
const risk = classifyRisk(outcome ?? "", design ?? "")
|
|
301
|
+
return { preflight, risk }
|
|
282
302
|
}
|
|
283
303
|
|
|
284
|
-
export async function toolCompass({ subcommand } = {}) {
|
|
285
|
-
|
|
304
|
+
export async function toolCompass({ subcommand = "brief", goal, idea, projectRoot } = {}) {
|
|
305
|
+
if (!_llm) return NO_LLM("quorum_compass")
|
|
306
|
+
|
|
307
|
+
const { chronicleDir } = await resolve(projectRoot)
|
|
308
|
+
// Delegate to the compass CLI command handler for now
|
|
309
|
+
const { run: compassRun } = await import("../commands/compass.js")
|
|
310
|
+
// Capture stdout
|
|
311
|
+
const captured = []
|
|
312
|
+
const origWrite = process.stdout.write.bind(process.stdout)
|
|
313
|
+
process.stdout.write = (chunk, ...rest) => { captured.push(String(chunk)); return true }
|
|
314
|
+
try {
|
|
315
|
+
const extraArgs = []
|
|
316
|
+
if (subcommand === "pathways" && goal) extraArgs.push("--goal", goal)
|
|
317
|
+
if (subcommand === "score" && idea) extraArgs.push("--idea", idea)
|
|
318
|
+
await compassRun([subcommand, ...extraArgs])
|
|
319
|
+
} finally {
|
|
320
|
+
process.stdout.write = origWrite
|
|
321
|
+
}
|
|
322
|
+
return { subcommand, output: captured.join("").trim() }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Call once at server startup to wire the LLM provider into LLM-powered tools.
|
|
327
|
+
*/
|
|
328
|
+
export function setLLM(llmProvider) {
|
|
329
|
+
_llm = llmProvider
|
|
286
330
|
}
|
|
287
331
|
|
|
288
332
|
// ── Proposal commit (human-gate — UI only, never an MCP AI tool) ──────────────
|
|
@@ -316,6 +360,27 @@ export async function deleteProposal(proposalId, chronicleDir) {
|
|
|
316
360
|
return { deleted: match.replace(".json", "") }
|
|
317
361
|
}
|
|
318
362
|
|
|
363
|
+
export async function updateProposal(proposalId, patch, chronicleDir) {
|
|
364
|
+
const ALLOWED = ["topic", "decision", "key_insight", "status", "confidence",
|
|
365
|
+
"affected_areas", "scope", "alternatives_considered", "rejected_reason"]
|
|
366
|
+
const proposalsDir = path.join(chronicleDir, "proposals")
|
|
367
|
+
const files = await fs.readdir(proposalsDir).catch(() => [])
|
|
368
|
+
const match = files.find(f => f === `${proposalId}.json` || f.startsWith(proposalId))
|
|
369
|
+
if (!match) throw new Error(`Proposal not found: ${proposalId}`)
|
|
370
|
+
|
|
371
|
+
const proposalPath = path.join(proposalsDir, match)
|
|
372
|
+
const raw = await fs.readFile(proposalPath, "utf8")
|
|
373
|
+
const current = JSON.parse(raw)
|
|
374
|
+
|
|
375
|
+
// Only apply allowed fields — never let PATCH overwrite id/proposalId/schema_version
|
|
376
|
+
for (const key of ALLOWED) {
|
|
377
|
+
if (patch[key] !== undefined) current[key] = patch[key]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await fs.writeFile(proposalPath, JSON.stringify(current, null, 2), "utf8")
|
|
381
|
+
return { updated: match.replace(".json", ""), topic: current.topic }
|
|
382
|
+
}
|
|
383
|
+
|
|
319
384
|
// ── MCP tool registry ─────────────────────────────────────────────────────────
|
|
320
385
|
|
|
321
386
|
export const MCP_TOOLS = [
|
|
@@ -416,7 +481,7 @@ export const MCP_TOOLS = [
|
|
|
416
481
|
// ── [TODO] LLM-powered tools ──
|
|
417
482
|
{
|
|
418
483
|
name: "quorum_advisor",
|
|
419
|
-
description: "
|
|
484
|
+
description: "Ask a plain-language question answered from Chronicle using an LLM. Returns a synthesised answer with evidence citations. Auto-activated when quorum serve detects an API key.",
|
|
420
485
|
inputSchema: {
|
|
421
486
|
type: "object",
|
|
422
487
|
properties: {
|
|
@@ -428,7 +493,7 @@ export const MCP_TOOLS = [
|
|
|
428
493
|
},
|
|
429
494
|
{
|
|
430
495
|
name: "quorum_check",
|
|
431
|
-
description: "
|
|
496
|
+
description: "Run instant risk triage on a design against Chronicle patterns — no LLM required. Returns preflight flags and a risk level (low/medium/high/critical).",
|
|
432
497
|
inputSchema: {
|
|
433
498
|
type: "object",
|
|
434
499
|
properties: {
|
|
@@ -440,7 +505,7 @@ export const MCP_TOOLS = [
|
|
|
440
505
|
},
|
|
441
506
|
{
|
|
442
507
|
name: "quorum_compass",
|
|
443
|
-
description: "
|
|
508
|
+
description: "Product-direction synthesis — behaviours, pathways, bets, idea scoring. Auto-activated when quorum serve detects an LLM provider. Use subcommand: brief | map | pathways | bets | score | opportunities.",
|
|
444
509
|
inputSchema: {
|
|
445
510
|
type: "object",
|
|
446
511
|
properties: {
|
package/bin/quorum.js
CHANGED
|
@@ -23,6 +23,8 @@ ${c.bold("Usage:")}
|
|
|
23
23
|
${c.cyan("quorum advisor")} ${c.dim('"question"')} Ask a plain-language question (uses LLM)
|
|
24
24
|
${c.cyan("quorum advisor query")} ${c.dim('"topic"')} Search Chronicle entries (no LLM)
|
|
25
25
|
${c.cyan("quorum advisor brief")} High-level Chronicle summary (no LLM)
|
|
26
|
+
${c.cyan("quorum llm")} Show LLM provider status and setup guide
|
|
27
|
+
${c.cyan("quorum llm --test")} Send a live test request to the active provider
|
|
26
28
|
${c.cyan("quorum init")} Scaffold Quorum into a project
|
|
27
29
|
${c.cyan("quorum status")} Show Chronicle health and pending proposals
|
|
28
30
|
${c.cyan("quorum check")} --outcome <x> --design <y> Preflight + risk (no LLM)
|
|
@@ -116,6 +118,12 @@ async function cli() {
|
|
|
116
118
|
return
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
if (command === "llm") {
|
|
122
|
+
const { run } = await import(path.join(__dirname, "commands/llm.js"))
|
|
123
|
+
await run(rest)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
if (command === "init") {
|
|
120
128
|
const { run } = await import(path.join(__dirname, "commands/init.js"))
|
|
121
129
|
await run(PKG_VERSION)
|