@balpal4495/quorum 3.3.3 → 3.4.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.
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Quorum MCP tool definitions — pure logic, no HTTP.
3
+ * Shared between the MCP JSON-RPC handler and the REST API used by the UI.
4
+ *
5
+ * Tool naming follows Keep's pattern: all tools share a consistent prefix.
6
+ * Tools marked [TODO] are stubs — LLM-powered tools are out of scope for the
7
+ * current stateless HTTP server and require a future quorum serve --llm design.
8
+ */
9
+ import { promises as fs } from "fs"
10
+ import path from "path"
11
+ import { fileURLToPath } from "url"
12
+ import { randomUUID } from "crypto"
13
+ import { findChronicleDir, readCommitted, readProposals, updateSummary } from "../shared/chronicle.js"
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
16
+
17
+ // ── BM25-lite search ──────────────────────────────────────────────────────────
18
+
19
+ function tokenize(text) {
20
+ return text.toLowerCase().split(/\W+/).filter(t => t.length > 2)
21
+ }
22
+
23
+ function scoreEntry(query, entry) {
24
+ const qTokens = new Set(tokenize(query))
25
+ const text = [
26
+ entry.key_insight ?? "",
27
+ entry.decision ?? "",
28
+ entry.topic ?? "",
29
+ ...(entry.affected_areas ?? []),
30
+ ...(entry.scope ?? []),
31
+ ].join(" ")
32
+ const eTokens = tokenize(text)
33
+ const overlap = eTokens.filter(t => qTokens.has(t)).length
34
+ return overlap / Math.sqrt(qTokens.size * eTokens.length + 1)
35
+ }
36
+
37
+ export function findRelevant(entries, query, limit = 8) {
38
+ return entries
39
+ .map(e => ({ entry: e, score: scoreEntry(query, e) }))
40
+ .filter(({ score }) => score > 0)
41
+ .sort((a, b) => b.score - a.score)
42
+ .slice(0, limit)
43
+ .map(({ entry }) => entry)
44
+ }
45
+
46
+ // ── File-tree coverage ────────────────────────────────────────────────────────
47
+
48
+ const IGNORED_DIRS = new Set(["node_modules", "dist", ".git", ".chronicle", "coverage", "__tests__"])
49
+ const TEST_SUFFIXES = [".test.ts", ".spec.ts", ".test.js", ".spec.js"]
50
+ const EXTENSIONS = [".ts", ".js"]
51
+
52
+ async function walkFiles(dir) {
53
+ const results = []
54
+ async function recurse(current) {
55
+ let entries
56
+ try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { return }
57
+ for (const entry of entries) {
58
+ if (entry.isDirectory()) {
59
+ if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
60
+ await recurse(path.join(current, entry.name))
61
+ }
62
+ } else if (EXTENSIONS.some(ext => entry.name.endsWith(ext))) {
63
+ if (TEST_SUFFIXES.some(s => entry.name.endsWith(s))) continue
64
+ results.push(path.join(current, entry.name))
65
+ }
66
+ }
67
+ }
68
+ await recurse(dir)
69
+ return results
70
+ }
71
+
72
+ function isCovered(relativePath, entries) {
73
+ const normalised = relativePath.replace(/\\/g, "/")
74
+ const entryIds = []
75
+ for (const entry of entries) {
76
+ const hits = (entry.affected_areas ?? []).some(area => {
77
+ const normArea = area.replace(/\\/g, "/")
78
+ return normalised.includes(normArea) || normArea.includes(normalised)
79
+ })
80
+ if (hits) entryIds.push(entry.id)
81
+ }
82
+ return { covered: entryIds.length > 0, entryIds }
83
+ }
84
+
85
+ // ── Tool functions ────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Resolve project root: explicit arg > cwd.
89
+ * Returns { projectRoot, chronicleDir } or throws if no .chronicle found.
90
+ */
91
+ async function resolve(projectRoot) {
92
+ const root = projectRoot ?? process.cwd()
93
+ const chronicleDir = await findChronicleDir(root)
94
+ if (!chronicleDir) throw new Error(`No .chronicle/ found from ${root}. Run quorum init first.`)
95
+ return { projectRoot: root, chronicleDir }
96
+ }
97
+
98
+ export async function toolQuery({ topic, projectRoot } = {}) {
99
+ if (!topic) throw new Error("topic is required")
100
+ const { chronicleDir } = await resolve(projectRoot)
101
+ const entries = await readCommitted(chronicleDir)
102
+ const results = findRelevant(entries, topic, 8)
103
+ return { query: topic, count: results.length, entries: results }
104
+ }
105
+
106
+ export async function toolBrief({ projectRoot } = {}) {
107
+ const { chronicleDir } = await resolve(projectRoot)
108
+ const entries = await readCommitted(chronicleDir)
109
+ const byStatus = { validated: 0, open: 0, refuted: 0, other: 0 }
110
+ for (const e of entries) {
111
+ const k = e.status === "validated" || e.status === "open" || e.status === "refuted"
112
+ ? e.status : "other"
113
+ byStatus[k]++
114
+ }
115
+ return {
116
+ total: entries.length,
117
+ byStatus,
118
+ entries: entries.slice(0, 50).map(e => ({
119
+ id: (e.id ?? "").slice(0, 8),
120
+ topic: e.topic,
121
+ decision: e.decision ?? e.key_insight,
122
+ status: e.status,
123
+ confidence: e.confidence,
124
+ affected_areas: e.affected_areas,
125
+ timestamp: e.timestamp,
126
+ })),
127
+ }
128
+ }
129
+
130
+ export async function toolStage({ entry, projectRoot } = {}) {
131
+ if (!entry) throw new Error("entry object is required")
132
+ const required = ["topic", "decision"]
133
+ for (const k of required) {
134
+ if (!entry[k]) throw new Error(`entry.${k} is required`)
135
+ }
136
+ const { chronicleDir } = await resolve(projectRoot)
137
+ const proposalId = randomUUID()
138
+ const proposal = {
139
+ schema_version: 2,
140
+ topic: entry.topic,
141
+ decision: entry.decision,
142
+ key_insight: entry.key_insight ?? entry.decision,
143
+ affected_areas: entry.affected_areas ?? [],
144
+ scope: entry.scope ?? [],
145
+ alternatives_considered: entry.alternatives_considered ?? [],
146
+ rejected_reason: entry.rejected_reason ?? [],
147
+ status: entry.status ?? "open",
148
+ confidence: entry.confidence ?? 0.7,
149
+ source_module: entry.source_module ?? "mcp",
150
+ evidence_cited: entry.evidence_cited ?? [],
151
+ work_ref: entry.work_ref ?? null,
152
+ }
153
+ const proposalPath = path.join(chronicleDir, "proposals", `${proposalId}.json`)
154
+ await fs.mkdir(path.join(chronicleDir, "proposals"), { recursive: true })
155
+ await fs.writeFile(proposalPath, JSON.stringify(proposal, null, 2), "utf8")
156
+ return { proposalId, topic: proposal.topic }
157
+ }
158
+
159
+ export async function toolPending({ projectRoot } = {}) {
160
+ const { chronicleDir } = await resolve(projectRoot)
161
+ const proposals = await readProposals(chronicleDir)
162
+ return {
163
+ count: proposals.length,
164
+ proposals: proposals.map(p => ({
165
+ id: p.proposalId,
166
+ topic: p.topic,
167
+ decision: p.decision ?? p.key_insight,
168
+ status: p.status,
169
+ confidence: p.confidence,
170
+ affected_areas: p.affected_areas,
171
+ })),
172
+ }
173
+ }
174
+
175
+ export async function toolCoverage({ projectRoot } = {}) {
176
+ const { projectRoot: root, chronicleDir } = await resolve(projectRoot)
177
+ const [entries, files] = await Promise.all([
178
+ readCommitted(chronicleDir),
179
+ walkFiles(root),
180
+ ])
181
+
182
+ const coverageByFile = files.map(absolute => {
183
+ const relative = path.relative(root, absolute).replace(/\\/g, "/")
184
+ const { covered, entryIds } = isCovered(relative, entries)
185
+ return { file: relative, covered, entryIds }
186
+ })
187
+
188
+ const coveredFiles = coverageByFile.filter(f => f.covered)
189
+ const percentage = files.length === 0 ? 0
190
+ : Math.round((coveredFiles.length / files.length) * 100)
191
+
192
+ return { percentage, totalFiles: files.length, coveredFiles: coveredFiles.length, coverageByFile }
193
+ }
194
+
195
+ export async function toolGrowth({ projectRoot } = {}) {
196
+ const { chronicleDir } = await resolve(projectRoot)
197
+ const [entries, proposals] = await Promise.all([
198
+ readCommitted(chronicleDir),
199
+ readProposals(chronicleDir),
200
+ ])
201
+
202
+ const byStatus = { validated: 0, open: 0, refuted: 0, other: 0 }
203
+ let totalConfidence = 0
204
+ for (const e of entries) {
205
+ const k = e.status === "validated" || e.status === "open" || e.status === "refuted"
206
+ ? e.status : "other"
207
+ byStatus[k]++
208
+ totalConfidence += e.confidence ?? 0
209
+ }
210
+
211
+ const avgConfidence = entries.length > 0
212
+ ? Math.round((totalConfidence / entries.length) * 100) / 100
213
+ : 0
214
+
215
+ // Rough health score: reward validated entries, penalise refuted + pending
216
+ const health = entries.length === 0 ? 0 : Math.max(0, Math.min(100, Math.round(
217
+ (byStatus.validated / entries.length) * 100
218
+ - (byStatus.refuted / entries.length) * 20
219
+ - (proposals.length / Math.max(1, entries.length)) * 10
220
+ )))
221
+
222
+ return {
223
+ health,
224
+ entries: { total: entries.length, byStatus, avgConfidence },
225
+ proposals: { pending: proposals.length },
226
+ hint: health >= 80 ? "Chronicle is healthy."
227
+ : health >= 50 ? "Chronicle is growing — consider committing pending proposals."
228
+ : "Chronicle needs attention — validate open entries and reduce pending proposals.",
229
+ }
230
+ }
231
+
232
+ // Path to the modules README for quorum_help
233
+ const HELP_PATH = path.join(__dirname, "../../modules/README.md")
234
+
235
+ export async function toolHelp({ topic } = {}) {
236
+ let readme
237
+ try {
238
+ readme = await fs.readFile(HELP_PATH, "utf8")
239
+ } catch {
240
+ return { topic, content: "Quorum help text not found. See https://github.com/balpal4495/Quorum for documentation." }
241
+ }
242
+
243
+ if (!topic || topic === "index") {
244
+ // Return the first 100 lines as an index
245
+ const lines = readme.split("\n").slice(0, 100).join("\n")
246
+ return { topic: "index", content: lines }
247
+ }
248
+
249
+ // Find the heading that best matches the topic and return its section
250
+ const lines = readme.split("\n")
251
+ const needle = topic.toLowerCase()
252
+ const start = lines.findIndex(l => l.startsWith("#") && l.toLowerCase().includes(needle))
253
+
254
+ if (start === -1) {
255
+ return { topic, content: `No section found for "${topic}". Try quorum_help with topic="index" to browse available topics.` }
256
+ }
257
+
258
+ // Collect lines until the next same-level heading
259
+ const level = (lines[start].match(/^#+/) ?? [""])[0].length
260
+ const end = lines.findIndex((l, i) => i > start && l.startsWith("#".repeat(level)) && l.length > level)
261
+ const section = lines.slice(start, end === -1 ? start + 60 : end).join("\n")
262
+
263
+ return { topic, content: section }
264
+ }
265
+
266
+ // ── [TODO] LLM-powered placeholders ──────────────────────────────────────────
267
+ // These tools require a live LLM provider wired into quorum serve (--llm flag).
268
+ // They are registered so AI clients can discover them, but return a clear
269
+ // "not yet available" message rather than silently failing.
270
+
271
+ const TODO_MESSAGE = (name) =>
272
+ `${name} requires an LLM provider. This is planned — run 'quorum ${name.replace("quorum_", "")}' from the CLI for now, or watch for a future 'quorum serve --llm' flag.`
273
+
274
+ export async function toolAdvisor({ question } = {}) {
275
+ if (!question) throw new Error("question is required")
276
+ return { status: "todo", message: TODO_MESSAGE("quorum_advisor") }
277
+ }
278
+
279
+ export async function toolCheck({ outcome, design } = {}) {
280
+ if (!outcome && !design) throw new Error("outcome or design is required")
281
+ return { status: "todo", message: TODO_MESSAGE("quorum_check") }
282
+ }
283
+
284
+ export async function toolCompass({ subcommand } = {}) {
285
+ return { status: "todo", message: TODO_MESSAGE("quorum_compass") }
286
+ }
287
+
288
+ // ── Proposal commit (human-gate — UI only, never an MCP AI tool) ──────────────
289
+
290
+ export async function commitProposal(proposalId, chronicleDir) {
291
+ const proposalsDir = path.join(chronicleDir, "proposals")
292
+ const files = await fs.readdir(proposalsDir).catch(() => [])
293
+ const match = files.find(f => f === `${proposalId}.json` || f.startsWith(proposalId))
294
+ if (!match) throw new Error(`Proposal not found: ${proposalId}`)
295
+
296
+ const proposalPath = path.join(proposalsDir, match)
297
+ const raw = await fs.readFile(proposalPath, "utf8")
298
+ const partial = JSON.parse(raw)
299
+
300
+ const entry = { ...partial, id: randomUUID(), timestamp: new Date().toISOString() }
301
+ const committedPath = path.join(chronicleDir, "committed", `${entry.id}.json`)
302
+ await fs.mkdir(path.join(chronicleDir, "committed"), { recursive: true })
303
+ await fs.writeFile(committedPath, JSON.stringify(entry, null, 2), "utf8")
304
+ await fs.unlink(proposalPath)
305
+ await updateSummary(chronicleDir).catch(() => {})
306
+
307
+ return { id: entry.id, topic: entry.topic }
308
+ }
309
+
310
+ export async function deleteProposal(proposalId, chronicleDir) {
311
+ const proposalsDir = path.join(chronicleDir, "proposals")
312
+ const files = await fs.readdir(proposalsDir).catch(() => [])
313
+ const match = files.find(f => f === `${proposalId}.json` || f.startsWith(proposalId))
314
+ if (!match) throw new Error(`Proposal not found: ${proposalId}`)
315
+ await fs.unlink(path.join(proposalsDir, match))
316
+ return { deleted: match.replace(".json", "") }
317
+ }
318
+
319
+ // ── MCP tool registry ─────────────────────────────────────────────────────────
320
+
321
+ export const MCP_TOOLS = [
322
+ // ── Core: Chronicle (no LLM) ──
323
+ {
324
+ name: "quorum_query",
325
+ description: "Search Chronicle entries by topic using BM25. Returns the most relevant prior decisions and findings. Always call this before proposing a design.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ topic: { type: "string", description: "Topic or keywords to search for" },
330
+ projectRoot: { type: "string", description: "Project root directory (defaults to server cwd)" },
331
+ },
332
+ required: ["topic"],
333
+ },
334
+ fn: toolQuery,
335
+ },
336
+ {
337
+ name: "quorum_brief",
338
+ description: "Return a full summary of all Chronicle entries — decisions, statuses, and confidence scores.",
339
+ inputSchema: {
340
+ type: "object",
341
+ properties: {
342
+ projectRoot: { type: "string" },
343
+ },
344
+ },
345
+ fn: toolBrief,
346
+ },
347
+ {
348
+ name: "quorum_stage",
349
+ description: "Stage a new Chronicle entry for human review. Returns a proposalId. A human must approve it via the Quorum UI or 'quorum commit <id>'.",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ entry: {
354
+ type: "object",
355
+ description: "Chronicle entry to propose",
356
+ properties: {
357
+ topic: { type: "string" },
358
+ decision: { type: "string" },
359
+ key_insight: { type: "string" },
360
+ affected_areas: { type: "array", items: { type: "string" } },
361
+ scope: { type: "array", items: { type: "string" } },
362
+ status: { type: "string", enum: ["validated", "open", "refuted"] },
363
+ confidence: { type: "number", minimum: 0, maximum: 1 },
364
+ alternatives_considered: { type: "array", items: { type: "string" } },
365
+ rejected_reason: { type: "array", items: { type: "string" } },
366
+ },
367
+ required: ["topic", "decision"],
368
+ },
369
+ projectRoot: { type: "string" },
370
+ },
371
+ required: ["entry"],
372
+ },
373
+ fn: toolStage,
374
+ },
375
+ {
376
+ name: "quorum_pending",
377
+ description: "List Chronicle proposals awaiting human approval.",
378
+ inputSchema: {
379
+ type: "object",
380
+ properties: { projectRoot: { type: "string" } },
381
+ },
382
+ fn: toolPending,
383
+ },
384
+ // ── Sentinel ──
385
+ {
386
+ name: "quorum_coverage",
387
+ description: "Return Chronicle coverage for source files — which files have Chronicle entries referencing them and which are undocumented.",
388
+ inputSchema: {
389
+ type: "object",
390
+ properties: { projectRoot: { type: "string" } },
391
+ },
392
+ fn: toolCoverage,
393
+ },
394
+ // ── Memory health ──
395
+ {
396
+ name: "quorum_growth",
397
+ description: "Report Chronicle memory health — entry counts by status, average confidence, pending proposals, and a health score with guidance.",
398
+ inputSchema: {
399
+ type: "object",
400
+ properties: { projectRoot: { type: "string" } },
401
+ },
402
+ fn: toolGrowth,
403
+ },
404
+ // ── Documentation ──
405
+ {
406
+ name: "quorum_help",
407
+ description: "Browse Quorum documentation. Call with topic='index' to see all available sections, or topic='<section>' to read a specific section.",
408
+ inputSchema: {
409
+ type: "object",
410
+ properties: {
411
+ topic: { type: "string", description: "Documentation topic, or 'index' for the full list" },
412
+ },
413
+ },
414
+ fn: toolHelp,
415
+ },
416
+ // ── [TODO] LLM-powered tools ──
417
+ {
418
+ name: "quorum_advisor",
419
+ description: "[TODO] Ask a plain-language question answered from Chronicle using an LLM. Requires 'quorum serve --llm'. Use 'quorum advisor' CLI for now.",
420
+ inputSchema: {
421
+ type: "object",
422
+ properties: {
423
+ question: { type: "string", description: "Plain-language question to answer from Chronicle" },
424
+ },
425
+ required: ["question"],
426
+ },
427
+ fn: toolAdvisor,
428
+ },
429
+ {
430
+ name: "quorum_check",
431
+ description: "[TODO] Run instant risk triage on a design against Chronicle evidence. Requires 'quorum serve --llm'. Use 'quorum check' CLI for now.",
432
+ inputSchema: {
433
+ type: "object",
434
+ properties: {
435
+ outcome: { type: "string", description: "Desired outcome" },
436
+ design: { type: "string", description: "Proposed design or approach" },
437
+ },
438
+ },
439
+ fn: toolCheck,
440
+ },
441
+ {
442
+ name: "quorum_compass",
443
+ description: "[TODO] Product-direction synthesis — behaviours, pathways, bets, idea scoring. Requires 'quorum serve --llm'. Use 'quorum compass' CLI for now.",
444
+ inputSchema: {
445
+ type: "object",
446
+ properties: {
447
+ subcommand: { type: "string", description: "One of: brief, map, pathways, bets, score, opportunities" },
448
+ goal: { type: "string", description: "Goal for pathways subcommand" },
449
+ idea: { type: "string", description: "Idea to score for score subcommand" },
450
+ },
451
+ },
452
+ fn: toolCompass,
453
+ },
454
+ ]
package/bin/quorum.js CHANGED
@@ -31,7 +31,12 @@ ${c.bold("Usage:")}
31
31
  ${c.cyan("quorum growth")} Chronicle learning health and growth rate
