@balpal4495/quorum 0.4.0 → 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/CLAUDE.md +102 -42
- package/README.md +226 -176
- package/SETUP.md +24 -10
- package/bin/commands/advisor.js +301 -0
- package/bin/commands/commit.js +42 -52
- package/bin/commands/evolve.js +285 -0
- package/bin/commands/growth.js +139 -0
- package/bin/commands/init.js +8 -0
- package/bin/commands/sentinel.js +1 -1
- package/bin/init.js +9 -1
- package/bin/quorum.js +28 -0
- package/bin/shared/llm.js +228 -0
- package/modules/AGENTS.md +8 -0
- package/modules/CLAUDE.md +8 -2
- package/modules/README.md +72 -13
- package/modules/advisor/ask.ts +87 -0
- package/modules/advisor/index.ts +2 -0
- package/modules/advisor/prompt.ts +50 -0
- package/modules/advisor/types.ts +26 -0
- package/modules/council/deliberate.ts +15 -5
- package/modules/council/types.ts +1 -1
- package/modules/setup.ts +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { spawn, exec } from "child_process"
|
|
2
|
+
import { promisify } from "util"
|
|
3
|
+
import path from "path"
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auto-detect an available LLM provider from the environment.
|
|
9
|
+
*
|
|
10
|
+
* Priority:
|
|
11
|
+
* 1. ANTHROPIC_API_KEY → Anthropic Claude
|
|
12
|
+
* 2. OPENAI_API_KEY → OpenAI (or compatible via OPENAI_BASE_URL)
|
|
13
|
+
* 3. GEMINI_API_KEY → Google Gemini (API)
|
|
14
|
+
* 4. OPENAI_BASE_URL (no key) → OpenAI-compatible endpoint (Azure, Groq, Ollama, etc.)
|
|
15
|
+
* 5. OLLAMA_HOST env var → Ollama (explicit host)
|
|
16
|
+
* 6. localhost:11434 probe → Ollama (auto-detect)
|
|
17
|
+
* 7. gemini CLI in PATH → Google Gemini (CLI subprocess)
|
|
18
|
+
*
|
|
19
|
+
* Returns { llm: LLMProvider, name: string } or null.
|
|
20
|
+
*/
|
|
21
|
+
export async function detectProvider() {
|
|
22
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
23
|
+
return {
|
|
24
|
+
llm: createAnthropicProvider(process.env.ANTHROPIC_API_KEY),
|
|
25
|
+
name: "Anthropic",
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.env.OPENAI_API_KEY) {
|
|
30
|
+
const base = (process.env.OPENAI_BASE_URL ?? "").replace(/\/$/, "")
|
|
31
|
+
const name = base
|
|
32
|
+
? `OpenAI-compatible (${new URL(base).hostname})`
|
|
33
|
+
: "OpenAI"
|
|
34
|
+
return {
|
|
35
|
+
llm: createOpenAICompatProvider(process.env.OPENAI_API_KEY, base || "https://api.openai.com/v1"),
|
|
36
|
+
name,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.env.GEMINI_API_KEY) {
|
|
41
|
+
return {
|
|
42
|
+
llm: createGeminiProvider(process.env.GEMINI_API_KEY),
|
|
43
|
+
name: "Gemini",
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (process.env.OPENAI_BASE_URL) {
|
|
48
|
+
const base = process.env.OPENAI_BASE_URL.replace(/\/$/, "")
|
|
49
|
+
return {
|
|
50
|
+
llm: createOpenAICompatProvider("", base),
|
|
51
|
+
name: `OpenAI-compatible (${new URL(base).hostname})`,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ollamaHost = process.env.OLLAMA_HOST || "http://localhost:11434"
|
|
56
|
+
const ollamaModel = await probeOllama(ollamaHost)
|
|
57
|
+
if (ollamaModel) {
|
|
58
|
+
return {
|
|
59
|
+
llm: createOpenAICompatProvider("", `${ollamaHost}/v1`, ollamaModel),
|
|
60
|
+
name: `Ollama (${ollamaModel})`,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const geminiCLI = await probeGeminiCLI()
|
|
65
|
+
if (geminiCLI) {
|
|
66
|
+
return {
|
|
67
|
+
llm: createGeminiCLIProvider(),
|
|
68
|
+
name: "Gemini CLI",
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Convenience wrapper — returns the provider function or null. */
|
|
76
|
+
export async function detectLLM() {
|
|
77
|
+
return (await detectProvider())?.llm ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Convenience wrapper — returns the provider name or null. */
|
|
81
|
+
export async function detectLLMName() {
|
|
82
|
+
return (await detectProvider())?.name ?? null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Probe Ollama ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async function probeOllama(host) {
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(1500) })
|
|
90
|
+
if (!res.ok) return null
|
|
91
|
+
const data = await res.json()
|
|
92
|
+
const model = process.env.OLLAMA_MODEL ?? data.models?.[0]?.name
|
|
93
|
+
return model ?? null
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Provider factories ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function createAnthropicProvider(apiKey) {
|
|
102
|
+
return async function llm(messages, model = "claude-3-5-sonnet-20241022") {
|
|
103
|
+
const systemMsg = messages.find(m => m.role === "system")?.content
|
|
104
|
+
const userMessages = messages.filter(m => m.role !== "system")
|
|
105
|
+
|
|
106
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
"x-api-key": apiKey,
|
|
110
|
+
"anthropic-version": "2023-06-01",
|
|
111
|
+
"content-type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
model,
|
|
115
|
+
max_tokens: 2048,
|
|
116
|
+
...(systemMsg ? { system: systemMsg } : {}),
|
|
117
|
+
messages: userMessages,
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
122
|
+
const data = await res.json()
|
|
123
|
+
return data.content?.[0]?.text ?? ""
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* OpenAI and OpenAI-compatible endpoints (Azure, Groq, Together, Ollama, etc.).
|
|
129
|
+
* Pass an empty apiKey for endpoints that don't require one.
|
|
130
|
+
* Pass a fixedModel to pin the model (e.g. for Ollama where the model comes from probe).
|
|
131
|
+
*/
|
|
132
|
+
function createOpenAICompatProvider(apiKey, baseUrl, fixedModel) {
|
|
133
|
+
return async function llm(messages, model = "gpt-4o") {
|
|
134
|
+
const headers = { "content-type": "application/json" }
|
|
135
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
|
|
136
|
+
|
|
137
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers,
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
model: fixedModel ?? model,
|
|
142
|
+
messages,
|
|
143
|
+
max_tokens: 2048,
|
|
144
|
+
}),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
if (!res.ok) throw new Error(`API ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
148
|
+
const data = await res.json()
|
|
149
|
+
return data.choices?.[0]?.message?.content ?? ""
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createGeminiProvider(apiKey) {
|
|
154
|
+
const defaultModel = process.env.GEMINI_MODEL ?? "gemini-2.0-flash"
|
|
155
|
+
|
|
156
|
+
return async function llm(messages, model = defaultModel) {
|
|
157
|
+
const systemMsg = messages.find(m => m.role === "system")?.content
|
|
158
|
+
const contents = messages
|
|
159
|
+
.filter(m => m.role !== "system")
|
|
160
|
+
.map(m => ({
|
|
161
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
162
|
+
parts: [{ text: m.content }],
|
|
163
|
+
}))
|
|
164
|
+
|
|
165
|
+
const body = {
|
|
166
|
+
contents,
|
|
167
|
+
generationConfig: { maxOutputTokens: 2048 },
|
|
168
|
+
}
|
|
169
|
+
if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] }
|
|
170
|
+
|
|
171
|
+
const res = await fetch(
|
|
172
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
|
173
|
+
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if (!res.ok) throw new Error(`Gemini API ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
177
|
+
const data = await res.json()
|
|
178
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text ?? ""
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function probeGeminiCLI() {
|
|
183
|
+
try {
|
|
184
|
+
await execAsync("which gemini")
|
|
185
|
+
} catch {
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Env vars that indicate Gemini CLI is authenticated
|
|
190
|
+
if (process.env.GOOGLE_GENAI_USE_VERTEXAI || process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Settings file with a configured auth type
|
|
195
|
+
try {
|
|
196
|
+
const { homedir } = await import("os")
|
|
197
|
+
const { readFile } = await import("fs/promises")
|
|
198
|
+
const raw = await readFile(path.join(homedir(), ".gemini", "settings.json"), "utf8")
|
|
199
|
+
const config = JSON.parse(raw)
|
|
200
|
+
return !!config.selectedAuthType
|
|
201
|
+
} catch {
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createGeminiCLIProvider() {
|
|
207
|
+
return function llm(messages) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const system = messages.find(m => m.role === "system")?.content ?? ""
|
|
210
|
+
const userContent = messages.filter(m => m.role !== "system").map(m => m.content).join("\n\n")
|
|
211
|
+
|
|
212
|
+
// Pass system instruction via -p; pipe user content via stdin
|
|
213
|
+
const args = system ? ["-p", system] : []
|
|
214
|
+
const child = spawn("gemini", args, { stdio: ["pipe", "pipe", "pipe"] })
|
|
215
|
+
|
|
216
|
+
let out = "", err = ""
|
|
217
|
+
child.stdout.on("data", d => { out += d })
|
|
218
|
+
child.stderr.on("data", d => { err += d })
|
|
219
|
+
child.on("error", reject)
|
|
220
|
+
child.on("close", code => {
|
|
221
|
+
if (code === 0) resolve(out.trim())
|
|
222
|
+
else reject(new Error(`gemini CLI exited ${code}: ${err.slice(0, 200)}`))
|
|
223
|
+
})
|
|
224
|
+
child.stdin.write(userContent)
|
|
225
|
+
child.stdin.end()
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
package/modules/AGENTS.md
CHANGED
|
@@ -35,6 +35,13 @@ When working inside this folder, follow these rules in addition to the root guid
|
|
|
35
35
|
| `council/risk.ts` | Deterministic risk classifier — no LLM. Assigns `low/medium/high/critical` and `council_mode` from design text and refuted evidence. Drives advisor/reviewer fan-out counts. |
|
|
36
36
|
| `council/deliberate.ts` | Full pipeline orchestration. Calls `oracle.propose()` at the end — never `oracle.commit()`. Risk classifier runs first to set fan-out counts. |
|
|
37
37
|
|
|
38
|
+
### Advisor
|
|
39
|
+
| File | Owns |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `advisor/ask.ts` | Main entry point. Queries Oracle, calls LLM, validates answer against satisfaction threshold (confidence ≥ 0.7, no blockers). Retries up to 2 times with previous answer as context. Throws on bad LLM output — do not add fallbacks. |
|
|
42
|
+
| `advisor/prompt.ts` | SYSTEM_PROMPT, evidence formatter, user prompt builder. The plain-language framing lives here. |
|
|
43
|
+
| `advisor/types.ts` | `AdvisorInput`, `AdvisorAnswer`, `AdvisorOutput`, `AdvisorDeps` types. |
|
|
44
|
+
|
|
38
45
|
---
|
|
39
46
|
|
|
40
47
|
## Extension points
|
|
@@ -51,6 +58,7 @@ When working inside this folder, follow these rules in addition to the root guid
|
|
|
51
58
|
|
|
52
59
|
## Invariants — do not break these
|
|
53
60
|
|
|
61
|
+
- `advisor/ask.ts` never calls `oracle.propose()` or `oracle.commit()`. It is a read-only path.
|
|
54
62
|
- `oracle.commit()` is never called without explicit human input. `deliberate()` calls `propose()` only.
|
|
55
63
|
- `jury/evaluate.ts` recomputes `confidence` as the exact average of `confidence_breakdown` dimensions — the LLM value is discarded.
|
|
56
64
|
- `jury/evaluate.ts` derives `council_brief` from the recomputed confidence — never trusts the LLM value.
|
package/modules/CLAUDE.md
CHANGED
|
@@ -6,7 +6,7 @@ Supplements the root-level instructions. Read this when working inside the `modu
|
|
|
6
6
|
|
|
7
7
|
## What these modules are
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Five portable TypeScript modules — Advisor, Oracle, Jury, Council, Sentinel — that form the knowledge and reasoning layer of an agentic workflow. They are designed to be dropped into any Node.js codebase.
|
|
10
10
|
|
|
11
11
|
The entry point for a host application is `setup.ts`. Everything else is internal.
|
|
12
12
|
|
|
@@ -21,7 +21,13 @@ No module imports a specific LLM provider, vector store, or embedder. All extern
|
|
|
21
21
|
In `jury/evaluate.ts`, after parsing the LLM response, `confidence` is recomputed as the exact average of the four `confidence_breakdown` dimensions. The LLM's stated `confidence` value is discarded. `council_brief` is then derived from this recomputed value. Do not remove either override.
|
|
22
22
|
|
|
23
23
|
### Throw on bad LLM output — never default to passing
|
|
24
|
-
|
|
24
|
+
`jury/evaluate.ts`, `council/chairman.ts`, and `advisor/ask.ts` all throw if the LLM returns non-JSON or output that fails schema validation. This is intentional. A silently passing score is worse than an error. Do not add fallbacks or defaults.
|
|
25
|
+
|
|
26
|
+
### Advisor is a read-only path
|
|
27
|
+
`advisor/ask.ts` queries Oracle and calls the LLM — it never calls `oracle.propose()` or `oracle.commit()`. It has no side effects on Chronicle. Do not add write calls to the Advisor path.
|
|
28
|
+
|
|
29
|
+
### Advisor validation loop
|
|
30
|
+
`advisor/ask.ts` retries the LLM call up to `MAX_RETRIES` (2) times when the answer does not meet the satisfaction threshold (confidence ≥ 0.7, no blockers). The previous answer is included as context in the retry prompt. After the retry budget is exhausted, the best answer is returned regardless. Do not increase `MAX_RETRIES` without considering LLM cost implications.
|
|
25
31
|
|
|
26
32
|
### oracle.commit() is a human gate
|
|
27
33
|
`council/deliberate.ts` calls `oracle.propose()` at the end of every deliberation. It never calls `oracle.commit()`. If you see a code path that calls `oracle.commit()` without explicit human input, that is a bug.
|
package/modules/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
# Oracle · Jury · Council · Sentinel
|
|
1
|
+
# Advisor · Oracle · Jury · Council · Sentinel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Five portable modules for the knowledge and reasoning layer of any agentic workflow.
|
|
4
4
|
Drop the `modules/` folder into your project and wire up the dependencies.
|
|
5
5
|
|
|
6
6
|
```
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Advisor → plain-language questions answered from Chronicle
|
|
8
|
+
Oracle → Jury → Council → human gate → Executor
|
|
9
|
+
Sentinel → coverage + drift + PR coverage map
|
|
9
10
|
```
|
|
10
11
|
|
|
11
12
|
---
|
|
@@ -14,6 +15,7 @@ Sentinel → coverage + drift + PR coverage map
|
|
|
14
15
|
|
|
15
16
|
| Module | Responsibility | LLM? |
|
|
16
17
|
|---|---|---|
|
|
18
|
+
| **Advisor** | Ask a plain-language question — synthesises Chronicle evidence into a concise answer with an internal validation loop | Yes |
|
|
17
19
|
| **Oracle** | Query and write interface to Chronicle (the persistent knowledge store) | No |
|
|
18
20
|
| **Jury** | Evaluate a design against Oracle evidence — produces a confidence score | Yes |
|
|
19
21
|
| **Council** | Adversarial validation via parallel advisor/reviewer fan-out — produces a verdict | Yes |
|
|
@@ -107,18 +109,34 @@ Recommended `tsconfig.json` settings:
|
|
|
107
109
|
|
|
108
110
|
## Quick start
|
|
109
111
|
|
|
112
|
+
```typescript
|
|
113
|
+
import { setup } from "./modules/setup"
|
|
114
|
+
|
|
115
|
+
// The simplest entry point — wires all modules from one call
|
|
116
|
+
const { oracle, evaluate, deliberate, ask } = await setup({ llm: myLLMProvider })
|
|
117
|
+
|
|
118
|
+
// Ask a plain-language question (Advisor)
|
|
119
|
+
const answer = await ask("what did the team decide about authentication?")
|
|
120
|
+
// answer.what_we_know, .recommendation, .next_step, .risks, .blockers, .retries
|
|
121
|
+
|
|
122
|
+
// Or run the full evaluation pipeline for a proposed design:
|
|
123
|
+
const evidence = await oracle.query("authentication patterns in this codebase")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Manual wiring (without setup())
|
|
127
|
+
|
|
110
128
|
```typescript
|
|
111
129
|
import { createOracleClient, xenovaEmbed, createLanceDBStore } from "./modules/oracle"
|
|
112
130
|
import { evaluate } from "./modules/jury"
|
|
113
131
|
import { deliberate } from "./modules/council"
|
|
114
132
|
|
|
115
|
-
//
|
|
133
|
+
// Wire Oracle manually
|
|
116
134
|
const oracle = createOracleClient({
|
|
117
135
|
embedder: xenovaEmbed,
|
|
118
136
|
vectorStore: await createLanceDBStore(".chronicle"),
|
|
119
137
|
})
|
|
120
138
|
|
|
121
|
-
//
|
|
139
|
+
// Retrieve evidence for the task at hand
|
|
122
140
|
const evidence = await oracle.query("authentication patterns in this codebase")
|
|
123
141
|
|
|
124
142
|
// 3. Jury evaluates the design against the evidence
|
|
@@ -167,6 +185,47 @@ if (verdict.satisfied) {
|
|
|
167
185
|
|
|
168
186
|
---
|
|
169
187
|
|
|
188
|
+
## Advisor
|
|
189
|
+
|
|
190
|
+
The Advisor is the plain-language interface to Chronicle. Use it to answer questions rather than to evaluate designs. It is a **read-only** path — it never calls `oracle.propose()` or `oracle.commit()`.
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { ask } from "./modules/advisor"
|
|
194
|
+
|
|
195
|
+
const answer = await ask(
|
|
196
|
+
{ question: "What did the team decide about session handling?", evidence },
|
|
197
|
+
{ llm: myLLMProvider },
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Or via `setup()`, which queries Oracle automatically:
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
const { ask } = await setup({ llm: myLLMProvider })
|
|
205
|
+
const answer = await ask("What did the team decide about session handling?")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Advisor output
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
interface AdvisorOutput {
|
|
212
|
+
question: string
|
|
213
|
+
confidence: number // 0–1 — how confident the answer is given the evidence
|
|
214
|
+
what_we_know: string // plain-language summary of relevant Chronicle knowledge
|
|
215
|
+
risks: string[] // real risks worth knowing
|
|
216
|
+
blockers: string[] // hard blockers — must be resolved before acting (often empty)
|
|
217
|
+
recommendation: string // one clear recommended action
|
|
218
|
+
next_step: string // specific next step or quorum command
|
|
219
|
+
retries: number // how many retry attempts were needed (0 = first try succeeded)
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Validation loop
|
|
224
|
+
|
|
225
|
+
Advisor validates its own answer before returning. If `confidence < 0.7` or `blockers.length > 0`, it retries the LLM call with the previous answer as context — up to 2 retries. After the retry budget is exhausted, the best answer is returned regardless. Throws on non-JSON or schema-invalid LLM output.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
170
229
|
## LLM provider interface
|
|
171
230
|
|
|
172
231
|
The `LLMProvider` type is a simple function. Wire it to any provider:
|
|
@@ -284,14 +343,14 @@ const risk = classifyRisk(outcome, design, evidence)
|
|
|
284
343
|
// risk.council_mode — "jury-only" | "lite" | "full"
|
|
285
344
|
```
|
|
286
345
|
|
|
287
|
-
| Risk | Triggers | Advisor + Reviewer
|
|
288
|
-
|
|
289
|
-
| Low | Nothing sensitive detected |
|
|
290
|
-
| Medium | Cache, queues, deployments, rate limiting | 1 + 2 |
|
|
291
|
-
| High | DB migrations, permissions, PII, secrets | 5 + 5 |
|
|
292
|
-
| Critical | Auth, payments, crypto, data deletion | 5 + 5 |
|
|
346
|
+
| Risk | Triggers | Council mode | Advisor + Reviewer |
|
|
347
|
+
|---|---|---|---|
|
|
348
|
+
| Low | Nothing sensitive detected | jury-only — skipped | 0 + 0 |
|
|
349
|
+
| Medium | Cache, queues, deployments, rate limiting | lite | 1 + 2 |
|
|
350
|
+
| High | DB migrations, permissions, PII, secrets | full | 5 + 5 |
|
|
351
|
+
| Critical | Auth, payments, crypto, data deletion | full + human flag | 5 + 5 |
|
|
293
352
|
|
|
294
|
-
Refuted entries in the evidence pack always elevate risk by at least one level.
|
|
353
|
+
Refuted entries in the evidence pack always elevate risk by at least one level. `jury-only` means Council is not called at all — Jury alone is sufficient for low-risk designs.
|
|
295
354
|
|
|
296
355
|
### Council output routing
|
|
297
356
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import type { AdvisorInput, AdvisorOutput, AdvisorAnswer, AdvisorDeps } from "./types"
|
|
3
|
+
import { SYSTEM_PROMPT, buildUserPrompt } from "./prompt"
|
|
4
|
+
|
|
5
|
+
const SATISFACTION_THRESHOLD = 0.7
|
|
6
|
+
const MAX_RETRIES = 2
|
|
7
|
+
|
|
8
|
+
const AdvisorAnswerSchema = z.object({
|
|
9
|
+
confidence: z.number().min(0).max(1),
|
|
10
|
+
what_we_know: z.string().min(1),
|
|
11
|
+
risks: z.array(z.string()),
|
|
12
|
+
blockers: z.array(z.string()),
|
|
13
|
+
recommendation: z.string().min(1),
|
|
14
|
+
next_step: z.string().min(1),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
async function callLLM(
|
|
18
|
+
input: AdvisorInput,
|
|
19
|
+
deps: AdvisorDeps,
|
|
20
|
+
attempt: number,
|
|
21
|
+
previous: AdvisorAnswer | null,
|
|
22
|
+
): Promise<AdvisorAnswer> {
|
|
23
|
+
const { llm, model } = deps
|
|
24
|
+
|
|
25
|
+
let userPrompt = buildUserPrompt(input.question, input.evidence)
|
|
26
|
+
|
|
27
|
+
if (attempt > 0 && previous) {
|
|
28
|
+
userPrompt += [
|
|
29
|
+
"",
|
|
30
|
+
`## Previous Answer (attempt ${attempt} — did not meet quality threshold)`,
|
|
31
|
+
`Confidence: ${previous.confidence.toFixed(2)} (need ≥ ${SATISFACTION_THRESHOLD})`,
|
|
32
|
+
previous.blockers.length > 0
|
|
33
|
+
? `Unresolved blockers: ${previous.blockers.join("; ")}`
|
|
34
|
+
: "",
|
|
35
|
+
"Please produce a more specific and concrete answer.",
|
|
36
|
+
].filter(Boolean).join("\n")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const raw = await llm(
|
|
40
|
+
[
|
|
41
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
42
|
+
{ role: "user", content: userPrompt },
|
|
43
|
+
],
|
|
44
|
+
model,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
let parsed: unknown
|
|
48
|
+
try {
|
|
49
|
+
const cleaned = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim()
|
|
50
|
+
parsed = JSON.parse(cleaned)
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error(`Advisor: LLM returned non-JSON. Raw (first 300 chars): ${raw.slice(0, 300)}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = AdvisorAnswerSchema.safeParse(parsed)
|
|
56
|
+
if (!result.success) {
|
|
57
|
+
throw new Error(`Advisor: LLM output failed validation. Issues: ${JSON.stringify(result.error.issues)}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return result.data
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ask the Advisor a plain-language question.
|
|
65
|
+
*
|
|
66
|
+
* Internally calls the LLM and validates the answer against a satisfaction
|
|
67
|
+
* threshold (confidence ≥ 0.7, no blockers). Retries up to MAX_RETRIES times
|
|
68
|
+
* with the previous answer included as context. Returns the best answer found
|
|
69
|
+
* within the retry budget regardless of whether the threshold was met.
|
|
70
|
+
*
|
|
71
|
+
* Throws if the LLM returns non-JSON or output that fails schema validation.
|
|
72
|
+
*/
|
|
73
|
+
export async function ask(input: AdvisorInput, deps: AdvisorDeps): Promise<AdvisorOutput> {
|
|
74
|
+
let last: AdvisorAnswer | null = null
|
|
75
|
+
|
|
76
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
77
|
+
const answer = await callLLM(input, deps, attempt, last)
|
|
78
|
+
last = answer
|
|
79
|
+
|
|
80
|
+
const satisfied = answer.confidence >= SATISFACTION_THRESHOLD && answer.blockers.length === 0
|
|
81
|
+
if (satisfied || attempt === MAX_RETRIES) {
|
|
82
|
+
return { ...answer, question: input.question, retries: attempt }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ...last!, question: input.question, retries: MAX_RETRIES }
|
|
87
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { OracleResult } from "../shared/types"
|
|
2
|
+
import { entryText } from "../shared/types"
|
|
3
|
+
|
|
4
|
+
export const SYSTEM_PROMPT = `You are the Quorum Advisor — the plain-language interface to a team's collective knowledge.
|
|
5
|
+
|
|
6
|
+
You receive a question from a developer or engineering manager, along with relevant Chronicle evidence.
|
|
7
|
+
Synthesise that evidence into a clear, concise answer a human can act on.
|
|
8
|
+
|
|
9
|
+
Rules:
|
|
10
|
+
- Write for a human who does not know what "Chronicle entries" or "vector search" mean.
|
|
11
|
+
- Be direct. One clear recommendation, not a list of options unless genuinely necessary.
|
|
12
|
+
- If Chronicle has relevant evidence, reference it plainly: "the team already decided X".
|
|
13
|
+
- If Chronicle has no evidence, say so honestly — do not invent history.
|
|
14
|
+
- Blockers are hard blockers only — things that MUST be resolved before moving forward.
|
|
15
|
+
- risks are real concerns worth knowing, not theoretical edge cases.
|
|
16
|
+
|
|
17
|
+
Return ONLY valid JSON matching this schema (no markdown fences, no explanation):
|
|
18
|
+
{
|
|
19
|
+
"confidence": <number 0–1 — how confident are you given the available evidence>,
|
|
20
|
+
"what_we_know": <string — what Chronicle knows about this topic. Plain English, 1–3 sentences.>,
|
|
21
|
+
"risks": [<string — each real risk, plain English, one per item. Empty array if none.>],
|
|
22
|
+
"blockers": [<string — hard blockers only. Empty array if none.>],
|
|
23
|
+
"recommendation": <string — one clear recommended action>,
|
|
24
|
+
"next_step": <string — the specific next thing to do, e.g. a quorum command or a decision>
|
|
25
|
+
}`
|
|
26
|
+
|
|
27
|
+
export function formatEvidence(evidence: OracleResult[]): string {
|
|
28
|
+
if (evidence.length === 0) {
|
|
29
|
+
return "Chronicle has no prior entries on this topic."
|
|
30
|
+
}
|
|
31
|
+
return evidence
|
|
32
|
+
.map(e => {
|
|
33
|
+
const text = entryText(e)
|
|
34
|
+
const statusTag =
|
|
35
|
+
e.status === "refuted" ? " [REJECTED]" :
|
|
36
|
+
e.status === "validated" ? " [VALIDATED]" : ""
|
|
37
|
+
return `[${e.id.slice(0, 8)}]${statusTag} ${text}\n Areas: ${e.affected_areas.join(", ")}`
|
|
38
|
+
})
|
|
39
|
+
.join("\n\n")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildUserPrompt(question: string, evidence: OracleResult[]): string {
|
|
43
|
+
return [
|
|
44
|
+
"## Question",
|
|
45
|
+
question,
|
|
46
|
+
"",
|
|
47
|
+
"## Chronicle Evidence",
|
|
48
|
+
formatEvidence(evidence),
|
|
49
|
+
].join("\n")
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OracleResult, LLMProvider } from "../shared/types"
|
|
2
|
+
|
|
3
|
+
export interface AdvisorInput {
|
|
4
|
+
question: string
|
|
5
|
+
evidence: OracleResult[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AdvisorAnswer {
|
|
9
|
+
confidence: number
|
|
10
|
+
what_we_know: string
|
|
11
|
+
risks: string[]
|
|
12
|
+
blockers: string[]
|
|
13
|
+
recommendation: string
|
|
14
|
+
next_step: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AdvisorOutput extends AdvisorAnswer {
|
|
18
|
+
question: string
|
|
19
|
+
/** Number of retries taken before the answer met the satisfaction threshold. */
|
|
20
|
+
retries: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AdvisorDeps {
|
|
24
|
+
llm: LLMProvider
|
|
25
|
+
model?: string
|
|
26
|
+
}
|
|
@@ -10,8 +10,6 @@ const DEFAULT_ADVISOR_COUNT = 5
|
|
|
10
10
|
const DEFAULT_REVIEWER_COUNT = 5
|
|
11
11
|
const LITE_ADVISOR_COUNT = 1
|
|
12
12
|
const LITE_REVIEWER_COUNT = 2
|
|
13
|
-
const JURY_ONLY_ADVISOR_COUNT = 1
|
|
14
|
-
const JURY_ONLY_REVIEWER_COUNT = 1
|
|
15
13
|
|
|
16
14
|
/**
|
|
17
15
|
* Run the Council deliberation pipeline.
|
|
@@ -44,14 +42,26 @@ export async function deliberate(
|
|
|
44
42
|
|
|
45
43
|
// Classify risk to determine Council mode and advisor/reviewer counts
|
|
46
44
|
const risk = classifyRisk(input.outcome, input.design, input.evidence)
|
|
45
|
+
|
|
46
|
+
if (risk.council_mode === "jury-only") {
|
|
47
|
+
return {
|
|
48
|
+
satisfied: true,
|
|
49
|
+
verdict: "Skipped — low-risk design passed by Jury without Council review.",
|
|
50
|
+
blockers: [],
|
|
51
|
+
warnings: [],
|
|
52
|
+
challenges: [],
|
|
53
|
+
evidence_cited: [],
|
|
54
|
+
citation_validation: { valid_ids: [], hallucinated_ids: [] },
|
|
55
|
+
advisor_split: { proceed: 0, redesign: 0, "investigate-more": 0 },
|
|
56
|
+
recommendation: "proceed",
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
let defaultAdvisors = DEFAULT_ADVISOR_COUNT
|
|
48
61
|
let defaultReviewers = DEFAULT_REVIEWER_COUNT
|
|
49
62
|
if (risk.council_mode === "lite") {
|
|
50
63
|
defaultAdvisors = LITE_ADVISOR_COUNT
|
|
51
64
|
defaultReviewers = LITE_REVIEWER_COUNT
|
|
52
|
-
} else if (risk.council_mode === "jury-only") {
|
|
53
|
-
defaultAdvisors = JURY_ONLY_ADVISOR_COUNT
|
|
54
|
-
defaultReviewers = JURY_ONLY_REVIEWER_COUNT
|
|
55
65
|
}
|
|
56
66
|
const advisorCount = deps.advisorCount ?? defaultAdvisors
|
|
57
67
|
const reviewerCount = deps.reviewerCount ?? defaultReviewers
|
package/modules/council/types.ts
CHANGED
|
@@ -94,7 +94,7 @@ export type RiskLevel = "low" | "medium" | "high" | "critical"
|
|
|
94
94
|
/**
|
|
95
95
|
* Determines which Council mode to use.
|
|
96
96
|
* skip → Oracle query only, no LLM validation
|
|
97
|
-
* jury-only → Jury scores,
|
|
97
|
+
* jury-only → Jury scores, Council skipped entirely (low-risk fast path)
|
|
98
98
|
* lite → Jury + 1–2 reviewers (no full advisor fan-out)
|
|
99
99
|
* full → Full Council (default 5 advisors + 5 reviewers + Chairman)
|
|
100
100
|
*/
|
package/modules/setup.ts
CHANGED
|
@@ -5,10 +5,12 @@ import { xenovaEmbed, warmEmbedder } from "./oracle/adapters/xenova-embedder"
|
|
|
5
5
|
import { createLanceDBStore } from "./oracle/adapters/lance-db"
|
|
6
6
|
import { evaluate } from "./jury/evaluate"
|
|
7
7
|
import { deliberate } from "./council/deliberate"
|
|
8
|
+
import { ask as advisorAsk } from "./advisor/ask"
|
|
8
9
|
import type { LLMProvider, OracleClient } from "./shared/types"
|
|
9
10
|
import { entryText } from "./shared/types"
|
|
10
11
|
import type { JuryInput, JuryOutput, JuryDeps } from "./jury/types"
|
|
11
12
|
import type { CouncilInput, CouncilOutput, CouncilDeps, CouncilModels } from "./council/types"
|
|
13
|
+
import type { AdvisorOutput } from "./advisor/types"
|
|
12
14
|
|
|
13
15
|
export interface SetupOptions {
|
|
14
16
|
/**
|
|
@@ -69,6 +71,14 @@ export interface Modules {
|
|
|
69
71
|
deliberate: (
|
|
70
72
|
input: Omit<CouncilInput, "jury_output"> & { jury_output: JuryOutput },
|
|
71
73
|
) => Promise<CouncilOutput>
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ask the Advisor a plain-language question.
|
|
77
|
+
* Queries Oracle automatically, synthesises Chronicle evidence into a
|
|
78
|
+
* human-readable answer, and retries internally until the answer meets
|
|
79
|
+
* the confidence threshold (≥ 0.7, no blockers) or the retry budget runs out.
|
|
80
|
+
*/
|
|
81
|
+
ask: (question: string) => Promise<AdvisorOutput>
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
/**
|
|
@@ -150,5 +160,10 @@ export async function setup(options: SetupOptions): Promise<Modules> {
|
|
|
150
160
|
oracle,
|
|
151
161
|
models: models.council,
|
|
152
162
|
}),
|
|
163
|
+
|
|
164
|
+
ask: async (question: string) => {
|
|
165
|
+
const evidence = await oracle.query(question)
|
|
166
|
+
return advisorAsk({ question, evidence }, { llm })
|
|
167
|
+
},
|
|
153
168
|
}
|
|
154
169
|
}
|