@balpal4495/quorum 3.0.0 → 3.0.2

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.
@@ -1,9 +1,470 @@
1
1
  #!/usr/bin/env node
2
+ import { promises as fs } from "fs"
2
3
  import path from "path"
4
+ import { randomUUID } from "crypto"
3
5
  import { c } from "../shared/colors.js"
4
- import { findChronicleDir } from "../shared/chronicle.js"
6
+ import { findChronicleDir, readCommitted } from "../shared/chronicle.js"
5
7
  import { detectProvider } from "../shared/llm.js"
6
8
 
9
+ // ── Chronicle / BM25 helpers ──────────────────────────────────────────────────
10
+
11
+ function tokenize(text) {
12
+ return text.toLowerCase().split(/\W+/).filter(t => t.length > 2)
13
+ }
14
+
15
+ function bm25Score(query, entry) {
16
+ const qTokens = new Set(tokenize(query))
17
+ const text = [
18
+ entry.key_insight ?? "",
19
+ entry.decision ?? "",
20
+ ...(entry.affected_areas ?? []),
21
+ ...(entry.scope ?? []),
22
+ entry.topic ?? "",
23
+ ].join(" ")
24
+ const eTokens = tokenize(text)
25
+ const overlap = eTokens.filter(t => qTokens.has(t)).length
26
+ return overlap / Math.sqrt(qTokens.size * eTokens.length + 1)
27
+ }
28
+
29
+ function queryChronicle(entries, query, limit = 8) {
30
+ return entries
31
+ .map(e => ({ entry: e, score: bm25Score(query, e) }))
32
+ .filter(({ score }) => score > 0)
33
+ .sort((a, b) => b.score - a.score)
34
+ .slice(0, limit)
35
+ .map(({ entry }) => entry)
36
+ }
37
+
38
+ function collectBearings(entries, area) {
39
+ const queries = [
40
+ area ?? "product direction goals decisions",
41
+ "rejected approaches refuted alternatives",
42
+ "constraints scope",
43
+ ]
44
+ const seen = new Set()
45
+ const bearings = []
46
+ for (const q of queries) {
47
+ for (const entry of queryChronicle(entries, q)) {
48
+ if (seen.has(entry.id)) continue
49
+ seen.add(entry.id)
50
+ const text = entry.decision ?? entry.key_insight ?? ""
51
+ bearings.push({
52
+ id: `bearing-${(entry.id ?? "").slice(0, 8)}`,
53
+ summary: text,
54
+ confidence: entry.confidence ?? 0.7,
55
+ status: entry.status,
56
+ })
57
+ }
58
+ }
59
+ return bearings
60
+ }
61
+
62
+ function formatBearings(bearings) {
63
+ if (!bearings.length) return "No Chronicle entries found."
64
+ return bearings
65
+ .map(b => {
66
+ const tag = b.status === "refuted" ? " [REJECTED]" : b.status === "validated" ? " [VALIDATED]" : ""
67
+ return `[${b.id}]${tag} ${b.summary}`
68
+ })
69
+ .join("\n")
70
+ }
71
+
72
+ // ── Source scanning ───────────────────────────────────────────────────────────
73
+
74
+ function inferTags(text) {
75
+ const tags = []
76
+ const lower = text.toLowerCase()
77
+ const keywords = ["oracle","advisor","jury","council","sentinel","compass","cli","api","auth","test","docs","config","chronicle","llm","module"]
78
+ for (const kw of keywords) if (lower.includes(kw)) tags.push(kw)
79
+ return tags
80
+ }
81
+
82
+ async function scanDocs(rootDir, area) {
83
+ const findings = []
84
+ let idx = 0
85
+ const targets = ["README.md","SETUP.md","CLAUDE.md","AGENTS.md","modules/README.md","quorum/CLAUDE.md","docs"]
86
+ async function scanMd(filePath) {
87
+ let content
88
+ try { content = await fs.readFile(filePath, "utf8") } catch { return }
89
+ const rel = path.relative(rootDir, filePath).replace(/\\/g, "/")
90
+ const lines = content.split("\n")
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const line = lines[i]
93
+ const m = line.match(/^#{1,3}\s+(.+)/)
94
+ if (m) {
95
+ const heading = m[1].trim()
96
+ const context = lines.slice(i + 1, i + 4).join(" ").replace(/```[^`]*```/g, "").trim().slice(0, 200)
97
+ findings.push({ id: `docs-${idx++}`, kind: "docs", source: rel, path: rel, line: i + 1, title: heading, summary: context || heading, confidence: 0.8, tags: inferTags(heading + " " + context) })
98
+ }
99
+ const trimmed = line.trim()
100
+ if (trimmed.startsWith("quorum ") || trimmed.startsWith("npx quorum")) {
101
+ findings.push({ id: `docs-cmd-${idx++}`, kind: "docs", source: rel, path: rel, line: i + 1, title: `CLI usage: ${trimmed.slice(0, 60)}`, summary: `Documented command: ${trimmed}`, confidence: 0.85, tags: ["cli", "command", ...inferTags(trimmed)] })
102
+ }
103
+ }
104
+ }
105
+ async function scanDir(dir) {
106
+ let entries
107
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
108
+ for (const entry of entries) {
109
+ const full = path.join(dir, entry.name)
110
+ if (entry.isDirectory() && !["node_modules",".git","dist"].includes(entry.name)) await scanDir(full)
111
+ else if (entry.isFile() && entry.name.endsWith(".md")) await scanMd(full)
112
+ }
113
+ }
114
+ for (const target of targets) {
115
+ const full = path.join(rootDir, target)
116
+ let stat
117
+ try { stat = await fs.stat(full) } catch { continue }
118
+ if (stat.isDirectory()) await scanDir(full)
119
+ else await scanMd(full)
120
+ }
121
+ return area ? findings.filter(f => !area || f.tags.includes(area.toLowerCase()) || f.summary.toLowerCase().includes(area.toLowerCase())) : findings
122
+ }
123
+
124
+ async function scanPackage(rootDir) {
125
+ const findings = []
126
+ let idx = 0
127
+ let pkg
128
+ try { pkg = JSON.parse(await fs.readFile(path.join(rootDir, "package.json"), "utf8")) } catch { return findings }
129
+ if (pkg.name) findings.push({ id: `pkg-${idx++}`, kind: "package", source: "package.json", title: "Package name", summary: `Published as: ${pkg.name}`, confidence: 1, tags: ["package","identity"] })
130
+ if (pkg.description) findings.push({ id: `pkg-${idx++}`, kind: "package", source: "package.json", title: "Package description", summary: String(pkg.description), confidence: 1, tags: ["package","description"] })
131
+ if (pkg.bin) for (const [name, entry] of Object.entries(pkg.bin)) findings.push({ id: `pkg-${idx++}`, kind: "package", source: "package.json", title: `CLI binary: ${name}`, summary: `CLI binary '${name}' at ${entry}`, confidence: 1, tags: ["cli","binary"] })
132
+ if (pkg.exports) findings.push({ id: `pkg-${idx++}`, kind: "package", source: "package.json", title: "Package exports", summary: `Exports: ${JSON.stringify(pkg.exports)}`, confidence: 0.95, tags: ["exports","api"] })
133
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.optionalDependencies ?? {}) }
134
+ if (Object.keys(deps).length > 0) findings.push({ id: `pkg-${idx++}`, kind: "package", source: "package.json", title: "Runtime dependencies", summary: Object.keys(deps).join(", "), confidence: 0.9, tags: ["dependencies"] })
135
+ return findings
136
+ }
137
+
138
+ async function scanCli(rootDir, area) {
139
+ const findings = []
140
+ let idx = 0
141
+ const binDir = path.join(rootDir, "bin", "commands")
142
+ let files
143
+ try { files = (await fs.readdir(binDir)).filter(f => f.endsWith(".js")) } catch { return findings }
144
+ for (const file of files) {
145
+ const cmdName = file.replace(".js", "")
146
+ let content
147
+ try { content = await fs.readFile(path.join(binDir, file), "utf8") } catch { continue }
148
+ const rel = `bin/commands/${file}`
149
+ const subcmds = [...content.matchAll(/case ["']([a-z-]+)["']/g)].map(m => m[1])
150
+ const flags = [...new Set([...content.matchAll(/["'](--[a-z-]+)["']/g)].map(m => m[1]))]
151
+ const usesLLM = /llm|LLM|provider|model/.test(content)
152
+ const readsChronicle = /readCommitted|findChronicleDir|committed/.test(content)
153
+ findings.push({
154
+ id: `cli-${idx++}`, kind: "cli", source: rel, path: rel, title: `Command: quorum ${cmdName}`,
155
+ summary: [`quorum ${cmdName}`, subcmds.length ? `Subcommands: ${subcmds.join(", ")}` : "", flags.length ? `Flags: ${flags.slice(0,8).join(", ")}` : "", usesLLM ? "Uses LLM" : "No LLM", readsChronicle ? "Reads Chronicle" : ""].filter(Boolean).join(" | "),
156
+ confidence: 0.9,
157
+ tags: ["cli","command",cmdName,...subcmds.map(s => `subcommand:${s}`), usesLLM ? "llm" : "deterministic"].filter(Boolean),
158
+ })
159
+ }
160
+ return area ? findings.filter(f => f.tags.includes(area.toLowerCase()) || f.summary.toLowerCase().includes(area.toLowerCase())) : findings
161
+ }
162
+
163
+ async function scanRepo(rootDir) {
164
+ const findings = []
165
+ let idx = 0
166
+ const modulesDir = path.join(rootDir, "modules")
167
+ try {
168
+ const entries = await fs.readdir(modulesDir, { withFileTypes: true })
169
+ for (const entry of entries) {
170
+ if (entry.isDirectory() && !entry.name.startsWith("_") && entry.name !== "shared") {
171
+ findings.push({ id: `repo-module-${idx++}`, kind: "code", source: `modules/${entry.name}/`, path: `modules/${entry.name}/`, title: `Module: ${entry.name}`, summary: `TypeScript module: modules/${entry.name}/`, confidence: 0.85, tags: ["module", entry.name, "code"] })
172
+ }
173
+ }
174
+ } catch { /* no modules dir */ }
175
+ return findings
176
+ }
177
+
178
+ async function collectTerrain(rootDir, area) {
179
+ const [docs, pkg, cli, repo] = await Promise.all([
180
+ scanDocs(rootDir, area),
181
+ scanPackage(rootDir),
182
+ scanCli(rootDir, area),
183
+ scanRepo(rootDir),
184
+ ])
185
+ return [...docs, ...pkg, ...cli, ...repo]
186
+ }
187
+
188
+ function formatTerrain(findings, limit = 40) {
189
+ if (!findings.length) return "No product behaviour found in sources."
190
+ const groups = {}
191
+ for (const f of findings.slice(0, limit)) {
192
+ groups[f.kind] = groups[f.kind] ?? []
193
+ groups[f.kind].push(f)
194
+ }
195
+ return Object.entries(groups).map(([kind, items]) =>
196
+ `### ${kind}\n${items.map(f => ` - ${f.summary.slice(0, 120)}`).join("\n")}`
197
+ ).join("\n\n")
198
+ }
199
+
200
+ // ── Behaviour mapping ─────────────────────────────────────────────────────────
201
+
202
+ function inferArea(f) {
203
+ const lower = (f.title + " " + f.summary).toLowerCase()
204
+ for (const area of ["oracle","advisor","jury","council","sentinel","compass","chronicle","onboarding"]) {
205
+ if (lower.includes(area)) return area
206
+ }
207
+ return f.tags?.find(t => !["cli","command","llm","deterministic","chronicle","module","code"].includes(t)) ?? "general"
208
+ }
209
+
210
+ function extractCommand(text) {
211
+ const m = text.match(/quorum\s+(\w+)/)
212
+ return m ? m[1] : ""
213
+ }
214
+
215
+ function findingToRef(f) {
216
+ return { id: f.id, kind: f.kind, source: f.source, path: f.path ?? f.source, summary: f.summary, confidence: f.confidence }
217
+ }
218
+
219
+ function mapBehaviors(findings, area) {
220
+ const behaviors = []
221
+ const gaps = []
222
+
223
+ const cliFindings = findings.filter(f => f.kind === "cli")
224
+ for (const f of cliFindings) {
225
+ behaviors.push({ id: `behavior-cli-${f.id}`, area: inferArea(f), name: f.title, current_behavior: f.summary, evidence: [findingToRef(f)], confidence: f.confidence })
226
+ }
227
+
228
+ const docsCliFindings = findings.filter(f => f.kind === "docs" && f.tags?.includes("cli"))
229
+ for (const f of docsCliFindings) {
230
+ const cmd = extractCommand(f.summary)
231
+ const alreadyPresent = cmd.length > 3 && behaviors.some(b => b.current_behavior.toLowerCase().includes(cmd.toLowerCase()))
232
+ if (!alreadyPresent && cmd) {
233
+ behaviors.push({ id: `behavior-docs-${f.id}`, area: inferArea(f), name: `Documented: ${f.title}`, current_behavior: f.summary, evidence: [findingToRef(f)], confidence: f.confidence * 0.9 })
234
+ }
235
+ }
236
+
237
+ const EXPECTED = ["onboarding","chronicle","advisor","review"]
238
+ for (const expected of EXPECTED) {
239
+ const has = behaviors.some(b => b.area === expected || b.name.toLowerCase().includes(expected))
240
+ if (!has) {
241
+ gaps.push({ id: `gap-${expected}`, area: expected, gap: `No first-class CLI command found for '${expected}'.`, why_it_matters: `'${expected}' appears in product docs but has no dedicated CLI surface.`, confidence: 0.7 })
242
+ }
243
+ }
244
+
245
+ if (!behaviors.some(b => b.name.toLowerCase().includes("compass"))) {
246
+ gaps.push({ id: "gap-product-direction", area: "product direction", gap: "No product behaviour mapping or direction module currently exists.", why_it_matters: "Quorum helps agents avoid repeating engineering mistakes, but has no module to help avoid repeating product-direction mistakes.", confidence: 0.93 })
247
+ }
248
+
249
+ const filtered = area ? behaviors.filter(b => b.area.toLowerCase().includes(area.toLowerCase()) || b.name.toLowerCase().includes(area.toLowerCase())) : behaviors
250
+ const filteredGaps = area ? gaps.filter(g => g.area.toLowerCase().includes(area.toLowerCase())) : gaps
251
+ const confidence = filtered.length ? filtered.reduce((s, b) => s + b.confidence, 0) / filtered.length : 0.5
252
+
253
+ return { generated_at: new Date().toISOString(), area, behaviors: filtered, gaps: filteredGaps, contradictions: [], confidence: Math.round(confidence * 100) / 100 }
254
+ }
255
+
256
+ function summarizeBehaviorMap(map) {
257
+ const lines = []
258
+ if (map.behaviors.length) {
259
+ lines.push("## Current behaviours")
260
+ for (const b of map.behaviors.slice(0, 20)) lines.push(` ✓ ${b.current_behavior.slice(0, 100)}`)
261
+ }
262
+ if (map.gaps.length) {
263
+ lines.push("## Gaps")
264
+ for (const g of map.gaps) lines.push(` ? [${g.area}] ${g.gap}`)
265
+ }
266
+ return lines.join("\n") || "No behaviours mapped."
267
+ }
268
+
269
+ // ── Score computation ─────────────────────────────────────────────────────────
270
+
271
+ function computeScore(dims) {
272
+ const raw =
273
+ dims.strategic_fit * 20 +
274
+ dims.user_problem_clarity * 15 +
275
+ dims.evidence_strength * 20 +
276
+ dims.leverage * 10 +
277
+ dims.feasibility * 15 +
278
+ dims.time_to_signal * 10 +
279
+ dims.reversibility * 10 -
280
+ dims.complexity_penalty * 10 -
281
+ dims.dependency_penalty * 8 -
282
+ dims.contradiction_penalty * 15 -
283
+ dims.evidence_gap_penalty * 12
284
+ return { ...dims, total: Math.max(0, Math.min(100, Math.round(raw))) }
285
+ }
286
+
287
+ // ── Prompts ───────────────────────────────────────────────────────────────────
288
+
289
+ const COMPASS_SYSTEM_PROMPT = `You are Quorum Compass, the product-direction module for an AI-assisted software team.
290
+
291
+ Your job is to help decide where the product should go next.
292
+
293
+ You are not a generic brainstormer.
294
+ You must ground every recommendation in provided evidence.
295
+
296
+ Evidence may come from:
297
+ - Chronicle memory (human-approved past decisions)
298
+ - current code behaviour
299
+ - docs
300
+ - tests
301
+ - package metadata
302
+ - CLI commands
303
+
304
+ Rules:
305
+ 1. Separate known facts from inferences and assumptions.
306
+ 2. Never claim user demand unless user evidence (analytics, support, issues) is provided.
307
+ 3. Prefer small, reversible next moves unless asked for big bets.
308
+ 4. Identify contradictions with Chronicle or current product behaviour.
309
+ 5. Include assumptions, invalidation signals, and open questions.
310
+ 6. Do not recommend implementation details beyond product-level guidance.
311
+ 7. Return only valid JSON matching the requested schema.
312
+ 8. When no analytics/support data is connected, always state: "No direct user signal connected."`
313
+
314
+ function buildBriefPrompt(chronicleCtx, behaviorCtx, area) {
315
+ return `Produce a Compass Brief — a summary of current product direction.
316
+
317
+ ${area ? `Focus area: ${area}\n` : ""}
318
+ ## Chronicle evidence (approved project memory)
319
+ ${chronicleCtx}
320
+
321
+ ## Current product behaviour
322
+ ${behaviorCtx}
323
+
324
+ Return ONLY valid JSON with this exact schema (no markdown fences, no explanation):
325
+ {
326
+ "product_direction": "<one clear sentence>",
327
+ "known_from_chronicle": ["<fact from Chronicle>"],
328
+ "known_from_behavior": ["<fact from code/docs/tests>"],
329
+ "inferred": ["<inference>"],
330
+ "assumptions": ["<assumption>"],
331
+ "unknowns": ["<unknown — include 'No analytics or support evidence connected' if no user data>"],
332
+ "missing_evidence": ["<what would improve this brief>"],
333
+ "recommended_next_step": "<specific quorum command or action>",
334
+ "confidence": <number 0–1>
335
+ }`
336
+ }
337
+
338
+ function buildPathwaysPrompt(goal, horizon, appetite, chronicleCtx, behaviorCtx, area, limit) {
339
+ return `Generate ${limit ?? 5} product pathways toward the following goal.
340
+
341
+ Goal: ${goal}
342
+ ${horizon ? `Horizon: ${horizon}` : ""}
343
+ ${appetite ? `Appetite: ${appetite}` : ""}
344
+ ${area ? `Focus area: ${area}` : ""}
345
+
346
+ ## Chronicle evidence
347
+ ${chronicleCtx}
348
+
349
+ ## Current product behaviour
350
+ ${behaviorCtx}
351
+
352
+ Return ONLY valid JSON: { "pathways": [ { "id":"<slug>","kind":"product_pathway","title":"<title>","goal":"<goal>","target_user":"<who>","problem":"<problem>","current_behaviors":["<behaviour>"],"opportunity":"<gap>","why_now":"<why>","smallest_useful_version":"<mvp>","phases":[{"name":"<phase>","outcome":"<outcome>","user_value":"<value>","build_notes":["<note>"],"dependencies":["<dep>"],"risks":["<risk>"]}],"dependencies":["<dep>"],"risks":["<risk>"],"assumptions":["<assumption>"],"open_questions":["<question>"],"evidence":[{"id":"<id>","kind":"<kind>","source":"<source>","summary":"<summary>","confidence":<0-1>}],"scores":{"strategic_fit":<0-1>,"user_problem_clarity":<0-1>,"evidence_strength":<0-1>,"leverage":<0-1>,"feasibility":<0-1>,"time_to_signal":<0-1>,"reversibility":<0-1>,"complexity_penalty":<0-1>,"dependency_penalty":<0-1>,"contradiction_penalty":<0-1>,"evidence_gap_penalty":<0-1>,"total":<0-100>},"confidence":<0-1>,"time_to_signal":"<timeframe>","reversibility":"high|medium|low","suggested_next_step":"<step>" } ] }
353
+
354
+ Sort by scores.total descending. Assumptions must always be present.`
355
+ }
356
+
357
+ function buildBetsPrompt(horizon, goal, appetite, chronicleCtx, behaviorCtx) {
358
+ return `Generate 2–3 strategic product bets.
359
+
360
+ ${horizon ? `Horizon: ${horizon}` : ""}
361
+ ${goal ? `Goal: ${goal}` : ""}
362
+ ${appetite ? `Appetite: ${appetite}` : ""}
363
+
364
+ ## Chronicle evidence
365
+ ${chronicleCtx}
366
+
367
+ ## Current product behaviour
368
+ ${behaviorCtx}
369
+
370
+ Return ONLY valid JSON: { "bets": [ { "id":"<slug>","kind":"product_bet","title":"<title>","thesis":"<falsifiable hypothesis>","why_now":"<why>","target_user":"<who>","upside":"<best case>","downside":"<downside>","assumptions":["<assumption>"],"validation_signals":["<signal>"],"invalidation_signals":["<signal>"],"kill_criteria":["<criteria>"],"first_experiment":"<smallest test>","build_path":["<phase>"],"evidence":[{"id":"<id>","kind":"<kind>","source":"<source>","summary":"<summary>","confidence":<0-1>}],"scores":{"strategic_fit":<0-1>,"user_problem_clarity":<0-1>,"evidence_strength":<0-1>,"leverage":<0-1>,"feasibility":<0-1>,"time_to_signal":<0-1>,"reversibility":<0-1>,"complexity_penalty":<0-1>,"dependency_penalty":<0-1>,"contradiction_penalty":<0-1>,"evidence_gap_penalty":<0-1>,"total":<0-100>},"confidence":<0-1>,"time_to_signal":"<timeframe>","reversibility":"high|medium|low","appetite":"small|medium|large" } ] }
371
+
372
+ Kill criteria and invalidation_signals must be present. If no user evidence, evidence_strength ≤ 0.4.`
373
+ }
374
+
375
+ function buildScorePrompt(idea, chronicleCtx, behaviorCtx) {
376
+ return `Evaluate this product idea.
377
+
378
+ Idea: ${idea}
379
+
380
+ ## Chronicle evidence
381
+ ${chronicleCtx}
382
+
383
+ ## Current product behaviour
384
+ ${behaviorCtx}
385
+
386
+ Return ONLY valid JSON: { "idea":"${idea}","summary":"<one sentence>","recommendation":"pursue|pursue-small-test|investigate-more|defer|avoid","scores":{"strategic_fit":<0-1>,"user_problem_clarity":<0-1>,"evidence_strength":<0-1>,"leverage":<0-1>,"feasibility":<0-1>,"time_to_signal":<0-1>,"reversibility":<0-1>,"complexity_penalty":<0-1>,"dependency_penalty":<0-1>,"contradiction_penalty":<0-1>,"evidence_gap_penalty":<0-1>,"total":<0-100>},"evidence":[{"id":"<id>","kind":"<kind>","source":"<source>","summary":"<summary>","confidence":<0-1>}],"supporting_reasons":["<reason>"],"risks":["<risk>"],"assumptions":["<assumption>"],"open_questions":["<question>"],"suggested_next_step":"<action>" }
387
+
388
+ Score total = strategic_fit*20 + user_problem_clarity*15 + evidence_strength*20 + leverage*10 + feasibility*15 + time_to_signal*10 + reversibility*10 - complexity_penalty*10 - dependency_penalty*8 - contradiction_penalty*15 - evidence_gap_penalty*12. Clamp 0–100.`
389
+ }
390
+
391
+ // ── LLM helper ────────────────────────────────────────────────────────────────
392
+
393
+ function parseLLMJson(raw) {
394
+ const cleaned = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim()
395
+ return JSON.parse(cleaned)
396
+ }
397
+
398
+ async function callLLM(llm, userPrompt) {
399
+ if (!llm) throw new Error("LLM provider is required for this subcommand. Set ANTHROPIC_API_KEY or OPENAI_API_KEY.")
400
+ return llm([
401
+ { role: "system", content: COMPASS_SYSTEM_PROMPT },
402
+ { role: "user", content: userPrompt },
403
+ ])
404
+ }
405
+
406
+ // ── Proposal staging ──────────────────────────────────────────────────────────
407
+
408
+ async function stageProposal(chronicleDir, artifactKind, payload) {
409
+ const title = payload.title ?? payload.idea ?? "Compass artifact"
410
+ const decision = artifactKind === "product_bet"
411
+ ? `Product bet: ${title}. Thesis: ${payload.thesis ?? ""}`.slice(0, 300)
412
+ : artifactKind === "product_pathway"
413
+ ? `Product pathway: ${title}. ${payload.opportunity ?? ""}`.slice(0, 300)
414
+ : `Product idea scored: ${title}. Recommendation: ${payload.recommendation ?? ""}`.slice(0, 300)
415
+
416
+ const entry = {
417
+ schema_version: 2,
418
+ topic: `product/${artifactKind.replace("product_", "")}/${title.slice(0, 40).replace(/\s+/g, "-").toLowerCase()}`,
419
+ key_insight: decision.slice(0, 200),
420
+ decision,
421
+ scope: ["product", "compass", artifactKind.replace("product_", "")],
422
+ affected_areas: [],
423
+ status: "open",
424
+ confidence: payload.confidence ?? 0.7,
425
+ source_module: "compass",
426
+ evidence_cited: [],
427
+ alternatives_considered: [],
428
+ rejected_reason: [],
429
+ validation_plan: (payload.kill_criteria ?? []).slice(0, 3),
430
+ }
431
+
432
+ const id = randomUUID()
433
+ const proposalsDir = path.join(chronicleDir, "proposals")
434
+ await fs.mkdir(proposalsDir, { recursive: true })
435
+ await fs.writeFile(path.join(proposalsDir, `${id}.json`), JSON.stringify(entry, null, 2), "utf8")
436
+ return { proposal_id: id, message: `Staged Chronicle proposal ${id.slice(0, 8)} — run 'quorum commit --list' to review.` }
437
+ }
438
+
439
+ async function stageOutcome(chronicleDir, entryId, result, note) {
440
+ const resultLabel = { validated: "has been validated", "partially-validated": "has been partially validated", invalidated: "has been invalidated", unclear: "outcome is unclear", superseded: "has been superseded" }
441
+ const label = resultLabel[result] ?? result
442
+ const decision = `Product bet/pathway ${entryId.slice(0, 8)} ${label}.${note ? " " + note : ""}`
443
+
444
+ const entry = {
445
+ schema_version: 2,
446
+ topic: `product/outcome/${entryId.slice(0, 8)}`,
447
+ key_insight: decision.slice(0, 200),
448
+ decision,
449
+ scope: ["product", "compass", "outcome"],
450
+ affected_areas: [],
451
+ status: "validated",
452
+ confidence: result === "validated" ? 0.9 : result === "partially-validated" ? 0.7 : 0.6,
453
+ source_module: "compass",
454
+ evidence_cited: [entryId],
455
+ alternatives_considered: [],
456
+ rejected_reason: [],
457
+ validation_plan: [],
458
+ post_merge_result: result === "validated" ? "successful" : result === "invalidated" ? "rolled-back" : result === "partially-validated" ? "partial" : undefined,
459
+ }
460
+
461
+ const id = randomUUID()
462
+ const proposalsDir = path.join(chronicleDir, "proposals")
463
+ await fs.mkdir(proposalsDir, { recursive: true })
464
+ await fs.writeFile(path.join(proposalsDir, `${id}.json`), JSON.stringify(entry, null, 2), "utf8")
465
+ return { proposal_id: id, message: `Staged outcome proposal ${id.slice(0, 8)} — run 'quorum commit --list' to review.` }
466
+ }
467
+
7
468
  // ── Helpers ───────────────────────────────────────────────────────────────────
8
469
 
9
470
  function help() {
@@ -247,51 +708,65 @@ export async function run(argv) {
247
708
  const entryId = flags["entry-id"] || flags["entryId"] || undefined
248
709
  const result = flags["result"] || undefined
249
710
 
250
- // ── Load Compass module ────────────────────────────────────────────────────
711
+ // ── Setup ─────────────────────────────────────────────────────────────────
251
712
 
252
713
  const rootDir = process.cwd()
253
- const chronicleDir = findChronicleDir(rootDir)
714
+ const chronicleDir = await findChronicleDir(rootDir)
254
715
 
255
716
  if (!chronicleDir) {
256
717
  console.error(c.red("Error: Chronicle not found. Run 'quorum init' first."))
257
718
  process.exit(1)
258
719
  }
259
720
 
260
- // Lazy import to keep CLI startup fast
261
- const { createCompass } = await import("../../modules/compass/create.js")
262
- const { defaultSources } = await import("../../modules/compass/sources/index.js")
263
- const { createOracleClient } = await import("../../modules/oracle/index.js")
264
- const { createLanceDBStore } = await import("../../modules/oracle/adapters/lance-db.js")
265
- const { xenovaEmbed } = await import("../../modules/oracle/adapters/xenova-embedder.js")
266
-
267
- const vectorStore = await createLanceDBStore(chronicleDir)
268
- const oracle = createOracleClient({ embedder: xenovaEmbed, vectorStore, chronicleDir })
269
-
270
- // Only load LLM for subcommands that need it
271
721
  const NO_LLM_CMDS = new Set(["map", "opportunities"])
272
- const llm = NO_LLM_CMDS.has(subcommand) ? undefined : detectProvider()
273
-
274
- const compass = createCompass({
275
- oracle,
276
- llm,
277
- rootDir,
278
- chronicleDir,
279
- sources: defaultSources(),
280
- })
722
+ const provider = NO_LLM_CMDS.has(subcommand) ? null : await detectProvider()
723
+ const llm = provider?.llm
724
+
725
+ // ── Shared context helper ─────────────────────────────────────────────────
726
+
727
+ async function getContext(areaFilter) {
728
+ const [entries, findings] = await Promise.all([
729
+ readCommitted(chronicleDir),
730
+ collectTerrain(rootDir, areaFilter),
731
+ ])
732
+ const bearings = collectBearings(entries, areaFilter)
733
+ const chronicleCtx = formatBearings(bearings)
734
+ const behaviorCtx = formatTerrain(findings)
735
+ const behaviorMap = mapBehaviors(findings, areaFilter)
736
+ return { entries, findings, bearings, chronicleCtx, behaviorCtx, behaviorMap }
737
+ }
281
738
 
282
739
  // ── Route subcommand ───────────────────────────────────────────────────────
283
740
 
284
741
  try {
285
742
  switch (subcommand) {
286
743
  case "brief": {
287
- const data = await compass.brief({ area })
744
+ const { chronicleCtx, behaviorCtx, behaviorMap } = await getContext(area)
745
+ if (!llm) {
746
+ const data = {
747
+ product_direction: "Unable to synthesize direction — no LLM configured. See Chronicle and behaviour map for raw evidence.",
748
+ known_from_chronicle: [],
749
+ known_from_behavior: behaviorMap.behaviors.slice(0, 5).map(b => b.current_behavior),
750
+ inferred: [],
751
+ unknowns: ["LLM not configured — full synthesis unavailable."],
752
+ recommended_next_step: "Run: quorum advisor brief",
753
+ confidence: 0.4,
754
+ }
755
+ if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
756
+ renderBrief(data)
757
+ break
758
+ }
759
+ const raw = await callLLM(llm, buildBriefPrompt(chronicleCtx, behaviorCtx, area))
760
+ let data
761
+ try { data = parseLLMJson(raw) } catch { throw new Error(`Compass brief: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
288
762
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
289
763
  renderBrief(data)
290
764
  break
291
765
  }
292
766
 
293
767
  case "map": {
294
- const data = await compass.mapBehaviors({ area })
768
+ const findings = await collectTerrain(rootDir, area)
769
+ const data = mapBehaviors(findings, area)
295
770
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
296
771
  renderBehaviorMap(data)
297
772
  break
@@ -303,7 +778,19 @@ export async function run(argv) {
303
778
  console.error(c.red('Error: provide a question, e.g. quorum compass behavior "what does quorum do for onboarding?"'))
304
779
  process.exit(1)
305
780
  }
306
- const data = await compass.behavior({ question, area })
781
+ const findings = await collectTerrain(rootDir, area)
782
+ const behaviorMap = mapBehaviors(findings, area)
783
+ const what_exists = behaviorMap.behaviors.slice(0, 6).map(b => b.current_behavior)
784
+ const what_appears_missing = behaviorMap.gaps.slice(0, 4).map(g => g.gap)
785
+ const data = {
786
+ question,
787
+ what_exists,
788
+ what_appears_missing,
789
+ product_implication: behaviorMap.gaps.length > 0
790
+ ? `The area has ${behaviorMap.behaviors.length} documented behaviours but ${behaviorMap.gaps.length} notable gaps.`
791
+ : `The area appears well-covered with ${behaviorMap.behaviors.length} documented behaviours.`,
792
+ confidence: behaviorMap.confidence,
793
+ }
307
794
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
308
795
  console.log(`\n${c.bold("Behaviour answer:")} ${data.product_implication}`)
309
796
  if (data.what_exists?.length) {
@@ -318,9 +805,19 @@ export async function run(argv) {
318
805
  }
319
806
 
320
807
  case "opportunities": {
321
- const data = await compass.opportunities({ area, goal, limit: limitN })
322
- if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
323
- renderOpportunities(data)
808
+ const findings = await collectTerrain(rootDir, area)
809
+ const behaviorMap = mapBehaviors(findings, area)
810
+ let opps = behaviorMap.gaps.map((g, i) => ({
811
+ id: `opp-${i}`, title: g.gap, area: g.area,
812
+ why_it_matters: g.why_it_matters,
813
+ evidence_strength: g.confidence >= 0.7 ? "strong" : g.confidence >= 0.5 ? "medium" : "inferred",
814
+ suggested_next_step: `quorum compass pathways --goal "${g.gap.slice(0, 50)}"`,
815
+ confidence: g.confidence,
816
+ }))
817
+ if (limitN) opps = opps.slice(0, limitN)
818
+ if (goal) opps = opps.filter(o => o.title.toLowerCase().includes(goal.toLowerCase()) || o.area.toLowerCase().includes(goal.toLowerCase()))
819
+ if (jsonMode) { console.log(JSON.stringify(opps, null, 2)); break }
820
+ renderOpportunities(opps)
324
821
  break
325
822
  }
326
823
 
@@ -329,7 +826,11 @@ export async function run(argv) {
329
826
  console.error(c.red('Error: --goal is required. Example: quorum compass pathways --goal "onboard new agents faster"'))
330
827
  process.exit(1)
331
828
  }
332
- const data = await compass.pathways({ goal, horizon, appetite, area, limit: limitN })
829
+ const { chronicleCtx, behaviorCtx } = await getContext(area)
830
+ const raw = await callLLM(llm, buildPathwaysPrompt(goal, horizon, appetite, chronicleCtx, behaviorCtx, area, limitN))
831
+ let parsed
832
+ try { parsed = parseLLMJson(raw) } catch { throw new Error(`Compass pathways: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
833
+ const data = (parsed.pathways ?? []).map(p => ({ ...p, scores: computeScore(p.scores ?? {}) }))
333
834
  _lastArtifact = { kind: "product_pathway", items: data }
334
835
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
335
836
  renderPathways(data)
@@ -338,7 +839,11 @@ export async function run(argv) {
338
839
  }
339
840
 
340
841
  case "bets": {
341
- const data = await compass.bigBets({ horizon, goal, appetite })
842
+ const { chronicleCtx, behaviorCtx } = await getContext()
843
+ const raw = await callLLM(llm, buildBetsPrompt(horizon, goal, appetite, chronicleCtx, behaviorCtx))
844
+ let parsed
845
+ try { parsed = parseLLMJson(raw) } catch { throw new Error(`Compass bets: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
846
+ const data = (parsed.bets ?? []).map(b => ({ ...b, scores: computeScore(b.scores ?? {}) }))
342
847
  _lastArtifact = { kind: "product_bet", items: data }
343
848
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
344
849
  renderBets(data)
@@ -352,7 +857,11 @@ export async function run(argv) {
352
857
  console.error(c.red('Error: provide an idea. Example: quorum compass score "add Slack integration"'))
353
858
  process.exit(1)
354
859
  }
355
- const data = await compass.scoreIdea({ idea })
860
+ const { chronicleCtx, behaviorCtx } = await getContext()
861
+ const raw = await callLLM(llm, buildScorePrompt(idea, chronicleCtx, behaviorCtx))
862
+ let data
863
+ try { data = parseLLMJson(raw) } catch { throw new Error(`Compass score: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
864
+ if (data.scores) data.scores = computeScore(data.scores)
356
865
  _lastArtifact = { kind: "product_idea_score", items: [data] }
357
866
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
358
867
  renderScore(data)
@@ -365,7 +874,19 @@ export async function run(argv) {
365
874
  console.error(c.red('Error: provide a title. Example: quorum compass spec "Smart retry backoff"'))
366
875
  process.exit(1)
367
876
  }
368
- const data = await compass.productBrief({ title })
877
+ const { chronicleCtx, behaviorCtx } = await getContext()
878
+ const specPrompt = `Generate a lightweight product brief for: ${title}
879
+
880
+ ## Chronicle evidence
881
+ ${chronicleCtx}
882
+
883
+ ## Current product behaviour
884
+ ${behaviorCtx}
885
+
886
+ Return ONLY valid JSON: { "title":"${title}","problem":"<problem>","target_user":"<user>","recommended_solution":"<solution>","smallest_useful_version":"<mvp>","non_goals":["<non-goal>"],"risks":["<risk>"],"open_questions":["<question>"],"suggested_quorum_checks":["<quorum command>"] }`
887
+ const raw = await callLLM(llm, specPrompt)
888
+ let data
889
+ try { data = parseLLMJson(raw) } catch { throw new Error(`Compass spec: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
369
890
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
370
891
  renderProductBrief(data)
371
892
  break
@@ -378,8 +899,8 @@ export async function run(argv) {
378
899
  process.exit(1)
379
900
  }
380
901
  const item = _lastArtifact.items[0]
381
- const result = await compass.propose({ artifact_kind: _lastArtifact.kind, payload: item })
382
- console.log(c.green(`\n✓ ${result.message}`))
902
+ const res = await stageProposal(chronicleDir, _lastArtifact.kind, item)
903
+ console.log(c.green(`\n✓ ${res.message}`))
383
904
  break
384
905
  }
385
906
  console.error(c.red('Error: provide --from-last. Example: quorum compass propose --from-last'))
@@ -397,7 +918,7 @@ export async function run(argv) {
397
918
  process.exit(1)
398
919
  }
399
920
  const note = flags["note"] || undefined
400
- const data = await compass.outcome({ entry_id: entryId, result, note })
921
+ const data = await stageOutcome(chronicleDir, entryId, result, note)
401
922
  if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
402
923
  console.log(c.green(`\n✓ ${data.message}`))
403
924
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@balpal4495/quorum",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Git-backed memory and design review for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",