32
32
  ${c.cyan("quorum evolve")} Consolidate and improve Chronicle entries (uses LLM)
33
33
  ${c.cyan("quorum compass")} <subcommand> Product-direction synthesis (brief, map, pathways, bets, score)
34
+ ${c.cyan("quorum serve")} Start web UI + MCP server (default port 3000)
34
35
  ${c.cyan("quorum sync")} Refresh Quorum instruction blocks after npm update
36
+ ${c.cyan("quorum ingest")} <paths...> Ingest files/folders as low-trust evidence
37
+ ${c.cyan("quorum ingest-git")} [--since P90D] Ingest git history as low-trust evidence
38
+ ${c.cyan("quorum ingest-url")} <url...> Ingest URLs as low-trust evidence
39
+ ${c.cyan("quorum bootstrap")} --from-git Cold-start Chronicle from git history
35
40
  ${c.cyan("quorum migrate-v2")} Migrate from v1 vendored quorum/modules/ to npm package
36
41
  ${c.cyan("quorum --version")} Print version
37
42
 
@@ -69,6 +74,22 @@ ${c.bold("quorum compass")} subcommands:
69
74
  propose --from-last Stage a Chronicle entry from last artifact
70
75
  outcome --entry-id X --result Y Record a bet/pathway outcome
