@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.
- package/bin/commands/compass.js +557 -36
- package/package.json +1 -1
package/bin/commands/compass.js
CHANGED
|
@@ -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
|
-
// ──
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
382
|
-
console.log(c.green(`\n✓ ${
|
|
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
|
|
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
|