@balpal4495/quorum 0.4.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -119,23 +119,15 @@ export async function run(argv) {
119
119
  return
120
120
  }
121
121
 
122
- // ── Check optional dependencies ────────────────────────────────────────────
123
- console.log(`\n${c.bold("Checking dependencies")}`)
122
+ // ── Check optional embedding dependencies ─────────────────────────────────
124
123
  const hasXenova = await checkDep("@xenova/transformers")
125
124
  const hasLanceDB = await checkDep("vectordb")
125
+ const canEmbed = hasXenova && hasLanceDB
126
126
 
127
- if (!hasXenova) {
128
- console.error(`\n ${c.red("✗")} @xenova/transformers not installed`)
129
- console.error(c.dim(" Run: npm install @xenova/transformers\n"))
130
- process.exit(1)
131
- }
132
- if (!hasLanceDB) {
133
- console.error(`\n ${c.red("✗")} vectordb not installed`)
134
- console.error(c.dim(" Run: npm install vectordb\n"))
135
- process.exit(1)
127
+ if (!canEmbed) {
128
+ console.log(`\n${c.dim(" Embedding deps not found — committing to JSON store only.")}`)
129
+ console.log(c.dim(" Install @xenova/transformers + vectordb to enable semantic search.\n"))
136
130
  }
137
- console.log(` ${c.green("✓")} @xenova/transformers`)
138
- console.log(` ${c.green("✓")} vectordb`)
139
131
 
140
132
  // ── Build entry ────────────────────────────────────────────────────────────
141
133
  const entry = {
@@ -144,46 +136,44 @@ export async function run(argv) {
144
136
  timestamp: new Date().toISOString(),
145
137
  }
146
138
 
147
- // ── Embed ──────────────────────────────────────────────────────────────────
148
- const spin = spinner("Loading embedder (first run may take 30s)…")
149
- let vector
150
- try {
151
- const { pipeline } = (await import("@xenova/transformers")).default ?? await import("@xenova/transformers")
152
- const embedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2")
153
- const embeddingText = [
154
- entryText(entry),
155
- ...(entry.affected_areas ?? []),
156
- ...(entry.scope ?? []),
157
- ].join(" ")
158
- const output = await embedder(embeddingText, { pooling: "mean", normalize: true })
159
- vector = Array.from(output.data)
160
- spin.stop(`${c.green("✓")} Embedded (${vector.length}-dim)`)
161
- } catch (err) {
162
- spin.stop(`${c.red("")} Embedding failed`)
163
- console.error(c.red(`\n ${err.message}\n`))
164
- process.exit(1)
165
- }
166
-
167
- // ── Store in LanceDB ───────────────────────────────────────────────────────
168
- const storeSpin = spinner("Indexing in Chronicle…")
169
- try {
170
- const lancedb = (await import("vectordb")).default ?? (await import("vectordb"))
171
- const tableDir = path.join(chronicleDir, "entries")
172
- const db = await lancedb.connect(tableDir)
173
- const row = { id: entry.id, vector, payload: JSON.stringify(entry) }
174
- const names = await db.tableNames()
175
- if (names.includes("entries")) {
176
- const table = await db.openTable("entries")
177
- await table.delete(`id = '${entry.id.replace(/'/g, "''")}'`)
178
- await table.add([row])
179
- } else {
180
- await db.createTable("entries", [row], { metric: "cosine" })
139
+ // ── Embed + index in vector store (optional) ───────────────────────────────
140
+ if (canEmbed) {
141
+ const spin = spinner("Embedding and indexing…")
142
+ try {
143
+ const { pipeline } = (await import("@xenova/transformers")).default ?? await import("@xenova/transformers")
144
+ const embedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2")
145
+ const embeddingText = [
146
+ entryText(entry),
147
+ ...(entry.affected_areas ?? []),
148
+ ...(entry.scope ?? []),
149
+ ].join(" ")
150
+ const output = await embedder(embeddingText, { pooling: "mean", normalize: true })
151
+ const vector = Array.from(output.data)
152
+ spin.stop(`${c.green("✓")} Embedded (${vector.length}-dim)`)
153
+
154
+ const storeSpin = spinner("Indexing in vector store…")
155
+ try {
156
+ const lancedb = (await import("vectordb")).default ?? (await import("vectordb"))
157
+ const tableDir = path.join(chronicleDir, "entries")
158
+ const db = await lancedb.connect(tableDir)
159
+ const row = { id: entry.id, vector, payload: JSON.stringify(entry) }
160
+ const names = await db.tableNames()
161
+ if (names.includes("entries")) {
162
+ const table = await db.openTable("entries")
163
+ await table.delete(`id = '${entry.id.replace(/'/g, "''")}'`)
164
+ await table.add([row])
165
+ } else {
166
+ await db.createTable("entries", [row], { metric: "cosine" })
167
+ }
168
+ storeSpin.stop(`${c.green("")} Indexed in vector store`)
169
+ } catch (err) {
170
+ storeSpin.stop(`${c.yellow("⚠")} Vector store write failed — JSON commit will proceed`)
171
+ console.error(c.dim(` ${err.message}`))
172
+ }
173
+ } catch (err) {
174
+ spin.stop(`${c.yellow("⚠")} Embedding failed — JSON commit will proceed`)
175
+ console.error(c.dim(` ${err.message}`))
181
176
  }
182
- storeSpin.stop(`${c.green("✓")} Indexed in vector store`)
183
- } catch (err) {
184
- storeSpin.stop(`${c.red("✗")} Vector store write failed`)
185
- console.error(c.red(`\n ${err.message}\n`))
186
- process.exit(1)
187
177
  }
188
178
 
189
179
  // ── Write committed file ───────────────────────────────────────────────────
@@ -0,0 +1,285 @@
1
+ import { promises as fs } from "fs"
2
+ import path from "path"
3
+ import { randomUUID } from "crypto"
4
+ import { c } from "../shared/colors.js"
5
+ import { findChronicleDir, readCommitted, entryText } from "../shared/chronicle.js"
6
+ import { detectProvider } from "../shared/llm.js"
7
+
8
+ function parseArgs(argv) {
9
+ const args = { dryRun: false, json: false }
10
+ for (const arg of argv) {
11
+ if (arg === "--dry-run") args.dryRun = true
12
+ if (arg === "--json") args.json = true
13
+ }
14
+ return args
15
+ }
16
+
17
+ const SYSTEM_PROMPT = `You are a Chronicle Analyst for Quorum — a persistent knowledge store that gives AI coding assistants long-term memory across sessions.
18
+
19
+ You will receive a list of committed Chronicle entries. Your task is to find quality improvements. Be conservative — it is correct to propose nothing when entries are all distinct and healthy. Never consolidate entries that are merely related; only merge when they are genuinely saying the same thing.
20
+
21
+ Three types of improvement:
22
+
23
+ CONSOLIDATE — two or more entries that cover the same ground, expressed redundantly or overlapping in scope. Propose merging into one sharper entry that supersedes them.
24
+
25
+ RESOLVE_CONTRADICTION — a validated entry that a newer entry implicitly supersedes or contradicts. Propose marking the older entry as refuted.
26
+
27
+ PROMOTE_OPEN — an entry with status "open" that other entries have since confirmed or validated. Propose elevating to "validated" with a justified confidence score.
28
+
29
+ Return ONLY valid JSON — no prose, no markdown fences — with this exact shape:
30
+ {
31
+ "actions": [
32
+ {
33
+ "type": "consolidate",
34
+ "entry_ids": ["full-uuid-1", "full-uuid-2"],
35
+ "synthesised": {
36
+ "topic": "short label",
37
+ "decision": "One precise sentence: what was decided and why.",
38
+ "key_insight": "same as decision",
39
+ "affected_areas": ["path/to/file.ts"],
40
+ "scope": ["tag1", "tag2"],
41
+ "alternatives_considered": [],
42
+ "rejected_reason": [],
43
+ "status": "validated",
44
+ "confidence": 0.9
45
+ },
46
+ "reason": "why these entries should be merged"
47
+ },
48
+ {
49
+ "type": "resolve_contradiction",
50
+ "stale_entry_id": "full-uuid",
51
+ "superseding_entry_id": "full-uuid",
52
+ "reason": "why the older entry is now superseded"
53
+ },
54
+ {
55
+ "type": "promote_open",
56
+ "entry_id": "full-uuid",
57
+ "new_confidence": 0.85,
58
+ "reason": "which other entries confirm this"
59
+ }
60
+ ],
61
+ "no_action_reason": "brief note on why the rest need no change, or empty string"
62
+ }`
63
+
64
+ function formatEntries(entries) {
65
+ return entries.map(e => {
66
+ const lines = [
67
+ `ID: ${e.id}`,
68
+ `Topic: ${e.topic ?? "(none)"}`,
69
+ `Status: ${e.status}`,
70
+ `Confidence: ${e.confidence}`,
71
+ `Decision: ${e.decision ?? e.key_insight}`,
72
+ `Affected areas: ${(e.affected_areas ?? []).join(", ")}`,
73
+ `Scope: ${(e.scope ?? []).join(", ")}`,
74
+ ]
75
+ if (e.alternatives_considered?.length) lines.push(`Alternatives considered: ${e.alternatives_considered.join("; ")}`)
76
+ if (e.rejected_reason?.length) lines.push(`Rejected reason: ${e.rejected_reason.join("; ")}`)
77
+ if (e.supersedes) lines.push(`Supersedes: ${e.supersedes}`)
78
+ if (e.superseded_by) lines.push(`Superseded by: ${e.superseded_by}`)
79
+ return lines.join("\n")
80
+ }).join("\n\n---\n\n")
81
+ }
82
+
83
+ function validateAction(action) {
84
+ if (!action || typeof action.type !== "string") return false
85
+ if (action.type === "consolidate")
86
+ return Array.isArray(action.entry_ids) && action.entry_ids.length >= 2 && action.synthesised
87
+ if (action.type === "resolve_contradiction")
88
+ return typeof action.stale_entry_id === "string" && typeof action.superseding_entry_id === "string"
89
+ if (action.type === "promote_open")
90
+ return typeof action.entry_id === "string" && typeof action.new_confidence === "number"
91
+ return false
92
+ }
93
+
94
+ function spinner(msg) {
95
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
96
+ let i = 0
97
+ const interval = setInterval(() => {
98
+ process.stdout.write(`\r ${c.cyan(frames[i++ % frames.length])} ${msg}`)
99
+ }, 80)
100
+ return { stop: (final) => { clearInterval(interval); process.stdout.write(`\r ${final}\n`) } }
101
+ }
102
+
103
+ function renderEvolvePassthrough(entries) {
104
+ console.log(`\n${c.bold("Chronicle evolution analysis")} ${c.dim(`${entries.length} entries`)}\n`)
105
+ console.log(c.dim(" No LLM configured — outputting Chronicle for agent analysis.\n"))
106
+ console.log(formatEntries(entries))
107
+ console.log(c.dim("─".repeat(60)))
108
+ console.log(`\n${c.bold("Analysis request")}\n`)
109
+ console.log(" Review the Chronicle entries above and identify quality improvements:")
110
+ console.log(" · consolidate — entries covering the same ground (merge into one stronger entry)")
111
+ console.log(" · resolve — a validated entry superseded or contradicted by a newer one")
112
+ console.log(" · promote — an 'open' entry confirmed by other entries (elevate to validated)")
113
+ console.log("")
114
+ console.log(" For each improvement, create a proposal using the template in CLAUDE.md:")
115
+ console.log(c.dim(" node -e \"const { randomUUID } = require('crypto'); ...\" (see CLAUDE.md)"))
116
+ console.log(c.dim(" Then run: quorum commit --list\n"))
117
+ }
118
+
119
+ export async function run(argv) {
120
+ const args = parseArgs(argv)
121
+
122
+ const chronicleDir = await findChronicleDir(process.cwd())
123
+ if (!chronicleDir) {
124
+ console.error(`\n${c.red("No .chronicle/ directory found.")} Run ${c.bold("quorum init")} first.\n`)
125
+ process.exit(1)
126
+ }
127
+
128
+ const entries = await readCommitted(chronicleDir)
129
+ if (entries.length === 0) {
130
+ console.log(`\n${c.dim("No committed entries — nothing to evolve.")}\n`)
131
+ return
132
+ }
133
+
134
+ const provider = await detectProvider()
135
+ if (!provider) {
136
+ renderEvolvePassthrough(entries)
137
+ return
138
+ }
139
+ const { llm, name: llmName } = provider
140
+
141
+ console.log(`\n${c.bold("Quorum evolve")} ${c.dim(`${entries.length} entries · via ${llmName}`)}\n`)
142
+
143
+ const spin = spinner(`Analysing ${entries.length} Chronicle entries…`)
144
+
145
+ let raw
146
+ try {
147
+ raw = await llm([
148
+ { role: "system", content: SYSTEM_PROMPT },
149
+ {
150
+ role: "user",
151
+ content: `Here are the ${entries.length} committed Chronicle entries:\n\n${formatEntries(entries)}\n\nAnalyse and return your proposed improvements as JSON.`,
152
+ },
153
+ ])
154
+ spin.stop(`${c.green("✓")} Analysis complete`)
155
+ } catch (err) {
156
+ spin.stop(`${c.red("✗")} LLM call failed`)
157
+ console.error(c.dim(` ${err.message}\n`))
158
+ process.exit(1)
159
+ }
160
+
161
+ let parsed
162
+ try {
163
+ const cleaned = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim()
164
+ parsed = JSON.parse(cleaned)
165
+ } catch {
166
+ console.error(`\n${c.red("Could not parse LLM response as JSON.")}\n`)
167
+ console.error(c.dim(raw.slice(0, 500)))
168
+ process.exit(1)
169
+ }
170
+
171
+ const actions = (parsed.actions ?? []).filter(validateAction)
172
+
173
+ if (args.json) {
174
+ console.log(JSON.stringify({ actions, no_action_reason: parsed.no_action_reason ?? "" }, null, 2))
175
+ return
176
+ }
177
+
178
+ if (actions.length === 0) {
179
+ console.log(`\n ${c.green("✓")} Chronicle is clean — no improvements identified.`)
180
+ if (parsed.no_action_reason) console.log(`\n ${c.dim(parsed.no_action_reason)}`)
181
+ console.log("")
182
+ return
183
+ }
184
+
185
+ console.log(`\n ${c.bold(String(actions.length))} improvement${actions.length === 1 ? "" : "s"} found\n`)
186
+
187
+ if (args.dryRun) {
188
+ for (const action of actions) {
189
+ if (action.type === "consolidate") {
190
+ console.log(` ${c.cyan("consolidate")} ${action.entry_ids.map(id => id.slice(0, 8)).join(" + ")}`)
191
+ console.log(` ${c.dim(action.reason)}`)
192
+ console.log(` ${c.dim("→")} ${action.synthesised.decision ?? action.synthesised.key_insight}`)
193
+ } else if (action.type === "resolve_contradiction") {
194
+ console.log(` ${c.yellow("resolve")} ${action.stale_entry_id.slice(0, 8)} → refuted (superseded by ${action.superseding_entry_id.slice(0, 8)})`)
195
+ console.log(` ${c.dim(action.reason)}`)
196
+ } else if (action.type === "promote_open") {
197
+ console.log(` ${c.green("promote")} ${action.entry_id.slice(0, 8)} → validated (confidence ${action.new_confidence})`)
198
+ console.log(` ${c.dim(action.reason)}`)
199
+ }
200
+ console.log("")
201
+ }
202
+ console.log(c.dim(" (Dry run — no proposals written.)\n"))
203
+ return
204
+ }
205
+
206
+ // Write proposals
207
+ const proposalsDir = path.join(chronicleDir, "proposals")
208
+ await fs.mkdir(proposalsDir, { recursive: true })
209
+
210
+ const entryMap = new Map(entries.map(e => [e.id, e]))
211
+ let stagedCount = 0
212
+
213
+ for (const action of actions) {
214
+ const proposalId = randomUUID()
215
+ let proposal
216
+
217
+ if (action.type === "consolidate") {
218
+ const sources = action.entry_ids.map(eid => entryMap.get(eid)).filter(Boolean)
219
+ const mergedAreas = [...new Set(sources.flatMap(e => e.affected_areas ?? []))]
220
+ const mergedScope = [...new Set(sources.flatMap(e => e.scope ?? []))]
221
+ proposal = {
222
+ schema_version: 2,
223
+ ...action.synthesised,
224
+ affected_areas: action.synthesised.affected_areas?.length ? action.synthesised.affected_areas : mergedAreas,
225
+ scope: action.synthesised.scope?.length ? action.synthesised.scope : mergedScope,
226
+ key_insight: action.synthesised.decision ?? action.synthesised.key_insight,
227
+ decision: action.synthesised.decision ?? action.synthesised.key_insight,
228
+ supersedes: action.entry_ids,
229
+ source_module: "evolve",
230
+ evidence_cited: action.entry_ids,
231
+ _evolve_action: "consolidate",
232
+ _evolve_reason: action.reason,
233
+ }
234
+ } else if (action.type === "resolve_contradiction") {
235
+ const stale = entryMap.get(action.stale_entry_id)
236
+ proposal = {
237
+ schema_version: 2,
238
+ key_insight: stale ? entryText(stale) : action.stale_entry_id,
239
+ decision: stale ? entryText(stale) : action.stale_entry_id,
240
+ topic: stale?.topic ?? "contradiction-resolution",
241
+ affected_areas: stale?.affected_areas ?? [],
242
+ scope: [...(stale?.scope ?? []), "evolution"],
243
+ status: "refuted",
244
+ confidence: stale?.confidence ?? 0.5,
245
+ source_module: "evolve",
246
+ evidence_cited: [action.superseding_entry_id],
247
+ supersedes: action.stale_entry_id,
248
+ _evolve_action: "resolve_contradiction",
249
+ _evolve_reason: action.reason,
250
+ }
251
+ } else if (action.type === "promote_open") {
252
+ const original = entryMap.get(action.entry_id)
253
+ if (!original) continue
254
+ const { id: _id, timestamp: _ts, ...rest } = original
255
+ proposal = {
256
+ ...rest,
257
+ schema_version: 2,
258
+ status: "validated",
259
+ confidence: action.new_confidence,
260
+ supersedes: action.entry_id,
261
+ source_module: "evolve",
262
+ _evolve_action: "promote_open",
263
+ _evolve_reason: action.reason,
264
+ }
265
+ }
266
+
267
+ if (!proposal) continue
268
+ await fs.writeFile(path.join(proposalsDir, `${proposalId}.json`), JSON.stringify(proposal, null, 2))
269
+ stagedCount++
270
+
271
+ if (action.type === "consolidate") {
272
+ console.log(` ${c.green("✓")} ${c.cyan("consolidate")} ${action.entry_ids.map(id => id.slice(0, 8)).join(" + ")}`)
273
+ console.log(` ${c.dim(action.reason)}`)
274
+ } else if (action.type === "resolve_contradiction") {
275
+ console.log(` ${c.green("✓")} ${c.yellow("resolve")} ${action.stale_entry_id.slice(0, 8)} → refuted`)
276
+ console.log(` ${c.dim(action.reason)}`)
277
+ } else if (action.type === "promote_open") {
278
+ console.log(` ${c.green("✓")} ${c.green("promote")} ${action.entry_id.slice(0, 8)} → validated (${action.new_confidence})`)
279
+ console.log(` ${c.dim(action.reason)}`)
280
+ }
281
+ console.log("")
282
+ }
283
+
284
+ console.log(` ${c.green(String(stagedCount))} proposal${stagedCount === 1 ? "" : "s"} staged — run ${c.bold("quorum commit --list")} to review\n`)
285
+ }
@@ -0,0 +1,139 @@
1
+ import { c } from "../shared/colors.js"
2
+ import { findChronicleDir, readCommitted, readProposals, entryText } from "../shared/chronicle.js"
3
+
4
+ const STALLED_DAYS = 14
5
+ const SLOW_DAYS = 7
6
+
7
+ function parseArgs(argv) {
8
+ const args = { json: false }
9
+ for (const arg of argv) {
10
+ if (arg === "--json") args.json = true
11
+ }
12
+ return args
13
+ }
14
+
15
+ function weekKey(timestamp) {
16
+ const d = new Date(timestamp)
17
+ const day = d.getUTCDay() || 7
18
+ const mon = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - day + 1))
19
+ return mon.toISOString().slice(0, 10)
20
+ }
21
+
22
+ export async function run(argv) {
23
+ const args = parseArgs(argv)
24
+
25
+ const chronicleDir = await findChronicleDir(process.cwd())
26
+ if (!chronicleDir) {
27
+ console.error(`\n${c.red("No .chronicle/ directory found.")} Run ${c.bold("quorum init")} first.\n`)
28
+ process.exit(1)
29
+ }
30
+
31
+ const [entries, proposals] = await Promise.all([
32
+ readCommitted(chronicleDir),
33
+ readProposals(chronicleDir),
34
+ ])
35
+
36
+ const now = Date.now()
37
+ const msPerDay = 86_400_000
38
+ const sorted = [...entries].sort((a, b) => (a.timestamp ?? "").localeCompare(b.timestamp ?? ""))
39
+
40
+ const in7Days = entries.filter(e => e.timestamp && (now - new Date(e.timestamp).getTime()) < 7 * msPerDay).length
41
+ const in30Days = entries.filter(e => e.timestamp && (now - new Date(e.timestamp).getTime()) < 30 * msPerDay).length
42
+
43
+ const newest = sorted[sorted.length - 1]
44
+ const daysSince = newest?.timestamp
45
+ ? Math.floor((now - new Date(newest.timestamp).getTime()) / msPerDay)
46
+ : null
47
+
48
+ let status
49
+ if (entries.length === 0) status = "empty"
50
+ else if (daysSince !== null && daysSince >= STALLED_DAYS) status = "stalled"
51
+ else if (daysSince !== null && daysSince >= SLOW_DAYS) status = "slow"
52
+ else if (in7Days >= 3) status = "thriving"
53
+ else status = "healthy"
54
+
55
+ if (args.json) {
56
+ console.log(JSON.stringify({
57
+ status,
58
+ totalEntries: entries.length,
59
+ pendingProposals: proposals.length,
60
+ commitsLast7Days: in7Days,
61
+ commitsLast30Days: in30Days,
62
+ daysSinceLastCommit: daysSince,
63
+ newestEntryTimestamp: newest?.timestamp ?? null,
64
+ }, null, 2))
65
+ return
66
+ }
67
+
68
+ const statusLabel = {
69
+ empty: c.red("EMPTY — Chronicle has no committed entries"),
70
+ stalled: c.red(`STALLED — no commits in ${daysSince} days`),
71
+ slow: c.yellow(`SLOW — no commits in ${daysSince} day${daysSince === 1 ? "" : "s"}`),
72
+ thriving: c.green("THRIVING"),
73
+ healthy: c.green("HEALTHY"),
74
+ }[status]
75
+
76
+ console.log(`\n${c.bold("Chronicle growth")}\n`)
77
+ console.log(` Status ${statusLabel}`)
78
+ console.log(` Total entries ${c.bold(String(entries.length))}`)
79
+ console.log(` Last 7 days ${in7Days === 0 ? c.yellow("0") : c.green(String(in7Days))} commit${in7Days === 1 ? "" : "s"}`)
80
+ console.log(` Last 30 days ${in30Days} commit${in30Days === 1 ? "" : "s"}`)
81
+
82
+ if (daysSince !== null) {
83
+ const col = daysSince >= STALLED_DAYS ? c.red : daysSince >= SLOW_DAYS ? c.yellow : c.green
84
+ console.log(` Last commit ${col(`${daysSince} day${daysSince === 1 ? "" : "s"} ago`)} ${c.dim(newest.timestamp.slice(0, 10))}`)
85
+ }
86
+
87
+ console.log(` Pending ${proposals.length} proposal${proposals.length === 1 ? "" : "s"} awaiting ${c.bold("quorum commit")}`)
88
+
89
+ // Weekly sparkline (last 8 weeks)
90
+ if (entries.length > 0) {
91
+ const weeks = new Map()
92
+ for (const e of entries) {
93
+ if (!e.timestamp) continue
94
+ const k = weekKey(e.timestamp)
95
+ weeks.set(k, (weeks.get(k) ?? 0) + 1)
96
+ }
97
+ const weekKeys = [...weeks.keys()].sort().reverse().slice(0, 8)
98
+ if (weekKeys.length > 0) {
99
+ console.log(`\n ${c.bold("Weekly commits")}`)
100
+ for (const wk of weekKeys) {
101
+ const n = weeks.get(wk)
102
+ const bar = "▪".repeat(n)
103
+ const col = n >= 3 ? c.green : n >= 1 ? c.cyan : c.dim
104
+ console.log(` ${c.dim("w/c")} ${wk} ${col(bar || "—")} ${col(String(n))}`)
105
+ }
106
+ }
107
+ }
108
+
109
+ // Recent learnings
110
+ const recent = [...entries]
111
+ .filter(e => e.timestamp)
112
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp))
113
+ .slice(0, 7)
114
+ if (recent.length > 0) {
115
+ console.log(`\n ${c.bold("Recent learnings")}`)
116
+ for (const e of recent) {
117
+ const text = entryText(e).slice(0, 72)
118
+ const trail = entryText(e).length > 72 ? "…" : ""
119
+ const date = e.timestamp.slice(0, 10)
120
+ const col = e.status === "refuted" ? c.red : e.status === "open" ? c.yellow : c.dim
121
+ console.log(` ${c.dim(e.id.slice(0, 8))} ${text}${trail} ${col(date)}`)
122
+ }
123
+ }
124
+
125
+ // Actionable advice when not healthy
126
+ if (status === "stalled" || status === "slow" || status === "empty") {
127
+ console.log(`\n ${c.yellow("Action needed:")}`)
128
+ if (proposals.length > 0) {
129
+ console.log(` ${proposals.length} proposal${proposals.length === 1 ? " is" : "s are"} staged and ready to commit.`)
130
+ console.log(` Run ${c.bold("quorum commit --list")} to review them.`)
131
+ } else {
132
+ console.log(` No proposals are staged.`)
133
+ console.log(` At the end of every session, create proposals for significant decisions.`)
134
+ console.log(` ${c.dim("See CLAUDE.md for the proposal format and session protocol.")}`)
135
+ }
136
+ }
137
+
138
+ console.log("")
139
+ }