71
76
 
77
+ ${c.bold("quorum ingest")} flags:
78
+ --recurse -r Walk directories recursively
79
+ --propose Also stage evidence as Chronicle proposals
80
+
81
+ ${c.bold("quorum ingest-git")} flags:
82
+ --since P90D ISO 8601 duration: P30D, P6M, P1Y [default: P90D]
83
+ --propose Also stage commits as Chronicle proposals
84
+
85
+ ${c.bold("quorum ingest-url")} flags:
86
+ --propose Also stage URLs as Chronicle proposals
87
+
88
+ ${c.bold("quorum bootstrap")} flags:
89
+ --from-git Bootstrap from git commit history
90
+ --since P90D ISO 8601 duration [default: P90D]
91
+ --propose Also stage evidence as Chronicle proposals
92
+
72
93
  ${c.bold("Exit codes")} (quorum check):
73
94
  0 low / medium risk
74
95
  1 high risk
@@ -155,6 +176,36 @@ async function cli() {
155
176
  return
156
177
  }
157
178
 
179
+ if (command === "serve") {
180
+ const { run } = await import(path.join(__dirname, "commands/serve.js"))
181
+ await run(rest)
182
+ return
183
+ }
184
+
185
+ if (command === "ingest") {
186
+ const { run } = await import(path.join(__dirname, "commands/ingest.js"))
187
+ await run(rest)
188
+ return
189
+ }
190
+
191
+ if (command === "ingest-git") {
192
+ const { run } = await import(path.join(__dirname, "commands/ingest-git.js"))
193
+ await run(rest)
194
+ return
195
+ }
196
+
197
+ if (command === "ingest-url") {
198
+ const { run } = await import(path.join(__dirname, "commands/ingest-url.js"))
199
+ await run(rest)
200
+ return
201
+ }
202
+
203
+ if (command === "bootstrap") {
204
+ const { run } = await import(path.join(__dirname, "commands/bootstrap.js"))
205
+ await run(rest)
206
+ return
207
+ }
208
+
158
209
  console.error(`${c.red(`Unknown command: ${command}`)}`)
