@balpal4495/quorum 0.4.2 → 1.0.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/SETUP.md CHANGED
@@ -117,7 +117,7 @@ Append to it:
117
117
 
118
118
  ## Quorum modules
119
119
 
120
- See [quorum/modules/AGENTS.md](quorum/modules/AGENTS.md) for Oracle, Jury, and Council internals.
120
+ See [quorum/modules/AGENTS.md](quorum/modules/AGENTS.md) for Advisor, Oracle, Jury, Council, and Sentinel internals.
121
121
  ```
122
122
 
123
123
  ### 4c. `CLAUDE.md`
@@ -139,7 +139,7 @@ Append to it:
139
139
 
140
140
  ## Quorum modules
141
141
 
142
- See [quorum/modules/CLAUDE.md](quorum/modules/CLAUDE.md) for Oracle, Jury, and Council internals.
142
+ See [quorum/modules/CLAUDE.md](quorum/modules/CLAUDE.md) for Advisor, Oracle, Jury, Council, and Sentinel internals.
143
143
  ```
144
144
 
145
145
  ---
@@ -170,13 +170,15 @@ Add the following import and call at startup, **before** any agent or workflow c
170
170
  ```typescript
171
171
  import { setup } from "./quorum/modules/setup"
172
172
 
173
- const { oracle, evaluate, deliberate } = await setup({
173
+ const { oracle, evaluate, deliberate, ask } = await setup({
174
174
  llm: yourLLMProvider, // replace with your project's LLM provider function
175
175
  })
176
176
  ```
177
177
 
178
178
  `setup()` creates `.chronicle/` directories, warms the embedder, and wires all module dependencies.
179
- It must be called once before any `oracle.query()`, `evaluate()`, or `deliberate()` call.
179
+ It must be called once before any `oracle.query()`, `evaluate()`, `deliberate()`, or `ask()` call.
180
+
181
+ `ask(question)` is the plain-language interface — it queries Oracle automatically, synthesises Chronicle evidence into a concise answer, and retries internally until the answer meets a confidence threshold. Use it to answer questions rather than to evaluate designs.
180
182
 
181
183
  If no entry point exists yet, note that `setup()` must be called before first use — do not inline it.
182
184
 
@@ -271,6 +273,7 @@ Gemini and use it for large-context tasks.
271
273
  You are now operating under Quorum. The rules in `quorum/modules/AGENTS.md` and `.github/copilot-instructions.md` apply to all subsequent work.
272
274
 
273
275
  Key reminders:
276
+ - **Ask Advisor for context.** `quorum advisor "what has the team decided about X?"` before starting any meaningful work.
274
277
  - **Query Oracle before proposing anything.** `oracle.query("what you're about to do")` first.
275
278
  - **Never call `oracle.commit()` autonomously.** Only `oracle.propose()`. A human commits.
276
279
  - **Chronicle entries are ground truth.** Respect `refuted` entries — do not retry what has already failed.
@@ -281,10 +284,17 @@ These commands are available globally after `npm install -g @balpal4495/quorum`:
281
284
 
282
285
  | Command | What it does |
283
286
  |---|---|
287
+ | `quorum advisor "question"` | Ask a plain-language question — answer synthesised from Chronicle (needs LLM) |
288
+ | `quorum advisor query "topic"` | Search Chronicle entries by keyword (no LLM) |
289
+ | `quorum advisor brief` | High-level Chronicle summary (no LLM) |
284
290
  | `quorum status` | Chronicle health — pending proposals, committed entries |
285
291
  | `quorum check --outcome X --design Y` | Preflight + risk classifier (no LLM) |
286
292
  | `quorum commit --list` | List pending proposals |
287
293
  | `quorum commit <id>` | Approve and index a proposal |
288
294
  | `quorum sentinel coverage [--path <dir>]` | Chronicle coverage of source files |
295
+ | `quorum growth` | Chronicle learning health — growth rate, last commit, pending proposals |
296
+ | `quorum evolve` | Consolidate Chronicle — merges duplicates, resolves contradictions, promotes open entries |
289
297
 
