@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.
- package/CLAUDE.md +102 -42
- package/README.md +317 -223
- package/SETUP.md +68 -94
- 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 +59 -60
- package/bin/commands/sentinel.js +1 -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/setup.ts +15 -0
- package/package.json +4 -2
- package/bin/init.js +0 -378
package/bin/commands/init.js
CHANGED
|
@@ -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"
|
|
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("
|
|
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
|
|
40
|
-
log.section("
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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,
|
|
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
|
-
"",
|
|
86
|
-
"
|
|
87
|
-
"
|
|
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
|
|
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
|
|
98
|
+
const dest = path.join(target, "CLAUDE.md")
|
|
102
99
|
const section = `
|
|
103
|
-
|
|
100
|
+
<!-- quorum:start -->
|
|
101
|
+
## Quorum
|
|
104
102
|
|
|
105
|
-
See [quorum/
|
|
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
|
|
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.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
"",
|
|
174
|
+
"",
|
|
175
|
+
"# Quorum — Chronicle",
|
|
182
176
|
"# entries/ is a binary vector store — do not commit",
|
|
183
|
-
".chronicle/entries/",
|
|
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"),
|
|
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
|
|
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.
|
|
232
|
-
console.log(c.dim(
|
|
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
|
}
|
package/bin/commands/sentinel.js
CHANGED
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
|
-
|
|
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.
|