@balpal4495/quorum 0.4.2 → 1.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.
- package/CLAUDE.md +102 -42
- package/README.md +226 -176
- package/SETUP.md +14 -4
- package/bin/commands/advisor.js +301 -0
- package/bin/commands/commit.js +42 -52
- package/bin/commands/evolve.js +285 -0
- package/bin/commands/growth.js +139 -0
- package/bin/commands/sentinel.js +1 -1
- package/bin/quorum.js +28 -0
- package/bin/shared/llm.js +228 -0
- package/modules/AGENTS.md +8 -0
- package/modules/CLAUDE.md +8 -2
- package/modules/README.md +72 -13
- package/modules/advisor/ask.ts +87 -0
- package/modules/advisor/index.ts +2 -0
- package/modules/advisor/prompt.ts +50 -0
- package/modules/advisor/types.ts +26 -0
- package/modules/setup.ts +15 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/bin/commands/sentinel.js
CHANGED
package/bin/quorum.js
CHANGED
|
@@ -20,13 +20,23 @@ function help() {
|
|
|
20
20
|
${c.bold("quorum")} ${c.dim(`v${PKG_VERSION}`)} — portable reasoning layer for agentic codebases
|
|
21
21
|
|
|
22
22
|
${c.bold("Usage:")}
|
|
23
|
+
${c.cyan("quorum advisor")} ${c.dim('"question"')} Ask a plain-language question (uses LLM)
|
|
24
|
+
${c.cyan("quorum advisor query")} ${c.dim('"topic"')} Search Chronicle entries (no LLM)
|
|
25
|
+
${c.cyan("quorum advisor brief")} High-level Chronicle summary (no LLM)
|
|
23
26
|
${c.cyan("quorum init")} Scaffold Quorum into a project
|
|
24
27
|
${c.cyan("quorum status")} Show Chronicle health and pending proposals
|
|
25
28
|
${c.cyan("quorum check")} --outcome <x> --design <y> Preflight + risk (no LLM)
|
|
26
29
|
${c.cyan("quorum commit")} <id> Approve and index a Chronicle proposal
|
|
27
30
|
${c.cyan("quorum sentinel")} [coverage] Chronicle coverage of source files
|
|
31
|
+
${c.cyan("quorum growth")} Chronicle learning health and growth rate
|
|
32
|
+
${c.cyan("quorum evolve")} Consolidate and improve Chronicle entries (uses LLM)
|
|
28
33
|
${c.cyan("quorum --version")} Print version
|
|
29
34
|
|
|
35
|
+
${c.bold("quorum advisor")} subcommands:
|
|
36
|
+
ask ${c.dim('"question"')} Ask with LLM synthesis + validation loop
|
|
37
|
+
query ${c.dim('"topic"')} Chronicle lookup (no LLM, instant)
|
|
38
|
+
brief Chronicle summary (no LLM, instant)
|
|
39
|
+
|
|
30
40
|
${c.bold("quorum check")} flags:
|
|
31
41
|
--outcome -o What you want to achieve
|
|
32
42
|
--design -d How you plan to do it
|
|
@@ -62,6 +72,12 @@ async function cli() {
|
|
|
62
72
|
|
|
63
73
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
64
74
|
|
|
75
|
+
if (command === "advisor") {
|
|
76
|
+
const { run } = await import(path.join(__dirname, "commands/advisor.js"))
|
|
77
|
+
await run(rest)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
if (command === "init") {
|
|
66
82
|
const { run } = await import(path.join(__dirname, "commands/init.js"))
|
|
67
83
|
await run(PKG_VERSION)
|
|
@@ -92,6 +108,18 @@ async function cli() {
|
|
|
92
108
|
return
|
|
93
109
|
}
|
|
94
110
|
|
|
111
|
+
if (command === "growth") {
|
|
112
|
+
const { run } = await import(path.join(__dirname, "commands/growth.js"))
|
|
113
|
+
await run(rest)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (command === "evolve") {
|
|
118
|
+
const { run } = await import(path.join(__dirname, "commands/evolve.js"))
|
|
119
|
+
await run(rest)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
95
123
|
console.error(`${c.red(`Unknown command: ${command}`)}`)
|
|
96
124
|
console.error(`Run ${c.bold("quorum help")} for usage.`)
|
|
97
125
|
process.exit(1)
|