290
298
  `quorum check` exit codes: `0` = low/medium risk · `1` = high · `2` = critical
299
+
300
+ `quorum advisor ask` and `quorum evolve` auto-detect any available LLM: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `OPENAI_BASE_URL`, Ollama at localhost:11434, or an authenticated `gemini` CLI. When running inside an AI agent (Claude Code, Copilot, Codex, Gemini) with no separate key, they output Chronicle evidence and a synthesis request for the agent to answer inline.
@@ -0,0 +1,301 @@
1
+ import { c } from "../shared/colors.js"
2
+ import { findChronicleDir, readCommitted } from "../shared/chronicle.js"
3
+ import { detectProvider } from "../shared/llm.js"
4
+
5
+ const SATISFACTION_THRESHOLD = 0.7
6
+ const MAX_RETRIES = 2
7
+
8
+ // ── Evidence helpers ──────────────────────────────────────────────────────────
9
+
10
+ function tokenize(text) {
11
+ return text.toLowerCase().split(/\W+/).filter(t => t.length > 2)
12
+ }
13
+
14
+ function scoreEntry(query, entry) {
15
+ const qTokens = new Set(tokenize(query))
16
+ const text = [
17
+ entry.key_insight ?? "",
18
+ entry.decision ?? "",
19
+ ...(entry.affected_areas ?? []),
20
+ ...(entry.scope ?? []),
21
+ ].join(" ")
22
+ const eTokens = tokenize(text)
23
+ const overlap = eTokens.filter(t => qTokens.has(t)).length
24
+ return overlap / Math.sqrt(qTokens.size * eTokens.length + 1)
25
+ }
26
+
27
+ function entryText(entry) {
28
+ return (entry.decision ?? entry.key_insight ?? "").trim()
29
+ }
30
+
31
+ function findRelevant(entries, query, limit = 6) {
32
+ return entries
33
+ .map(e => ({ entry: e, score: scoreEntry(query, e) }))
34
+ .filter(({ score }) => score > 0)
35
+ .sort((a, b) => b.score - a.score)
36
+ .slice(0, limit)
37
+ .map(({ entry }) => entry)
38
+ }
39
+
40
+ function formatEvidenceForLLM(entries) {
41
+ if (entries.length === 0) return "Chronicle has no prior entries on this topic."
42
+ return entries.map(e => {
43
+ const statusTag =
44
+ e.status === "refuted" ? " [REJECTED]" :
45
+ e.status === "validated" ? " [VALIDATED]" : ""
46
+ return `[${(e.id ?? "").slice(0, 8)}]${statusTag} ${entryText(e)}\n Areas: ${(e.affected_areas ?? []).join(", ")}`
47
+ }).join("\n\n")
48
+ }
49
+
50
+ // ── LLM + validation loop ─────────────────────────────────────────────────────
51
+
52
+ const SYSTEM_PROMPT = `You are the Quorum Advisor — the plain-language interface to a team's collective knowledge.
53
+
54
+ You receive a question from a developer or engineering manager, along with relevant Chronicle evidence.
55
+ Synthesise that evidence into a clear, concise answer a human can act on.
56
+
57
+ Rules:
58
+ - Write for a human who does not know what "Chronicle entries" or "vector search" mean.
59
+ - Be direct. One clear recommendation.
60
+ - If Chronicle has relevant evidence, reference it plainly: "the team already decided X".
61
+ - If Chronicle has no evidence, say so honestly — do not invent history.
62
+ - Blockers are hard blockers only — things that MUST be resolved before moving forward.
63
+
64
+ Return ONLY valid JSON matching this schema (no markdown fences, no explanation):
65
+ {
66
+ "confidence": <number 0–1>,
67
+ "what_we_know": <string — what Chronicle knows, plain English, 1–3 sentences>,
68
+ "risks": [<string>],
69
+ "blockers": [<string — hard blockers only, empty array if none>],
70
+ "recommendation": <string — one clear action>,
71
+ "next_step": <string — specific next step or quorum command>
72
+ }`
73
+
74
+ async function callLLM(llm, question, evidence, attempt, previous) {
75
+ let userPrompt = `## Question\n${question}\n\n## Chronicle Evidence\n${formatEvidenceForLLM(evidence)}`
76
+
77
+ if (attempt > 0 && previous) {
78
+ const lines = [
79
+ "",
80
+ `## Previous Answer (attempt ${attempt} — quality threshold not met)`,
81
+ `Confidence: ${previous.confidence.toFixed(2)} (need ≥ ${SATISFACTION_THRESHOLD})`,
82
+ ]
83
+ if (previous.blockers?.length > 0) {
84
+ lines.push(`Unresolved blockers: ${previous.blockers.join("; ")}`)
85
+ }
86
+ lines.push("Please produce a more specific and concrete answer.")
87
+ userPrompt += lines.join("\n")
88
+ }
89
+
90
+ const raw = await llm([
91
+ { role: "system", content: SYSTEM_PROMPT },
92
+ { role: "user", content: userPrompt },
93
+ ])
94
+
95
+ let parsed
96
+ try {
97
+ const cleaned = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim()
98
+ parsed = JSON.parse(cleaned)
99
+ } catch {
100
+ throw new Error(`LLM returned non-JSON. Raw: ${raw.slice(0, 200)}`)
101
+ }
102
+
103
+ if (typeof parsed.confidence !== "number" || !parsed.what_we_know || !parsed.recommendation) {
104
+ throw new Error("LLM response missing required fields (confidence, what_we_know, recommendation)")
105
+ }
106
+
107
+ return parsed
108
+ }
109
+
110
+ async function runAdvisor(llm, question, evidence) {
111
+ let last = null
112
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
113
+ const answer = await callLLM(llm, question, evidence, attempt, last)
114
+ last = answer
115
+ const satisfied = answer.confidence >= SATISFACTION_THRESHOLD && (answer.blockers?.length ?? 0) === 0
116
+ if (satisfied || attempt === MAX_RETRIES) return { ...answer, retries: attempt }
117
+ }
118
+ return { ...last, retries: MAX_RETRIES }
119
+ }
120
+
121
+ // ── Output renderers ──────────────────────────────────────────────────────────
122
+
123
+ function renderAsk(question, result) {
124
+ console.log(`\n${c.bold("Advisor")}\n`)
125
+ console.log(` ${c.dim("Question:")} ${question}\n`)
126
+
127
+ console.log(`${c.bold("What we know")}`)
128
+ console.log(` ${result.what_we_know}\n`)
129
+
130
+ if (result.blockers?.length > 0) {
131
+ console.log(`${c.bold(c.red("Blockers"))}`)
132
+ for (const b of result.blockers) console.log(` ${c.red("✗")} ${b}`)
133
+ console.log("")
134
+ }
135
+
136
+ if (result.risks?.length > 0) {
137
+ console.log(`${c.bold("Risks")}`)
138
+ for (const r of result.risks) console.log(` ${c.yellow("⚠")} ${r}`)
139
+ console.log("")
140
+ }
141
+
142
+ console.log(`${c.bold("Recommendation")}`)
143
+ console.log(` ${result.recommendation}\n`)
144
+
145
+ console.log(`${c.bold("Next step")}`)
146
+ console.log(` ${c.cyan(result.next_step)}\n`)
147
+
148
+ if (result.retries > 0) {
149
+ console.log(c.dim(` (Refined over ${result.retries + 1} attempts)\n`))
150
+ }
151
+ }
152
+
153
+ function renderQuery(topic, entries) {
154
+ console.log(`\n${c.bold("Chronicle")} ${c.dim(`query: "${topic}"`)}\n`)
155
+
156
+ if (entries.length === 0) {
157
+ console.log(` ${c.dim("No matching entries found.")}\n`)
158
+ return
159
+ }
160
+
161
+ for (const e of entries) {
162
+ const statusColor =
163
+ e.status === "validated" ? c.green :
164
+ e.status === "refuted" ? c.red : c.dim
165
+ console.log(` ${c.cyan((e.id ?? "").slice(0, 8))} ${statusColor(`[${e.status}]`)} ${entryText(e)}`)
166
+ if (e.affected_areas?.length) console.log(` ${c.dim(e.affected_areas.join(", "))}`)
167
+ console.log("")
168
+ }
169
+ }
170
+
171
+ function renderBrief(allEntries) {
172
+ const validated = allEntries.filter(e => e.status === "validated")
173
+ const refuted = allEntries.filter(e => e.status === "refuted")
174
+ const open = allEntries.filter(e => e.status === "open")
175
+ const recent = allEntries.slice(0, 5)
176
+
177
+ console.log(`\n${c.bold("Chronicle Brief")}\n`)
178
+ console.log(` ${c.green(validated.length)} validated ${c.red(refuted.length)} refuted ${c.dim(open.length + " open")}\n`)
179
+
180
+ if (recent.length > 0) {
181
+ console.log(`${c.bold("Recent entries")}`)
182
+ for (const e of recent) {
183
+ const statusColor =
184
+ e.status === "validated" ? c.green :
185
+ e.status === "refuted" ? c.red : c.dim
186
+ console.log(` ${c.cyan((e.id ?? "").slice(0, 8))} ${statusColor(e.status)} ${entryText(e).slice(0, 70)}`)
187
+ }
188
+ console.log("")
189
+ }
190
+ }
191
+
192
+ // ── Subcommand handlers ───────────────────────────────────────────────────────
193
+
194
+ async function cmdAsk(question, chronicleDir) {
195
+ const allEntries = await readCommitted(chronicleDir)
196
+ const evidence = findRelevant(allEntries, question)
197
+ const provider = await detectProvider()
198
+
199
+ if (!provider) {
200
+ renderPassthrough(question, evidence)
201
+ return
202
+ }
203
+
204
+ const { llm, name: llmName } = provider
205
+ process.stdout.write(c.dim(`\n Thinking (${llmName})…`))
206
+ try {
207
+ const result = await runAdvisor(llm, question, evidence)
208
+ process.stdout.write("\r" + " ".repeat(50) + "\r")
209
+ renderAsk(question, result)
210
+ } catch (err) {
211
+ process.stdout.write("\r" + " ".repeat(50) + "\r")
212
+ console.error(`\n${c.red("Advisor failed:")} ${err.message}\n`)
213
+ process.exit(1)
214
+ }
215
+ }
216
+
217
+ function renderPassthrough(question, evidence) {
218
+ console.log(`\n${c.bold("Chronicle evidence")} ${c.dim(`for: "${question}"`)}\n`)
219
+ if (evidence.length === 0) {
220
+ console.log(c.dim(" No relevant Chronicle entries found.\n"))
221
+ } else {
222
+ for (const e of evidence) {
223
+ console.log(` ${c.cyan(e.id.slice(0, 8))} ${c.bold(entryText(e))}`)
224
+ console.log(` ${c.dim(`status: ${e.status} · confidence: ${e.confidence} · areas: ${(e.affected_areas ?? []).join(", ")}`)}`)
225
+ console.log("")
226
+ }
227
+ }
228
+ console.log(c.dim("─".repeat(60)))
229
+ console.log(`\n${c.bold("Synthesis request")}`)
230
+ console.log(`\n ${question}`)
231
+ console.log(`\n${c.dim(" No LLM configured — answer from the Chronicle evidence above.")}`)
232
+ console.log(c.dim(" (Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or run Ollama for auto-synthesis.)\n"))
233
+ }
234
+
235
+ async function cmdQuery(topic, chronicleDir) {
236
+ const allEntries = await readCommitted(chronicleDir)
237
+ const matches = findRelevant(allEntries, topic, 8)
238
+ renderQuery(topic, matches)
239
+ }
240
+
241
+ async function cmdBrief(chronicleDir) {
242
+ const allEntries = await readCommitted(chronicleDir)
243
+ renderBrief(allEntries)
244
+ }
245
+
246
+ // ── Entry point ───────────────────────────────────────────────────────────────
247
+
248
+ export async function run(argv) {
249
+ const [subOrQuestion, ...rest] = argv
250
+
251
+ const chronicleDir = await findChronicleDir(process.cwd())
252
+ if (!chronicleDir) {
253
+ console.error(`\n${c.red("No .chronicle/ directory found.")} Run ${c.bold("quorum init")} first.\n`)
254
+ process.exit(1)
255
+ }
256
+
257
+ // `quorum advisor ask "..."` or `quorum advisor "..."` (default to ask)
258
+ if (subOrQuestion === "ask") {
259
+ const question = rest.join(" ").trim()
260
+ if (!question) return printUsage()
261
+ return cmdAsk(question, chronicleDir)
262
+ }
263
+
264
+ // `quorum advisor query "topic"` — Chronicle lookup, no LLM
265
+ if (subOrQuestion === "query") {
266
+ const topic = rest.join(" ").trim()
267
+ if (!topic) return printUsage()
268
+ return cmdQuery(topic, chronicleDir)
269
+ }
270
+
271
+ // `quorum advisor brief` — high-level Chronicle summary
272
+ if (subOrQuestion === "brief") {
273
+ return cmdBrief(chronicleDir)
274
+ }
275
+
276
+ // No subcommand — treat the whole argv as a question (default: ask)
277
+ const question = argv.join(" ").trim()
278
+ if (!question) return printUsage()
279
+ return cmdAsk(question, chronicleDir)
280
+ }
281
+
282
+ function printUsage() {
283
+ console.log(`
284
+ ${c.bold("quorum advisor")} — ask plain-language questions about your codebase
285
+
286
+ ${c.bold("Subcommands:")}
287
+ ${c.cyan("quorum advisor")} ${c.dim('"question"')} Ask a question (default — uses LLM)
288
+ ${c.cyan("quorum advisor ask")} ${c.dim('"question"')} Ask explicitly
289
+ ${c.cyan("quorum advisor query")} ${c.dim('"topic"')} Search Chronicle entries (no LLM)
290
+ ${c.cyan("quorum advisor brief")} High-level Chronicle summary (no LLM)
291
+
292
+ ${c.bold("Examples:")}
293
+ quorum advisor "what happens if we change the auth system?"
294
+ quorum advisor ask "is it safe to add a NOT NULL column to users?"
295
+ quorum advisor query "authentication"
296
+ quorum advisor brief
297
+
298
+ ${c.dim("ask requires ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment.")}
299
+ `)
300
+ process.exit(1)
301
+ }
@@ -119,23 +119,15 @@ export async function run(argv) {
119
119
  return
120
120
  }
