@balpal4495/quorum 0.4.2 → 2.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.
@@ -10,12 +10,6 @@ const _require = createRequire(import.meta.url)
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
11
11
  const QUORUM_ROOT = path.resolve(__dirname, "../..")
12
12
 
13
- const DEPS = { zod: "^3.23.0" }
14
- const OPTIONAL_DEPS = {
15
- vectordb: "^0.4.0",
16
- "@xenova/transformers": "^2.17.0",
17
- }
18
-
19
13
  async function exists(p) {
20
14
  return fs.access(p).then(() => true).catch(() => false)
21
15
  }
@@ -29,25 +23,24 @@ function geminiAvailable() {
29
23
  }
30
24
 
31
25
  async function guardAlreadyInitialized(target) {
32
- if (await exists(path.join(target, "quorum", "modules"))) {
26
+ if (await exists(path.join(target, ".quorum-version"))) {
33
27
  console.log(c.yellow("\nQuorum is already initialized in this project."))
34
- console.log("Remove quorum/ first if you want to reinitialize.\n")
28
+ console.log("Run 'npm update @balpal4495/quorum' to upgrade to the latest version.\n")
35
29
  process.exit(0)
36
30
  }
37
31
  }
38
32
 
39
- async function copyModules(target) {
40
- log.section("Copying modules")
41
- const src = path.join(QUORUM_ROOT, "modules")
42
- const dest = path.join(target, "quorum", "modules")
43
- await fs.cp(src, dest, {
44
- recursive: true,
45
- filter: (src) =>
46
- !src.includes("__tests__") &&
47
- !src.includes(".test.ts") &&
48
- !src.includes(".spec.ts"),
49
- })
50
- log.created("quorum/modules/")
33
+ async function writeQuorumDocs(target) {
34
+ log.section("Writing Quorum docs")
35
+ await fs.mkdir(path.join(target, "quorum"), { recursive: true })
36
+ for (const file of ["CLAUDE.md", "AGENTS.md"]) {
37
+ const src = path.join(QUORUM_ROOT, "modules", file)
38
+ const dest = path.join(target, "quorum", file)
39
+ if (await exists(src)) {
40
+ await fs.copyFile(src, dest)
41
+ log.created(`quorum/${file}`)
42
+ }
43
+ }
51
44
  await fs.copyFile(
52
45
  path.join(QUORUM_ROOT, "SETUP.md"),
53
46
  path.join(target, "quorum", "SETUP.md"),
@@ -55,11 +48,9 @@ async function copyModules(target) {
55
48
  log.created("quorum/SETUP.md")
56
49
  }
57
50
 
58
- async function copyEvals(target) {
59
- const src = path.join(QUORUM_ROOT, "evals")
60
- const dest = path.join(target, "quorum", "evals")
61
- await fs.cp(src, dest, { recursive: true })
62
- log.created("quorum/evals/")
51
+ async function writeQuorumVersion(target, version) {
52
+ await fs.writeFile(path.join(target, ".quorum-version"), version + "\n", "utf8")
53
+ log.created(".quorum-version")
63
54
  }
64
55
 
65
56
  async function mergeCopilotInstructions(target) {
@@ -67,14 +58,15 @@ async function mergeCopilotInstructions(target) {
67
58
  const src = path.join(QUORUM_ROOT, ".github", "copilot-instructions.md")
68
59
  const dest = path.join(target, ".github", "copilot-instructions.md")
69
60
  const content = await fs.readFile(src, "utf8")
61
+ const block = `<!-- quorum:start -->\n${content}\n<!-- quorum:end -->`
70
62
  await fs.mkdir(path.join(target, ".github"), { recursive: true })
71
63
  if (await exists(dest)) {
72
64
  const existing = await fs.readFile(dest, "utf8")
73
- if (existing.includes("<!-- quorum -->")) { log.skipped(".github/copilot-instructions.md (already present)"); return }
74
- await fs.appendFile(dest, `\n\n---\n\n<!-- quorum -->\n${content}`, "utf8")
65
+ if (existing.includes("<!-- quorum:start -->")) { log.skipped(".github/copilot-instructions.md (already present)"); return }
66
+ await fs.appendFile(dest, `\n\n---\n\n${block}`, "utf8")
75
67
  log.appended(".github/copilot-instructions.md")
76
68
  } else {
77
- await fs.writeFile(dest, content, "utf8")
69
+ await fs.writeFile(dest, block, "utf8")
78
70
  log.created(".github/copilot-instructions.md")
79
71
  }
80
72
  }
@@ -82,13 +74,18 @@ async function mergeCopilotInstructions(target) {
82
74
  async function mergeAgentsMd(target) {
83
75
  const dest = path.join(target, "AGENTS.md")
84
76
  const section = [
85
- "", "## Quorum modules", "",
86
- "See [quorum/modules/AGENTS.md](quorum/modules/AGENTS.md) for Oracle, Jury, and Council internals.",
87
- "See [.github/copilot-instructions.md](.github/copilot-instructions.md) for workflow rules.", "",
77
+ "",
78
+ "<!-- quorum:start -->",
79
+ "## Quorum",
80
+ "",
81
+ "See [quorum/AGENTS.md](quorum/AGENTS.md) for module file ownership and internals.",
82
+ "See [.github/copilot-instructions.md](.github/copilot-instructions.md) for workflow rules.",
83
+ "<!-- quorum:end -->",
84
+ "",
88
85
  ].join("\n")
89
86
  if (await exists(dest)) {
90
87
  const existing = await fs.readFile(dest, "utf8")
91
- if (existing.includes("quorum/modules/AGENTS.md")) { log.skipped("AGENTS.md (already present)"); return }
88
+ if (existing.includes("<!-- quorum:start -->")) { log.skipped("AGENTS.md (already present)"); return }
92
89
  await fs.appendFile(dest, section, "utf8")
93
90
  log.appended("AGENTS.md")
94
91
  } else {
@@ -98,11 +95,12 @@ async function mergeAgentsMd(target) {
98
95
  }
99
96
 
100
97
  async function mergeClaudeMd(target) {
101
- const dest = path.join(target, "CLAUDE.md")
98
+ const dest = path.join(target, "CLAUDE.md")
102
99
  const section = `
103
- ## Quorum modules
100
+ <!-- quorum:start -->
101
+ ## Quorum
104
102
 
105
- See [quorum/modules/CLAUDE.md](quorum/modules/CLAUDE.md) for Oracle, Jury, and Council internals.
103
+ See [quorum/CLAUDE.md](quorum/CLAUDE.md) for design decisions and invariants.
106
104
  See [.github/copilot-instructions.md](.github/copilot-instructions.md) for workflow rules.
107
105
 
108
106
  ## Gemini CLI (optional assistant)
@@ -127,10 +125,11 @@ source ~/.zshrc && gemini -p "I'm about to change X. What should I watch out for
127
125
 
128
126
  You reason about Gemini's output — it assists, you decide. Never pass its response to the
129
127
  user unfiltered. If Gemini contradicts what you know from reading the code, trust your reading.
128
+ <!-- quorum:end -->
130
129
  `
131
130
  if (await exists(dest)) {
132
131
  const existing = await fs.readFile(dest, "utf8")
133
- if (existing.includes("quorum/modules/CLAUDE.md")) { log.skipped("CLAUDE.md (already present)"); return }
132
+ if (existing.includes("<!-- quorum:start -->")) { log.skipped("CLAUDE.md (already present)"); return }
134
133
  await fs.appendFile(dest, section, "utf8")
135
134
  log.appended("CLAUDE.md")
136
135
  } else {
@@ -147,7 +146,7 @@ async function mergeGeminiMd(target) {
147
146
  if (await exists(src)) { await fs.copyFile(src, dest); log.created("GEMINI.md") }
148
147
  }
149
148
 
150
- async function updatePackageJson(target) {
149
+ async function updatePackageJson(target, version) {
151
150
  log.section("Updating package.json")
152
151
  const pkgPath = path.join(target, "package.json")
153
152
  let pkg
@@ -157,30 +156,27 @@ async function updatePackageJson(target) {
157
156
  pkg = { name: path.basename(target), version: "0.1.0", private: true }
158
157
  log.warn("No package.json found — creating a minimal one")
159
158
  }
160
- pkg.dependencies = pkg.dependencies ?? {}
161
- pkg.optionalDependencies = pkg.optionalDependencies ?? {}
162
- const added = []
163
- for (const [name, version] of Object.entries(DEPS)) {
164
- if (!pkg.dependencies[name]) { pkg.dependencies[name] = version; added.push(name) }
165
- }
166
- for (const [name, version] of Object.entries(OPTIONAL_DEPS)) {
167
- if (!pkg.optionalDependencies[name]) { pkg.optionalDependencies[name] = version; added.push(`${name} (optional)`) }
159
+ pkg.devDependencies = pkg.devDependencies ?? {}
160
+ const quorumRange = `^${version}`
161
+ if (pkg.devDependencies["@balpal4495/quorum"] || pkg.dependencies?.["@balpal4495/quorum"]) {
162
+ log.skipped("package.json (@balpal4495/quorum already present)")
163
+ return
168
164
  }
165
+ pkg.devDependencies["@balpal4495/quorum"] = quorumRange
169
166
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8")
170
- if (added.length > 0) {
171
- log.appended(`package.json — added: ${added.join(", ")}`)
172
- } else {
173
- log.skipped("package.json (all deps already present)")
174
- }
167
+ log.appended(`package.json added @balpal4495/quorum@${quorumRange} to devDependencies`)
175
168
  }
176
169
 
177
170
  async function updateGitignore(target) {
178
171
  log.section("Updating .gitignore")
179
172
  const dest = path.join(target, ".gitignore")
180
173
  const block = [
181
- "", "# Quorum — Chronicle",
174
+ "",
175
+ "# Quorum — Chronicle",
182
176
  "# entries/ is a binary vector store — do not commit",
183
- ".chronicle/entries/", ".chronicle/query-log.jsonl", "",
177
+ ".chronicle/entries/",
178
+ ".chronicle/query-log.jsonl",
179
+ "",
184
180
  ].join("\n")
185
181
  if (await exists(dest)) {
186
182
  const existing = await fs.readFile(dest, "utf8")
@@ -195,7 +191,7 @@ async function updateGitignore(target) {
195
191
 
196
192
  async function createChronicle(target) {
197
193
  log.section("Creating Chronicle")
198
- await fs.mkdir(path.join(target, ".chronicle", "proposals"), { recursive: true })
194
+ await fs.mkdir(path.join(target, ".chronicle", "proposals"), { recursive: true })
199
195
  log.created(".chronicle/proposals/")
200
196
  await fs.mkdir(path.join(target, ".chronicle", "committed"), { recursive: true })
201
197
  log.created(".chronicle/committed/")
@@ -213,30 +209,33 @@ export async function run(PKG_VERSION) {
213
209
  }
214
210
 
215
211
  await guardAlreadyInitialized(target)
216
- await copyModules(target)
217
- await copyEvals(target)
212
+ await writeQuorumDocs(target)
218
213
  await mergeCopilotInstructions(target)
219
214
  await mergeAgentsMd(target)
220
215
  await mergeClaudeMd(target)
221
216
  await mergeGeminiMd(target)
222
- await updatePackageJson(target)
217
+ await updatePackageJson(target, PKG_VERSION)
223
218
  await updateGitignore(target)
224
219
  await createChronicle(target)
220
+ await writeQuorumVersion(target, PKG_VERSION)
225
221
 
226
222
  const hasGemini = geminiAvailable()
227
223
 
228
- console.log(`\n${c.green("✓ Quorum initialized.")}`)
224
+ console.log(`\n${c.green("✓ Quorum initialized.")} ${c.dim(`(v${PKG_VERSION})`)}`)
229
225
  console.log("\nNext steps:")
230
226
  console.log(" 1. npm install")
231
- console.log(" 2. Wire setup() into your entry point:\n")
232
- console.log(c.dim(' import { setup } from "./quorum/modules/setup"'))
227
+ console.log(" 2. Use the CLI:")
228
+ console.log(c.dim(" quorum advisor brief"))
229
+ console.log(c.dim(' quorum advisor "what has the team decided about X?"'))
230
+ console.log(c.dim(" quorum check --outcome '...' --design '...'"))
231
+ console.log("\n For programmatic use:")
232
+ console.log(c.dim(' import { setup } from "@balpal4495/quorum"'))
233
233
  console.log(c.dim(' const { oracle, evaluate, deliberate } = await setup({ llm: yourProvider })'))
234
234
  console.log("\n Or tell your AI: \"follow quorum/SETUP.md\"")
235
235
 
236
236
  if (!hasGemini) {
237
237
  console.log(`\n ${c.dim("Optional: install Gemini CLI for large-context assistance")}`)
238
238
  console.log(c.dim(" npm install -g @google/gemini-cli + set GEMINI_API_KEY"))
239
- console.log(c.dim(" See quorum/SETUP.md Step 10 for details."))
240
239
  } else {
241
240
  console.log(`\n ${c.green("✓ Gemini CLI detected")} — GEMINI.md written. Set GEMINI_API_KEY if not already set.`)
242
241
  }
@@ -1,4 +1,4 @@
1
- import { promises as fs, Dirent } from "fs"
1
+ import { promises as fs } from "fs"
2
2
  import path from "path"
3
3
  import { c } from "../shared/colors.js"
4
4
  import { findChronicleDir, readCommitted } from "../shared/chronicle.js"
package/bin/quorum.js CHANGED
@@ -20,13 +20,23 @@ function help() {
20
20
  ${c.bold("quorum")} ${c.dim(`v${PKG_VERSION}`)} — portable reasoning layer for agentic codebases
21
21
 
22
22
  ${c.bold("Usage:")}
23
+ ${c.cyan("quorum advisor")} ${c.dim('"question"')} Ask a plain-language question (uses LLM)
24
+ ${c.cyan("quorum advisor query")} ${c.dim('"topic"')} Search Chronicle entries (no LLM)
25
+ ${c.cyan("quorum advisor brief")} High-level Chronicle summary (no LLM)
23
26
  ${c.cyan("quorum init")} Scaffold Quorum into a project
24
27
  ${c.cyan("quorum status")} Show Chronicle health and pending proposals
25
28
  ${c.cyan("quorum check")} --outcome <x> --design <y> Preflight + risk (no LLM)
26
29
  ${c.cyan("quorum commit")} <id> Approve and index a Chronicle proposal
27
30
  ${c.cyan("quorum sentinel")} [coverage] Chronicle coverage of source files
31
+ ${c.cyan("quorum growth")} Chronicle learning health and growth rate
32
+ ${c.cyan("quorum evolve")} Consolidate and improve Chronicle entries (uses LLM)
28
33
  ${c.cyan("quorum --version")} Print version
29
34
 
35
+ ${c.bold("quorum advisor")} subcommands:
36
+ ask ${c.dim('"question"')} Ask with LLM synthesis + validation loop
37
+ query ${c.dim('"topic"')} Chronicle lookup (no LLM, instant)
38
+ brief Chronicle summary (no LLM, instant)
39
+
30
40
  ${c.bold("quorum check")} flags:
31
41
  --outcome -o What you want to achieve
32
42
  --design -d How you plan to do it
@@ -62,6 +72,12 @@ async function cli() {
62
72
 
63
73
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
64
74
 
75
+ if (command === "advisor") {
76
+ const { run } = await import(path.join(__dirname, "commands/advisor.js"))
77
+ await run(rest)
78
+ return
79
+ }
80
+
65
81
  if (command === "init") {
66
82
  const { run } = await import(path.join(__dirname, "commands/init.js"))
67
83
  await run(PKG_VERSION)
@@ -92,6 +108,18 @@ async function cli() {
92
108
  return
93
109
  }
94
110
 
111
+ if (command === "growth") {
112
+ const { run } = await import(path.join(__dirname, "commands/growth.js"))
113
+ await run(rest)
114
+ return
115
+ }
116
+
117
+ if (command === "evolve") {
118
+ const { run } = await import(path.join(__dirname, "commands/evolve.js"))
119
+ await run(rest)
120
+ return
121
+ }
122
+
95
123
  console.error(`${c.red(`Unknown command: ${command}`)}`)
96
124
  console.error(`Run ${c.bold("quorum help")} for usage.`)
97
125
  process.exit(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
- Three portable TypeScript modules — Oracle, Jury, Council — that form the knowledge and reasoning layer of an agentic workflow. They are designed to be dropped into any Node.js codebase.
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
- Both `jury/evaluate.ts` and `council/chairman.ts` throw if the LLM returns non-JSON or output that fails Zod validation. This is intentional. A silently passing Jury score is worse than an error. Do not add fallbacks or defaults.
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.