@balpal4495/quorum 2.0.0 → 3.0.1
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/.github/copilot-instructions.md +29 -6
- package/README.md +69 -2
- package/bin/commands/compass.js +942 -0
- package/bin/commands/init.js +13 -7
- package/bin/commands/migrate-v2.js +136 -0
- package/bin/commands/sentinel.js +1 -1
- package/bin/commands/sync.js +97 -0
- package/bin/quorum.js +35 -0
- package/bin/templates/CLAUDE.md +101 -0
- package/modules/README.md +57 -10
- package/modules/compass/behavior.ts +161 -0
- package/modules/compass/create.ts +365 -0
- package/modules/compass/evidence/collect.ts +109 -0
- package/modules/compass/index.ts +7 -0
- package/modules/compass/prompts/index.ts +230 -0
- package/modules/compass/prompts/system.ts +24 -0
- package/modules/compass/propose.ts +152 -0
- package/modules/compass/schemas.ts +121 -0
- package/modules/compass/score.ts +77 -0
- package/modules/compass/sources/index.ts +413 -0
- package/modules/compass/types.ts +431 -0
- package/modules/setup.ts +33 -0
- package/package.json +19 -11
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { randomUUID } from "crypto"
|
|
5
|
+
import { c } from "../shared/colors.js"
|
|
6
|
+
import { findChronicleDir, readCommitted } from "../shared/chronicle.js"
|
|
7
|
+
import { detectProvider } from "../shared/llm.js"
|
|
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
|
+
|
|
468
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
function help() {
|
|
471
|
+
console.log(`
|
|
472
|
+
${c.bold("quorum compass")} — product-direction synthesis
|
|
473
|
+
|
|
474
|
+
${c.bold("Usage:")}
|
|
475
|
+
quorum compass <subcommand> [options]
|
|
476
|
+
|
|
477
|
+
${c.bold("Subcommands:")}
|
|
478
|
+
brief Summarise current product direction (LLM)
|
|
479
|
+
map Map current product behaviours from code + docs (no LLM)
|
|
480
|
+
behavior Answer a product-behaviour question
|
|
481
|
+
opportunities List gaps and opportunities from the behaviour map
|
|
482
|
+
pathways Generate product pathways toward a goal (LLM)
|
|
483
|
+
bets Generate strategic big bets (LLM)
|
|
484
|
+
score <idea> Score a product idea (LLM)
|
|
485
|
+
spec <title> Generate a lightweight product brief (LLM)
|
|
486
|
+
propose Stage a Chronicle entry from a Compass artifact
|
|
487
|
+
outcome Record the outcome of a prior bet or pathway
|
|
488
|
+
|
|
489
|
+
${c.bold("Options:")}
|
|
490
|
+
--area <tag> Focus on a specific product area
|
|
491
|
+
--goal <text> Goal for pathways / bets
|
|
492
|
+
--horizon <text> Horizon for bets (e.g. "6 months")
|
|
493
|
+
--appetite small|medium|large
|
|
494
|
+
--limit <n> Max results to return
|
|
495
|
+
--json Output raw JSON
|
|
496
|
+
--help Show this help
|
|
497
|
+
|
|
498
|
+
${c.bold("Examples:")}
|
|
499
|
+
quorum compass brief
|
|
500
|
+
quorum compass map
|
|
501
|
+
quorum compass map --area advisor
|
|
502
|
+
quorum compass pathways --goal "onboard new agents faster"
|
|
503
|
+
quorum compass bets --horizon "6 months"
|
|
504
|
+
quorum compass score "add Slack integration"
|
|
505
|
+
quorum compass spec "Smart retry backoff"
|
|
506
|
+
quorum compass opportunities --limit 5
|
|
507
|
+
quorum compass propose --from-last
|
|
508
|
+
quorum compass outcome --entry-id <id> --result validated`)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── Render helpers ────────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
function renderBrief(brief) {
|
|
514
|
+
console.log(`\n${c.bold("Compass Brief")} ${c.dim(`(confidence: ${(brief.confidence * 100).toFixed(0)}%)`)}`)
|
|
515
|
+
console.log(`\n${c.bold("Direction:")} ${brief.product_direction}`)
|
|
516
|
+
|
|
517
|
+
if (brief.known_from_chronicle?.length) {
|
|
518
|
+
console.log(`\n${c.bold("From Chronicle:")}`)
|
|
519
|
+
brief.known_from_chronicle.forEach(item => console.log(` ${c.green("✓")} ${item}`))
|
|
520
|
+
}
|
|
521
|
+
if (brief.known_from_behavior?.length) {
|
|
522
|
+
console.log(`\n${c.bold("From code/docs:")}`)
|
|
523
|
+
brief.known_from_behavior.slice(0, 6).forEach(item => console.log(` ${c.green("✓")} ${item}`))
|
|
524
|
+
}
|
|
525
|
+
if (brief.inferred?.length) {
|
|
526
|
+
console.log(`\n${c.bold("Inferred:")}`)
|
|
527
|
+
brief.inferred.forEach(item => console.log(` ${c.yellow("~")} ${item}`))
|
|
528
|
+
}
|
|
529
|
+
if (brief.unknowns?.length) {
|
|
530
|
+
console.log(`\n${c.bold("Unknowns:")}`)
|
|
531
|
+
brief.unknowns.forEach(item => console.log(` ${c.dim("?")} ${item}`))
|
|
532
|
+
}
|
|
533
|
+
if (brief.opportunities?.length) {
|
|
534
|
+
console.log(`\n${c.bold("Opportunities:")}`)
|
|
535
|
+
brief.opportunities.slice(0, 4).forEach(o => console.log(` ${c.cyan("→")} ${o.title}`))
|
|
536
|
+
}
|
|
537
|
+
if (brief.recommended_next_step) {
|
|
538
|
+
console.log(`\n${c.bold("Next step:")} ${brief.recommended_next_step}`)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function renderBehaviorMap(map) {
|
|
543
|
+
console.log(`\n${c.bold("Behaviour Map")} ${map.area ? c.dim(`(area: ${map.area})`) : ""} ${c.dim(`(confidence: ${(map.confidence * 100).toFixed(0)}%)`)}`)
|
|
544
|
+
|
|
545
|
+
if (map.behaviors.length > 0) {
|
|
546
|
+
console.log(`\n${c.bold(`Behaviours (${map.behaviors.length}):`)}`)
|
|
547
|
+
map.behaviors.slice(0, 20).forEach(b => {
|
|
548
|
+
console.log(` ${c.green("✓")} ${b.current_behavior.slice(0, 100)}`)
|
|
549
|
+
})
|
|
550
|
+
} else {
|
|
551
|
+
console.log(`\n ${c.dim("No behaviours found.")}`)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (map.gaps.length > 0) {
|
|
555
|
+
console.log(`\n${c.bold(`Gaps (${map.gaps.length}):`)}`)
|
|
556
|
+
map.gaps.forEach(g => {
|
|
557
|
+
console.log(` ${c.yellow("?")} [${g.area}] ${g.gap}`)
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (map.contradictions?.length) {
|
|
562
|
+
console.log(`\n${c.bold(`Contradictions (${map.contradictions.length}):`)}`)
|
|
563
|
+
map.contradictions.slice(0, 5).forEach(ct => {
|
|
564
|
+
console.log(` ${c.red("!")} ${ct.description ?? JSON.stringify(ct).slice(0, 80)}`)
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function renderPathways(pathways) {
|
|
570
|
+
console.log(`\n${c.bold(`Pathways (${pathways.length})`)}`)
|
|
571
|
+
pathways.forEach((p, i) => {
|
|
572
|
+
const score = p.scores?.total ?? "?"
|
|
573
|
+
const label =
|
|
574
|
+
score >= 85 ? c.green(`${score}`) :
|
|
575
|
+
score >= 70 ? c.cyan(`${score}`) :
|
|
576
|
+
score >= 55 ? c.yellow(`${score}`) :
|
|
577
|
+
c.dim(`${score}`)
|
|
578
|
+
|
|
579
|
+
console.log(`\n${c.bold(`${i + 1}. ${p.title}`)} ${c.dim("[")}${label}${c.dim("]")}`)
|
|
580
|
+
if (p.opportunity) console.log(` ${p.opportunity}`)
|
|
581
|
+
if (p.smallest_useful_version) console.log(` ${c.dim("Start:")} ${p.smallest_useful_version}`)
|
|
582
|
+
if (p.suggested_next_step) console.log(` ${c.dim("Next:")} ${p.suggested_next_step}`)
|
|
583
|
+
if (p.assumptions?.length) {
|
|
584
|
+
console.log(` ${c.dim("Assumes:")} ${p.assumptions[0]}`)
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function renderBets(bets) {
|
|
590
|
+
console.log(`\n${c.bold(`Strategic Bets (${bets.length})`)}`)
|
|
591
|
+
bets.forEach((b, i) => {
|
|
592
|
+
const score = b.scores?.total ?? "?"
|
|
593
|
+
console.log(`\n${c.bold(`${i + 1}. ${b.title}`)} ${c.dim(`[${score}]`)}`)
|
|
594
|
+
console.log(` ${c.dim("Thesis:")} ${b.thesis}`)
|
|
595
|
+
if (b.first_experiment) console.log(` ${c.dim("First test:")} ${b.first_experiment}`)
|
|
596
|
+
if (b.kill_criteria?.length) console.log(` ${c.red("Kill if:")} ${b.kill_criteria[0]}`)
|
|
597
|
+
if (b.assumptions?.length) console.log(` ${c.dim("Assumes:")} ${b.assumptions[0]}`)
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function renderScore(score) {
|
|
602
|
+
const total = score.scores?.total ?? 0
|
|
603
|
+
const label =
|
|
604
|
+
total >= 85 ? c.green("Very strong — pursue") :
|
|
605
|
+
total >= 70 ? c.cyan("Strong — pursue small test") :
|
|
606
|
+
total >= 55 ? c.yellow("Plausible — investigate more") :
|
|
607
|
+
total >= 40 ? c.dim("Weak — defer") :
|
|
608
|
+
c.red("Avoid")
|
|
609
|
+
|
|
610
|
+
console.log(`\n${c.bold(`Score: ${total}/100`)} — ${label}`)
|
|
611
|
+
console.log(`Idea: ${score.idea}`)
|
|
612
|
+
if (score.summary) console.log(`Summary: ${score.summary}`)
|
|
613
|
+
|
|
614
|
+
if (score.supporting_reasons?.length) {
|
|
615
|
+
console.log(`\n${c.bold("Strengths:")}`)
|
|
616
|
+
score.supporting_reasons.forEach(r => console.log(` ${c.green("+")} ${r}`))
|
|
617
|
+
}
|
|
618
|
+
if (score.risks?.length) {
|
|
619
|
+
console.log(`\n${c.bold("Risks:")}`)
|
|
620
|
+
score.risks.forEach(r => console.log(` ${c.red("-")} ${r}`))
|
|
621
|
+
}
|
|
622
|
+
if (score.open_questions?.length) {
|
|
623
|
+
console.log(`\n${c.bold("Open questions:")}`)
|
|
624
|
+
score.open_questions.forEach(q => console.log(` ${c.dim("?")} ${q}`))
|
|
625
|
+
}
|
|
626
|
+
if (score.suggested_next_step) {
|
|
627
|
+
console.log(`\n${c.bold("Next step:")} ${score.suggested_next_step}`)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function renderOpportunities(opps) {
|
|
632
|
+
if (!opps.length) {
|
|
633
|
+
console.log(c.dim("\nNo gaps or opportunities found from current sources."))
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
console.log(`\n${c.bold(`Opportunities (${opps.length})`)}`)
|
|
637
|
+
opps.forEach((o, i) => {
|
|
638
|
+
const conf = `${(o.confidence * 100).toFixed(0)}%`
|
|
639
|
+
console.log(`\n${c.bold(`${i + 1}. ${o.title}`)} ${c.dim(`[${o.area}] [${o.evidence_strength}] [${conf}]`)}`)
|
|
640
|
+
if (o.why_it_matters) console.log(` ${o.why_it_matters}`)
|
|
641
|
+
if (o.suggested_next_step) console.log(` ${c.dim("Next:")} ${o.suggested_next_step}`)
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function renderProductBrief(brief) {
|
|
646
|
+
console.log(`\n${c.bold(`Product Brief: ${brief.title}`)}`)
|
|
647
|
+
if (brief.problem) console.log(`\n${c.bold("Problem:")} ${brief.problem}`)
|
|
648
|
+
if (brief.target_user) console.log(`${c.bold("Target user:")} ${brief.target_user}`)
|
|
649
|
+
if (brief.recommended_solution) {
|
|
650
|
+
console.log(`\n${c.bold("Recommended solution:")}`)
|
|
651
|
+
console.log(` ${brief.recommended_solution}`)
|
|
652
|
+
}
|
|
653
|
+
if (brief.smallest_useful_version) {
|
|
654
|
+
console.log(`\n${c.bold("Smallest useful version:")}`)
|
|
655
|
+
console.log(` ${brief.smallest_useful_version}`)
|
|
656
|
+
}
|
|
657
|
+
if (brief.non_goals?.length) {
|
|
658
|
+
console.log(`\n${c.bold("Non-goals:")}`)
|
|
659
|
+
brief.non_goals.forEach(g => console.log(` ${c.dim("✗")} ${g}`))
|
|
660
|
+
}
|
|
661
|
+
if (brief.risks?.length) {
|
|
662
|
+
console.log(`\n${c.bold("Risks:")}`)
|
|
663
|
+
brief.risks.forEach(r => console.log(` ${c.red("-")} ${r}`))
|
|
664
|
+
}
|
|
665
|
+
if (brief.open_questions?.length) {
|
|
666
|
+
console.log(`\n${c.bold("Open questions:")}`)
|
|
667
|
+
brief.open_questions.forEach(q => console.log(` ${c.dim("?")} ${q}`))
|
|
668
|
+
}
|
|
669
|
+
if (brief.suggested_quorum_checks?.length) {
|
|
670
|
+
console.log(`\n${c.bold("Quorum checks:")}`)
|
|
671
|
+
brief.suggested_quorum_checks.forEach(ch => console.log(` ${c.cyan("$")} ${ch}`))
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Last-run artifact cache (used by --from-last) ─────────────────────────────
|
|
676
|
+
|
|
677
|
+
let _lastArtifact = null
|
|
678
|
+
|
|
679
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
export async function run(argv) {
|
|
682
|
+
const [subcommand, ...rest] = argv
|
|
683
|
+
|
|
684
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
685
|
+
help()
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const flags = {}
|
|
690
|
+
const positional = []
|
|
691
|
+
for (let i = 0; i < rest.length; i++) {
|
|
692
|
+
const a = rest[i]
|
|
693
|
+
if (a.startsWith("--")) {
|
|
694
|
+
const key = a.slice(2)
|
|
695
|
+
const val = rest[i + 1] && !rest[i + 1].startsWith("--") ? rest[++i] : true
|
|
696
|
+
flags[key] = val
|
|
697
|
+
} else {
|
|
698
|
+
positional.push(a)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const area = flags["area"]
|
|
703
|
+
const goal = flags["goal"] || positional.join(" ") || undefined
|
|
704
|
+
const horizon = flags["horizon"] || undefined
|
|
705
|
+
const appetite = flags["appetite"] || undefined
|
|
706
|
+
const limitN = flags["limit"] ? parseInt(flags["limit"], 10) : undefined
|
|
707
|
+
const jsonMode = Boolean(flags["json"])
|
|
708
|
+
const entryId = flags["entry-id"] || flags["entryId"] || undefined
|
|
709
|
+
const result = flags["result"] || undefined
|
|
710
|
+
|
|
711
|
+
// ── Setup ─────────────────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
const rootDir = process.cwd()
|
|
714
|
+
const chronicleDir = findChronicleDir(rootDir)
|
|
715
|
+
|
|
716
|
+
if (!chronicleDir) {
|
|
717
|
+
console.error(c.red("Error: Chronicle not found. Run 'quorum init' first."))
|
|
718
|
+
process.exit(1)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const NO_LLM_CMDS = new Set(["map", "opportunities"])
|
|
722
|
+
const llm = NO_LLM_CMDS.has(subcommand) ? undefined : detectProvider()
|
|
723
|
+
|
|
724
|
+
// ── Shared context helper ─────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
async function getContext(areaFilter) {
|
|
727
|
+
const [entries, findings] = await Promise.all([
|
|
728
|
+
readCommitted(chronicleDir),
|
|
729
|
+
collectTerrain(rootDir, areaFilter),
|
|
730
|
+
])
|
|
731
|
+
const bearings = collectBearings(entries, areaFilter)
|
|
732
|
+
const chronicleCtx = formatBearings(bearings)
|
|
733
|
+
const behaviorCtx = formatTerrain(findings)
|
|
734
|
+
const behaviorMap = mapBehaviors(findings, areaFilter)
|
|
735
|
+
return { entries, findings, bearings, chronicleCtx, behaviorCtx, behaviorMap }
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ── Route subcommand ───────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
try {
|
|
741
|
+
switch (subcommand) {
|
|
742
|
+
case "brief": {
|
|
743
|
+
const { chronicleCtx, behaviorCtx, behaviorMap } = await getContext(area)
|
|
744
|
+
if (!llm) {
|
|
745
|
+
const data = {
|
|
746
|
+
product_direction: "Unable to synthesize direction — no LLM configured. See Chronicle and behaviour map for raw evidence.",
|
|
747
|
+
known_from_chronicle: [],
|
|
748
|
+
known_from_behavior: behaviorMap.behaviors.slice(0, 5).map(b => b.current_behavior),
|
|
749
|
+
inferred: [],
|
|
750
|
+
unknowns: ["LLM not configured — full synthesis unavailable."],
|
|
751
|
+
recommended_next_step: "Run: quorum advisor brief",
|
|
752
|
+
confidence: 0.4,
|
|
753
|
+
}
|
|
754
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
755
|
+
renderBrief(data)
|
|
756
|
+
break
|
|
757
|
+
}
|
|
758
|
+
const raw = await callLLM(llm, buildBriefPrompt(chronicleCtx, behaviorCtx, area))
|
|
759
|
+
let data
|
|
760
|
+
try { data = parseLLMJson(raw) } catch { throw new Error(`Compass brief: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
|
|
761
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
762
|
+
renderBrief(data)
|
|
763
|
+
break
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
case "map": {
|
|
767
|
+
const findings = await collectTerrain(rootDir, area)
|
|
768
|
+
const data = mapBehaviors(findings, area)
|
|
769
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
770
|
+
renderBehaviorMap(data)
|
|
771
|
+
break
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
case "behavior": {
|
|
775
|
+
const question = goal || positional.join(" ")
|
|
776
|
+
if (!question) {
|
|
777
|
+
console.error(c.red('Error: provide a question, e.g. quorum compass behavior "what does quorum do for onboarding?"'))
|
|
778
|
+
process.exit(1)
|
|
779
|
+
}
|
|
780
|
+
const findings = await collectTerrain(rootDir, area)
|
|
781
|
+
const behaviorMap = mapBehaviors(findings, area)
|
|
782
|
+
const what_exists = behaviorMap.behaviors.slice(0, 6).map(b => b.current_behavior)
|
|
783
|
+
const what_appears_missing = behaviorMap.gaps.slice(0, 4).map(g => g.gap)
|
|
784
|
+
const data = {
|
|
785
|
+
question,
|
|
786
|
+
what_exists,
|
|
787
|
+
what_appears_missing,
|
|
788
|
+
product_implication: behaviorMap.gaps.length > 0
|
|
789
|
+
? `The area has ${behaviorMap.behaviors.length} documented behaviours but ${behaviorMap.gaps.length} notable gaps.`
|
|
790
|
+
: `The area appears well-covered with ${behaviorMap.behaviors.length} documented behaviours.`,
|
|
791
|
+
confidence: behaviorMap.confidence,
|
|
792
|
+
}
|
|
793
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
794
|
+
console.log(`\n${c.bold("Behaviour answer:")} ${data.product_implication}`)
|
|
795
|
+
if (data.what_exists?.length) {
|
|
796
|
+
console.log(`\n${c.bold("What exists:")}`)
|
|
797
|
+
data.what_exists.forEach(e => console.log(` ${c.green("✓")} ${e}`))
|
|
798
|
+
}
|
|
799
|
+
if (data.what_appears_missing?.length) {
|
|
800
|
+
console.log(`\n${c.bold("Appears missing:")}`)
|
|
801
|
+
data.what_appears_missing.forEach(m => console.log(` ${c.yellow("?")} ${m}`))
|
|
802
|
+
}
|
|
803
|
+
break
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
case "opportunities": {
|
|
807
|
+
const findings = await collectTerrain(rootDir, area)
|
|
808
|
+
const behaviorMap = mapBehaviors(findings, area)
|
|
809
|
+
let opps = behaviorMap.gaps.map((g, i) => ({
|
|
810
|
+
id: `opp-${i}`, title: g.gap, area: g.area,
|
|
811
|
+
why_it_matters: g.why_it_matters,
|
|
812
|
+
evidence_strength: g.confidence >= 0.7 ? "strong" : g.confidence >= 0.5 ? "medium" : "inferred",
|
|
813
|
+
suggested_next_step: `quorum compass pathways --goal "${g.gap.slice(0, 50)}"`,
|
|
814
|
+
confidence: g.confidence,
|
|
815
|
+
}))
|
|
816
|
+
if (limitN) opps = opps.slice(0, limitN)
|
|
817
|
+
if (goal) opps = opps.filter(o => o.title.toLowerCase().includes(goal.toLowerCase()) || o.area.toLowerCase().includes(goal.toLowerCase()))
|
|
818
|
+
if (jsonMode) { console.log(JSON.stringify(opps, null, 2)); break }
|
|
819
|
+
renderOpportunities(opps)
|
|
820
|
+
break
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
case "pathways": {
|
|
824
|
+
if (!goal) {
|
|
825
|
+
console.error(c.red('Error: --goal is required. Example: quorum compass pathways --goal "onboard new agents faster"'))
|
|
826
|
+
process.exit(1)
|
|
827
|
+
}
|
|
828
|
+
const { chronicleCtx, behaviorCtx } = await getContext(area)
|
|
829
|
+
const raw = await callLLM(llm, buildPathwaysPrompt(goal, horizon, appetite, chronicleCtx, behaviorCtx, area, limitN))
|
|
830
|
+
let parsed
|
|
831
|
+
try { parsed = parseLLMJson(raw) } catch { throw new Error(`Compass pathways: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
|
|
832
|
+
const data = (parsed.pathways ?? []).map(p => ({ ...p, scores: computeScore(p.scores ?? {}) }))
|
|
833
|
+
_lastArtifact = { kind: "product_pathway", items: data }
|
|
834
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
835
|
+
renderPathways(data)
|
|
836
|
+
console.log(c.dim("\nTip: run 'quorum compass propose --from-last' to stage a Chronicle entry."))
|
|
837
|
+
break
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
case "bets": {
|
|
841
|
+
const { chronicleCtx, behaviorCtx } = await getContext()
|
|
842
|
+
const raw = await callLLM(llm, buildBetsPrompt(horizon, goal, appetite, chronicleCtx, behaviorCtx))
|
|
843
|
+
let parsed
|
|
844
|
+
try { parsed = parseLLMJson(raw) } catch { throw new Error(`Compass bets: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
|
|
845
|
+
const data = (parsed.bets ?? []).map(b => ({ ...b, scores: computeScore(b.scores ?? {}) }))
|
|
846
|
+
_lastArtifact = { kind: "product_bet", items: data }
|
|
847
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
848
|
+
renderBets(data)
|
|
849
|
+
console.log(c.dim("\nTip: run 'quorum compass propose --from-last' to stage a Chronicle entry."))
|
|
850
|
+
break
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
case "score": {
|
|
854
|
+
const idea = goal || positional.join(" ")
|
|
855
|
+
if (!idea) {
|
|
856
|
+
console.error(c.red('Error: provide an idea. Example: quorum compass score "add Slack integration"'))
|
|
857
|
+
process.exit(1)
|
|
858
|
+
}
|
|
859
|
+
const { chronicleCtx, behaviorCtx } = await getContext()
|
|
860
|
+
const raw = await callLLM(llm, buildScorePrompt(idea, chronicleCtx, behaviorCtx))
|
|
861
|
+
let data
|
|
862
|
+
try { data = parseLLMJson(raw) } catch { throw new Error(`Compass score: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
|
|
863
|
+
if (data.scores) data.scores = computeScore(data.scores)
|
|
864
|
+
_lastArtifact = { kind: "product_idea_score", items: [data] }
|
|
865
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
866
|
+
renderScore(data)
|
|
867
|
+
break
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
case "spec": {
|
|
871
|
+
const title = goal || positional.join(" ")
|
|
872
|
+
if (!title) {
|
|
873
|
+
console.error(c.red('Error: provide a title. Example: quorum compass spec "Smart retry backoff"'))
|
|
874
|
+
process.exit(1)
|
|
875
|
+
}
|
|
876
|
+
const { chronicleCtx, behaviorCtx } = await getContext()
|
|
877
|
+
const specPrompt = `Generate a lightweight product brief for: ${title}
|
|
878
|
+
|
|
879
|
+
## Chronicle evidence
|
|
880
|
+
${chronicleCtx}
|
|
881
|
+
|
|
882
|
+
## Current product behaviour
|
|
883
|
+
${behaviorCtx}
|
|
884
|
+
|
|
885
|
+
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>"] }`
|
|
886
|
+
const raw = await callLLM(llm, specPrompt)
|
|
887
|
+
let data
|
|
888
|
+
try { data = parseLLMJson(raw) } catch { throw new Error(`Compass spec: LLM returned non-JSON. Raw: ${raw.slice(0, 300)}`) }
|
|
889
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
890
|
+
renderProductBrief(data)
|
|
891
|
+
break
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
case "propose": {
|
|
895
|
+
if (flags["from-last"]) {
|
|
896
|
+
if (!_lastArtifact?.items?.length) {
|
|
897
|
+
console.error(c.red("Error: no Compass artifact in memory. Run pathways/bets/score first in the same session."))
|
|
898
|
+
process.exit(1)
|
|
899
|
+
}
|
|
900
|
+
const item = _lastArtifact.items[0]
|
|
901
|
+
const res = await stageProposal(chronicleDir, _lastArtifact.kind, item)
|
|
902
|
+
console.log(c.green(`\n✓ ${res.message}`))
|
|
903
|
+
break
|
|
904
|
+
}
|
|
905
|
+
console.error(c.red('Error: provide --from-last. Example: quorum compass propose --from-last'))
|
|
906
|
+
process.exit(1)
|
|
907
|
+
break
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
case "outcome": {
|
|
911
|
+
if (!entryId) {
|
|
912
|
+
console.error(c.red("Error: --entry-id is required. Example: quorum compass outcome --entry-id abc123 --result validated"))
|
|
913
|
+
process.exit(1)
|
|
914
|
+
}
|
|
915
|
+
if (!result) {
|
|
916
|
+
console.error(c.red("Error: --result is required. Values: validated, partially-validated, invalidated, unclear, superseded"))
|
|
917
|
+
process.exit(1)
|
|
918
|
+
}
|
|
919
|
+
const note = flags["note"] || undefined
|
|
920
|
+
const data = await stageOutcome(chronicleDir, entryId, result, note)
|
|
921
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); break }
|
|
922
|
+
console.log(c.green(`\n✓ ${data.message}`))
|
|
923
|
+
break
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
default: {
|
|
927
|
+
console.error(c.red(`Unknown subcommand: ${subcommand}`))
|
|
928
|
+
help()
|
|
929
|
+
process.exit(1)
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
} catch (err) {
|
|
933
|
+
if (err.message?.includes("LLM provider is required") || err.message?.includes("No LLM provider")) {
|
|
934
|
+
console.error(c.red(`\nError: ${err.message}`))
|
|
935
|
+
console.error(c.dim("Set ANTHROPIC_API_KEY or OPENAI_API_KEY to use this subcommand."))
|
|
936
|
+
} else {
|
|
937
|
+
console.error(c.red(`\nCompass error: ${err.message ?? err}`))
|
|
938
|
+
if (process.env.DEBUG) console.error(err.stack)
|
|
939
|
+
}
|
|
940
|
+
process.exit(1)
|
|
941
|
+
}
|
|
942
|
+
}
|