121
121
 
122
- // ── Check optional dependencies ────────────────────────────────────────────
123
- console.log(`\n${c.bold("Checking dependencies")}`)
122
+ // ── Check optional embedding dependencies ─────────────────────────────────
124
123
  const hasXenova = await checkDep("@xenova/transformers")
125
124
  const hasLanceDB = await checkDep("vectordb")
125
+ const canEmbed = hasXenova && hasLanceDB
126
126
 
127
- if (!hasXenova) {
128
- console.error(`\n ${c.red("✗")} @xenova/transformers not installed`)
129
- console.error(c.dim(" Run: npm install @xenova/transformers\n"))
130
- process.exit(1)
131
- }
132
- if (!hasLanceDB) {
133
- console.error(`\n ${c.red("✗")} vectordb not installed`)
134
- console.error(c.dim(" Run: npm install vectordb\n"))
135
- process.exit(1)
127
+ if (!canEmbed) {
128
+ console.log(`\n${c.dim(" Embedding deps not found — committing to JSON store only.")}`)
129
+ console.log(c.dim(" Install @xenova/transformers + vectordb to enable semantic search.\n"))
136
130
  }
137
- console.log(` ${c.green("✓")} @xenova/transformers`)
138
- console.log(` ${c.green("✓")} vectordb`)
139
131
 
140
132
  // ── Build entry ────────────────────────────────────────────────────────────
141
133
  const entry = {
@@ -144,46 +136,44 @@ export async function run(argv) {
144
136
  timestamp: new Date().toISOString(),
145
137
  }
146
138
 
147
- // ── Embed ──────────────────────────────────────────────────────────────────
148
- const spin = spinner("Loading embedder (first run may take 30s)…")
149
- let vector
150
- try {
151
- const { pipeline } = (await import("@xenova/transformers")).default ?? await import("@xenova/transformers")
152
- const embedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2")
153
- const embeddingText = [
154
- entryText(entry),
155
- ...(entry.affected_areas ?? []),
156
- ...(entry.scope ?? []),
157
- ].join(" ")
158
- const output = await embedder(embeddingText, { pooling: "mean", normalize: true })
159
- vector = Array.from(output.data)
160
- spin.stop(`${c.green("✓")} Embedded (${vector.length}-dim)`)
161
- } catch (err) {
162
- spin.stop(`${c.red("")} Embedding failed`)
163
- console.error(c.red(`\n ${err.message}\n`))
164
- process.exit(1)
165
- }
166
-
167
- // ── Store in LanceDB ───────────────────────────────────────────────────────
168
- const storeSpin = spinner("Indexing in Chronicle…")
169
- try {
170
- const lancedb = (await import("vectordb")).default ?? (await import("vectordb"))
171
- const tableDir = path.join(chronicleDir, "entries")
172
- const db = await lancedb.connect(tableDir)
173
- const row = { id: entry.id, vector, payload: JSON.stringify(entry) }
174
- const names = await db.tableNames()
175
- if (names.includes("entries")) {
176
- const table = await db.openTable("entries")
177
- await table.delete(`id = '${entry.id.replace(/'/g, "''")}'`)
178
- await table.add([row])
179
- } else {
180
- await db.createTable("entries", [row], { metric: "cosine" })
139
+ // ── Embed + index in vector store (optional) ───────────────────────────────
140
+ if (canEmbed) {
141
+ const spin = spinner("Embedding and indexing…")
142
+ try {
143
+ const { pipeline } = (await import("@xenova/transformers")).default ?? await import("@xenova/transformers")
144
+ const embedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2")
145
+ const embeddingText = [
146
+ entryText(entry),
147
+ ...(entry.affected_areas ?? []),
148
+ ...(entry.scope ?? []),
149
+ ].join(" ")
150
+ const output = await embedder(embeddingText, { pooling: "mean", normalize: true })
151
+ const vector = Array.from(output.data)
152
+ spin.stop(`${c.green("✓")} Embedded (${vector.length}-dim)`)
153
+
154
+ const storeSpin = spinner("Indexing in vector store…")
155
+ try {
156
+ const lancedb = (await import("vectordb")).default ?? (await import("vectordb"))
157
+ const tableDir = path.join(chronicleDir, "entries")
158
+ const db = await lancedb.connect(tableDir)
159
+ const row = { id: entry.id, vector, payload: JSON.stringify(entry) }
160
+ const names = await db.tableNames()
161
+ if (names.includes("entries")) {
162
+ const table = await db.openTable("entries")
163
+ await table.delete(`id = '${entry.id.replace(/'/g, "''")}'`)
164
+ await table.add([row])
165
+ } else {
166
+ await db.createTable("entries", [row], { metric: "cosine" })
167
+ }
168
+ storeSpin.stop(`${c.green("")} Indexed in vector store`)
169
+ } catch (err) {
170
+ storeSpin.stop(`${c.yellow("⚠")} Vector store write failed — JSON commit will proceed`)
171
+ console.error(c.dim(` ${err.message}`))
172
+ }
173
+ } catch (err) {
174
+ spin.stop(`${c.yellow("⚠")} Embedding failed — JSON commit will proceed`)
175
+ console.error(c.dim(` ${err.message}`))
181
176
  }
182
- storeSpin.stop(`${c.green("✓")} Indexed in vector store`)
183
- } catch (err) {
184
- storeSpin.stop(`${c.red("✗")} Vector store write failed`)
185
- console.error(c.red(`\n ${err.message}\n`))
186
- process.exit(1)
187
177
  }
188
178
 
189
179
  // ── Write committed file ───────────────────────────────────────────────────