159
210
  console.error(`Run ${c.bold("quorum help")} for usage.`)
160
211
  process.exit(1)
@@ -57,6 +57,46 @@ export async function readCommitted(chronicleDir) {
57
57
  return results.sort((a, b) => b.timestamp?.localeCompare(a.timestamp ?? "") ?? 0)
58
58
  }
59
59
 
60
+ /**
61
+ * Read all evidence records from .chronicle/evidence/.
62
+ * Returns array sorted newest-first by ingested_at.
63
+ */
64
+ export async function readEvidence(chronicleDir) {
65
+ const dir = path.join(chronicleDir, "evidence")
66
+ let files
67
+ try { files = await fs.readdir(dir) } catch { return [] }
68
+ const results = []
69
+ for (const file of files) {
70
+ if (!file.endsWith(".json")) continue
71
+ try {
72
+ const raw = await fs.readFile(path.join(dir, file), "utf8")
73
+ results.push(JSON.parse(raw))
74
+ } catch { /* skip malformed */ }
75
+ }
76
+ return results.sort((a, b) =>
77
+ (b.ingested_at ?? "").localeCompare(a.ingested_at ?? ""))
78
+ }
79
+
80
+ /**
81
+ * Read all source records from .chronicle/sources/.
82
+ * Returns array sorted newest-first by ingested_at.
83
+ */
84
+ export async function readSources(chronicleDir) {
85
+ const dir = path.join(chronicleDir, "sources")
86
+ let files
87
+ try { files = await fs.readdir(dir) } catch { return [] }
88
+ const results = []
89
+ for (const file of files) {
90
+ if (!file.endsWith(".json")) continue
91
+ try {
92
+ const raw = await fs.readFile(path.join(dir, file), "utf8")
93
+ results.push(JSON.parse(raw))
94
+ } catch { /* skip malformed */ }
95
+ }
96
+ return results.sort((a, b) =>
97
+ (b.ingested_at ?? "").localeCompare(a.ingested_at ?? ""))
98
+ }
99
+
60
100
  /** entryText mirrors the shared/types.ts entryText helper. */
61
101
  export function entryText(entry) {
62
102
  return `${entry.key_insight}. ${entry.decision ?? ""}`.trim().replace(/\.\.$/, ".")