@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.
@@ -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
+ }