@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.
- package/README.md +110 -0
- package/bin/__tests__/ingest.test.js +220 -0
- package/bin/__tests__/mcp-server.test.js +740 -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 +71 -0
- package/bin/mcp/server.js +335 -0
- package/bin/mcp/tools.js +519 -0
- package/bin/quorum.js +51 -0
- package/bin/shared/chronicle.js +40 -0
- package/bin/ui/app.html +1089 -0
- 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
|
@@ -141,6 +141,9 @@ Every PR merge posts a growth comment showing what Chronicle learned. `quorum ev
|
|
|
141
141
|
| Approve what the agent should remember | `quorum commit <id>` |
|
|
142
142
|
| See whether memory is growing | `quorum growth` |
|
|
143
143
|
| Consolidate stale or duplicate entries | `quorum evolve` |
|
|
144
|
+
| Seed Chronicle from git history | `quorum bootstrap --from-git --propose` |
|
|
145
|
+
| Ingest docs and source files as evidence | `quorum ingest docs/ --recurse` |
|
|
146
|
+
| Ingest URLs as evidence | `quorum ingest-url https://example.com/rfc` |
|
|
144
147
|
| Find undocumented areas | `quorum sentinel coverage` |
|
|
145
148
|
| Understand what the product currently does | `quorum compass map` |
|
|
146
149
|
| Generate product pathways toward a goal | `quorum compass pathways --goal "..."` |
|
|
@@ -151,6 +154,14 @@ Every PR merge posts a growth comment showing what Chronicle learned. `quorum ev
|
|
|
151
154
|
|
|
152
155
|
## Start small, then add guardrails
|
|
153
156
|
|
|
157
|
+
### Level 0 — Cold start
|
|
158
|
+
New repo with no Chronicle yet? Seed it from git history:
|
|
159
|
+
```bash
|
|
160
|
+
quorum bootstrap --from-git --since P90D --propose
|
|
161
|
+
quorum commit --list # review, then approve entries
|
|
162
|
+
```
|
|
163
|
+
Each commit becomes a low-trust draft proposal (`confidence: 0.4`, `needs_human_summary: true`). Nothing is indexed until you run `quorum commit <id>`.
|
|
164
|
+
|
|
154
165
|
### Level 1 — Local memory
|
|
155
166
|
Use `quorum advisor brief` and `quorum advisor query` at the start of AI sessions. No setup beyond `init`.
|
|
156
167
|
|
|
@@ -230,6 +241,66 @@ For most host-project use cases the CLI is sufficient and requires no loader. Se
|
|
|
230
241
|
|
|
231
242
|
## Command reference
|
|
232
243
|
|
|
244
|
+
### `quorum ingest` — ingest files and folders
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
quorum ingest README.md SETUP.md
|
|
248
|
+
quorum ingest docs/ --recurse
|
|
249
|
+
quorum ingest docs/ --recurse --propose # also stage as proposals
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Writes low-trust evidence to `.chronicle/sources/` and `.chronicle/evidence/`. Content-hash deduplication skips files that have not changed since the last ingest. Use `--propose` to also write to `.chronicle/proposals/` for review with `quorum commit --list`.
|
|
253
|
+
|
|
254
|
+
Supported extensions: `.md`, `.txt`, `.js`, `.ts`, `.json`, `.yaml`, `.html`, and other plain-text formats. Binary and unsupported files are recorded as sources with a fallback summary.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
### `quorum ingest-git` — ingest git history
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
quorum ingest-git --since P90D
|
|
262
|
+
quorum ingest-git --since P6M --propose # also stage commits as proposals
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`--since` accepts ISO 8601 durations: `P30D`, `P6M`, `P1Y`. Defaults to `P90D`. Each commit is stored as a source record and an evidence record containing the commit subject and changed files. Already-ingested commits are skipped by hash.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### `quorum ingest-url` — ingest URLs
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
quorum ingest-url https://example.com/internal-rfc
|
|
273
|
+
quorum ingest-url https://example.com/rfc https://example.com/spec --propose
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Fetches each URL (http/https only), strips HTML, and stores a low-trust evidence record. Follows redirects. Deduplicates by URL on re-runs.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
### `quorum bootstrap` — cold-start Chronicle from history
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
quorum bootstrap --from-git
|
|
284
|
+
quorum bootstrap --from-git --since P6M --propose
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Convenience wrapper around `ingest-git`. Seeds a new Chronicle from the project's commit history. Without `--propose`, evidence sits in `.chronicle/evidence/` until you promote it. With `--propose`, every commit becomes a draft proposal ready for `quorum commit`.
|
|
288
|
+
|
|
289
|
+
All ingested evidence uses the same low-trust format as PR-merge proposals:
|
|
290
|
+
|
|
291
|
+
```json
|
|
292
|
+
{
|
|
293
|
+
"source_quality": "metadata-derived",
|
|
294
|
+
"needs_human_summary": true,
|
|
295
|
+
"status": "open",
|
|
296
|
+
"confidence": 0.4
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
This solves the cold-start problem: a repo gets candidate memory from real history without bypassing the human gate.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
233
304
|
### `quorum advisor` — ask Chronicle a question
|
|
234
305
|
|
|
235
306
|
```bash
|
|
@@ -300,6 +371,8 @@ Writes to `.chronicle/committed/`, updates `SUMMARY.md`, removes the proposal. A
|
|
|
300
371
|
|
|
301
372
|
```
|
|
302
373
|
.chronicle/
|
|
374
|
+
sources/ ← raw ingested source records (files, URLs, git commits)
|
|
375
|
+
evidence/ ← low-trust extracted insights, not yet Chronicle
|
|
303
376
|
proposals/ ← AI-staged entries waiting for your approval
|
|
304
377
|
committed/ ← approved entries, indexed and searchable
|
|
305
378
|
SUMMARY.md ← auto-generated context for your AI to read
|
|
@@ -307,6 +380,43 @@ Writes to `.chronicle/committed/`, updates `SUMMARY.md`, removes the proposal. A
|
|
|
307
380
|
|
|
308
381
|
---
|
|
309
382
|
|
|
383
|
+
### `quorum serve` — governance UI + MCP server
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
quorum serve # starts on http://localhost:4242
|
|
387
|
+
quorum serve --port 8080 # custom port
|
|
388
|
+
quorum serve --no-llm # disable LLM auto-detection
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Starts a single HTTP server that provides:
|
|
392
|
+
|
|
393
|
+
- **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.
|
|
394
|
+
- **MCP server** at `POST /mcp` — JSON-RPC 2.0 endpoint exposing 10 tools and 6 resources to any MCP-compatible agent.
|
|
395
|
+
- **REST API** — used by the UI and accessible directly:
|
|
396
|
+
|
|
397
|
+
| Route | Description |
|
|
398
|
+
|---|---|
|
|
399
|
+
| `GET /api/entries?q=<query>` | Search committed Chronicle entries |
|
|
400
|
+
| `GET /api/proposals` | List pending proposals |
|
|
401
|
+
| `GET /api/coverage` | Chronicle coverage map |
|
|
402
|
+
| `GET /api/growth` | Chronicle health score |
|
|
403
|
+
| `GET /api/compass?subcommand=map\|brief\|opportunities\|bets` | Compass product direction |
|
|
404
|
+
| `PATCH /api/proposals/:id` | Edit a pending proposal before committing |
|
|
405
|
+
|
|
406
|
+
**MCP resources** (readable by agents via `resources/read`):
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
chronicle://summary chronicle://proposals
|
|
410
|
+
chronicle://coverage chronicle://growth
|
|
411
|
+
chronicle://compass chronicle://entry/{id}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**MCP tools**: `quorum_query`, `quorum_brief`, `quorum_stage`, `quorum_pending`, `quorum_coverage`, `quorum_growth`, `quorum_help`, `quorum_advisor`\*, `quorum_check`, `quorum_compass`\*
|
|
415
|
+
|
|
416
|
+
\* 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.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
310
420
|
### `quorum growth` — is Chronicle actually learning?
|
|
311
421
|
|
|
312
422
|
```bash
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import os from "os"
|
|
5
|
+
|
|
6
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
async function makeTmpDir() {
|
|
9
|
+
return fs.mkdtemp(path.join(os.tmpdir(), "quorum-ingest-"))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function rmrf(dir) {
|
|
13
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function makeChronicle(root) {
|
|
17
|
+
const chronicleDir = path.join(root, ".chronicle")
|
|
18
|
+
await fs.mkdir(chronicleDir, { recursive: true })
|
|
19
|
+
return chronicleDir
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readJsonDir(dir) {
|
|
23
|
+
let files
|
|
24
|
+
try { files = await fs.readdir(dir) } catch { return [] }
|
|
25
|
+
const results = []
|
|
26
|
+
for (const f of files) {
|
|
27
|
+
if (!f.endsWith(".json")) continue
|
|
28
|
+
results.push(JSON.parse(await fs.readFile(path.join(dir, f), "utf8")))
|
|
29
|
+
}
|
|
30
|
+
return results
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── parseDurationToGitSince ───────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
import { parseDurationToGitSince } from "../commands/ingest-git.js"
|
|
36
|
+
|
|
37
|
+
describe("parseDurationToGitSince", () => {
|
|
38
|
+
it("parses P90D to '90 days ago'", () => {
|
|
39
|
+
expect(parseDurationToGitSince("P90D")).toBe("90 days ago")
|
|
40
|
+
})
|
|
41
|
+
it("parses P30D to '30 days ago'", () => {
|
|
42
|
+
expect(parseDurationToGitSince("P30D")).toBe("30 days ago")
|
|
43
|
+
})
|
|
44
|
+
it("parses P1Y to '365 days ago'", () => {
|
|
45
|
+
expect(parseDurationToGitSince("P1Y")).toBe("365 days ago")
|
|
46
|
+
})
|
|
47
|
+
it("parses P6M to '180 days ago'", () => {
|
|
48
|
+
expect(parseDurationToGitSince("P6M")).toBe("180 days ago")
|
|
49
|
+
})
|
|
50
|
+
it("parses P1Y6M to '545 days ago'", () => {
|
|
51
|
+
expect(parseDurationToGitSince("P1Y6M")).toBe("545 days ago")
|
|
52
|
+
})
|
|
53
|
+
it("falls back to '90 days ago' for invalid input", () => {
|
|
54
|
+
expect(parseDurationToGitSince("not-valid")).toBe("90 days ago")
|
|
55
|
+
expect(parseDurationToGitSince("")).toBe("90 days ago")
|
|
56
|
+
expect(parseDurationToGitSince("P")).toBe("90 days ago")
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ── ingest command ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
import { run as runIngest } from "../commands/ingest.js"
|
|
63
|
+
import { findChronicleDir, readEvidence, readSources } from "../shared/chronicle.js"
|
|
64
|
+
|
|
65
|
+
describe("quorum ingest", () => {
|
|
66
|
+
let tmpDir, chronicleDir, origCwd
|
|
67
|
+
|
|
68
|
+
beforeEach(async () => {
|
|
69
|
+
tmpDir = await makeTmpDir()
|
|
70
|
+
chronicleDir = await makeChronicle(tmpDir)
|
|
71
|
+
origCwd = process.cwd()
|
|
72
|
+
process.chdir(tmpDir)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
process.chdir(origCwd)
|
|
77
|
+
await rmrf(tmpDir)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("ingests a single text file — writes source + evidence records", async () => {
|
|
81
|
+
const file = path.join(tmpDir, "README.md")
|
|
82
|
+
await fs.writeFile(file, "# My Project\n\nAn example project.")
|
|
83
|
+
|
|
84
|
+
await runIngest(["README.md"])
|
|
85
|
+
|
|
86
|
+
const sources = await readSources(chronicleDir)
|
|
87
|
+
const evidence = await readEvidence(chronicleDir)
|
|
88
|
+
|
|
89
|
+
expect(sources).toHaveLength(1)
|
|
90
|
+
expect(sources[0].type).toBe("file")
|
|
91
|
+
expect(sources[0].ref).toBe("README.md")
|
|
92
|
+
expect(sources[0].content_hash).toMatch(/^sha256:/)
|
|
93
|
+
|
|
94
|
+
expect(evidence).toHaveLength(1)
|
|
95
|
+
expect(evidence[0].source_quality).toBe("metadata-derived")
|
|
96
|
+
expect(evidence[0].needs_human_summary).toBe(true)
|
|
97
|
+
expect(evidence[0].confidence).toBe(0.4)
|
|
98
|
+
expect(evidence[0].status).toBe("open")
|
|
99
|
+
expect(evidence[0].source_module).toBe("ingest")
|
|
100
|
+
expect(evidence[0].affected_areas).toContain("README.md")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("skips unchanged files on second ingest (content-hash dedup)", async () => {
|
|
104
|
+
const file = path.join(tmpDir, "notes.md")
|
|
105
|
+
await fs.writeFile(file, "# Notes\nSome content.")
|
|
106
|
+
|
|
107
|
+
await runIngest(["notes.md"])
|
|
108
|
+
await runIngest(["notes.md"])
|
|
109
|
+
|
|
110
|
+
const sources = await readSources(chronicleDir)
|
|
111
|
+
const evidence = await readEvidence(chronicleDir)
|
|
112
|
+
expect(sources).toHaveLength(1)
|
|
113
|
+
expect(evidence).toHaveLength(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("writes proposals when --propose flag is set", async () => {
|
|
117
|
+
const file = path.join(tmpDir, "design.md")
|
|
118
|
+
await fs.writeFile(file, "# Design\nDecision records go here.")
|
|
119
|
+
|
|
120
|
+
await runIngest(["design.md", "--propose"])
|
|
121
|
+
|
|
122
|
+
const proposals = await readJsonDir(path.join(chronicleDir, "proposals"))
|
|
123
|
+
expect(proposals).toHaveLength(1)
|
|
124
|
+
expect(proposals[0].source_quality).toBe("metadata-derived")
|
|
125
|
+
expect(proposals[0].confidence).toBe(0.4)
|
|
126
|
+
expect(proposals[0].needs_human_summary).toBe(true)
|
|
127
|
+
// proposal must NOT contain id or ingested_at (stripped before writing)
|
|
128
|
+
expect(proposals[0].id).toBeUndefined()
|
|
129
|
+
expect(proposals[0].ingested_at).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("does not write proposals without --propose", async () => {
|
|
133
|
+
const file = path.join(tmpDir, "spec.md")
|
|
134
|
+
await fs.writeFile(file, "# Spec")
|
|
135
|
+
|
|
136
|
+
await runIngest(["spec.md"])
|
|
137
|
+
|
|
138
|
+
const proposalDir = path.join(chronicleDir, "proposals")
|
|
139
|
+
const files = await fs.readdir(proposalDir).catch(() => [])
|
|
140
|
+
expect(files.filter(f => f.endsWith(".json"))).toHaveLength(0)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("skips non-text files but writes a source record without content", async () => {
|
|
144
|
+
// Binary-like file with unsupported extension
|
|
145
|
+
const file = path.join(tmpDir, "icon.png")
|
|
146
|
+
await fs.writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
|
147
|
+
|
|
148
|
+
await runIngest(["icon.png"])
|
|
149
|
+
|
|
150
|
+
// Should have skipped writing (no TEXT_EXTS match → null content)
|
|
151
|
+
// The command writes evidence regardless, with a fallback summary
|
|
152
|
+
const sources = await readSources(chronicleDir)
|
|
153
|
+
const evidence = await readEvidence(chronicleDir)
|
|
154
|
+
// .png not in TEXT_EXTS → content null → evidence summary is fallback
|
|
155
|
+
expect(sources).toHaveLength(1)
|
|
156
|
+
expect(evidence[0].key_insight).toContain("icon.png")
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("walks directories recursively with --recurse", async () => {
|
|
160
|
+
const subDir = path.join(tmpDir, "docs")
|
|
161
|
+
await fs.mkdir(subDir)
|
|
162
|
+
await fs.writeFile(path.join(subDir, "api.md"), "# API")
|
|
163
|
+
await fs.writeFile(path.join(subDir, "guide.md"), "# Guide")
|
|
164
|
+
|
|
165
|
+
await runIngest(["docs", "--recurse"])
|
|
166
|
+
|
|
167
|
+
const evidence = await readEvidence(chronicleDir)
|
|
168
|
+
expect(evidence).toHaveLength(2)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// ── readEvidence / readSources helpers ───────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
describe("readEvidence and readSources", () => {
|
|
175
|
+
let tmpDir, chronicleDir
|
|
176
|
+
|
|
177
|
+
beforeEach(async () => {
|
|
178
|
+
tmpDir = await makeTmpDir()
|
|
179
|
+
chronicleDir = await makeChronicle(tmpDir)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
afterEach(async () => {
|
|
183
|
+
await rmrf(tmpDir)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("readEvidence returns empty array when directory does not exist", async () => {
|
|
187
|
+
const result = await readEvidence(chronicleDir)
|
|
188
|
+
expect(result).toEqual([])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("readSources returns empty array when directory does not exist", async () => {
|
|
192
|
+
const result = await readSources(chronicleDir)
|
|
193
|
+
expect(result).toEqual([])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("readEvidence reads written evidence records", async () => {
|
|
197
|
+
const dir = path.join(chronicleDir, "evidence")
|
|
198
|
+
await fs.mkdir(dir, { recursive: true })
|
|
199
|
+
await fs.writeFile(
|
|
200
|
+
path.join(dir, "abc123.json"),
|
|
201
|
+
JSON.stringify({ id: "abc123", key_insight: "test", ingested_at: "2026-01-01T00:00:00.000Z" }),
|
|
202
|
+
)
|
|
203
|
+
const result = await readEvidence(chronicleDir)
|
|
204
|
+
expect(result).toHaveLength(1)
|
|
205
|
+
expect(result[0].id).toBe("abc123")
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("readEvidence skips malformed JSON files", async () => {
|
|
209
|
+
const dir = path.join(chronicleDir, "evidence")
|
|
210
|
+
await fs.mkdir(dir, { recursive: true })
|
|
211
|
+
await fs.writeFile(path.join(dir, "bad.json"), "not json {{{{")
|
|
212
|
+
await fs.writeFile(
|
|
213
|
+
path.join(dir, "good.json"),
|
|
214
|
+
JSON.stringify({ id: "good", key_insight: "ok", ingested_at: "2026-01-01T00:00:00.000Z" }),
|
|
215
|
+
)
|
|
216
|
+
const result = await readEvidence(chronicleDir)
|
|
217
|
+
expect(result).toHaveLength(1)
|
|
218
|
+
expect(result[0].id).toBe("good")
|
|
219
|
+
})
|
|
220
|
+
})
|