@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,525 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest"
2
+ import { promises as fs } from "fs"
3
+ import path from "path"
4
+ import os from "os"
5
+ import { randomUUID } from "crypto"
6
+
7
+ import {
8
+ findRelevant,
9
+ toolQuery,
10
+ toolBrief,
11
+ toolStage,
12
+ toolPending,
13
+ toolCoverage,
14
+ toolGrowth,
15
+ toolHelp,
16
+ toolAdvisor,
17
+ toolCheck,
18
+ toolCompass,
19
+ commitProposal,
20
+ deleteProposal,
21
+ } from "../mcp/tools.js"
22
+
23
+ // ── helpers ───────────────────────────────────────────────────────────────────
24
+
25
+ async function makeTmp() {
26
+ return fs.mkdtemp(path.join(os.tmpdir(), "quorum-mcp-tools-"))
27
+ }
28
+
29
+ async function rmrf(dir) {
30
+ await fs.rm(dir, { recursive: true, force: true })
31
+ }
32
+
33
+ /** Create a minimal chronicle layout under `root` with the given entries + proposals. */
34
+ async function makeChronicle(root, { entries = [], proposals = [] } = {}) {
35
+ const dir = path.join(root, ".chronicle")
36
+ const committedDir = path.join(dir, "committed")
37
+ const proposalsDir = path.join(dir, "proposals")
38
+ await fs.mkdir(committedDir, { recursive: true })
39
+ await fs.mkdir(proposalsDir, { recursive: true })
40
+
41
+ for (const entry of entries) {
42
+ const id = entry.id ?? randomUUID()
43
+ await fs.writeFile(
44
+ path.join(committedDir, `${id}.json`),
45
+ JSON.stringify({ schema_version: 2, id, timestamp: new Date().toISOString(), ...entry }),
46
+ "utf8"
47
+ )
48
+ }
49
+
50
+ const proposalIds = []
51
+ for (const proposal of proposals) {
52
+ const id = proposal.proposalId ?? randomUUID()
53
+ proposalIds.push(id)
54
+ await fs.writeFile(
55
+ path.join(proposalsDir, `${id}.json`),
56
+ JSON.stringify({ schema_version: 2, ...proposal }),
57
+ "utf8"
58
+ )
59
+ }
60
+
61
+ return { dir, proposalIds }
62
+ }
63
+
64
+ // ── findRelevant ──────────────────────────────────────────────────────────────
65
+
66
+ describe("findRelevant", () => {
67
+ const entries = [
68
+ { topic: "retry logic", key_insight: "exponential backoff is preferred", decision: "use exponential backoff for retries", affected_areas: ["modules/oracle"] },
69
+ { topic: "LLM validation", key_insight: "jury validates every design", decision: "jury scores across four dimensions", affected_areas: ["modules/jury"] },
70
+ { topic: "CLI structure", key_insight: "each command lives in its own file", decision: "split commands into bin/commands/", affected_areas: ["bin/commands"] },
71
+ { topic: "unrelated stuff", key_insight: "zzz nothing here", decision: "no overlap", affected_areas: [] },
72
+ ]
73
+
74
+ it("returns entries matching the query, highest score first", () => {
75
+ const results = findRelevant(entries, "retry exponential backoff")
76
+ expect(results[0].topic).toBe("retry logic")
77
+ })
78
+
79
+ it("filters out entries with zero overlap", () => {
80
+ const results = findRelevant(entries, "retry")
81
+ expect(results.every(e => e.topic !== "unrelated stuff")).toBe(true)
82
+ })
83
+
84
+ it("respects the limit parameter", () => {
85
+ const results = findRelevant(entries, "design modules", 2)
86
+ expect(results.length).toBeLessThanOrEqual(2)
87
+ })
88
+
89
+ it("returns empty array when nothing matches", () => {
90
+ const results = findRelevant(entries, "xyzzy nonexistent token")
91
+ expect(results).toHaveLength(0)
92
+ })
93
+
94
+ it("matches tokens in affected_areas", () => {
95
+ const results = findRelevant(entries, "oracle")
96
+ expect(results.some(e => e.topic === "retry logic")).toBe(true)
97
+ })
98
+ })
99
+
100
+ // ── toolChronicleQuery ────────────────────────────────────────────────────────
101
+
102
+ describe("toolQuery", () => {
103
+ let tmpDir
104
+
105
+ beforeEach(async () => { tmpDir = await makeTmp() })
106
+ afterEach(async () => { await rmrf(tmpDir) })
107
+
108
+ it("throws when topic is missing", async () => {
109
+ await makeChronicle(tmpDir)
110
+ await expect(toolQuery({ projectRoot: tmpDir })).rejects.toThrow("topic is required")
111
+ })
112
+
113
+ it("throws when no .chronicle directory exists", async () => {
114
+ await expect(
115
+ toolQuery({ topic: "anything", projectRoot: tmpDir })
116
+ ).rejects.toThrow("No .chronicle/ found")
117
+ })
118
+
119
+ it("returns matching entries", async () => {
120
+ await makeChronicle(tmpDir, {
121
+ entries: [
122
+ { topic: "caching strategy", decision: "use Redis for caching", key_insight: "Redis caching chosen" },
123
+ { topic: "auth flow", decision: "JWT tokens with RS256", key_insight: "JWT RS256" },
124
+ ],
125
+ })
126
+
127
+ const result = await toolQuery({ topic: "Redis caching", projectRoot: tmpDir })
128
+
129
+ expect(result.query).toBe("Redis caching")
130
+ expect(result.entries.some(e => e.topic === "caching strategy")).toBe(true)
131
+ expect(result.count).toBeGreaterThan(0)
132
+ })
133
+
134
+ it("returns count 0 when no entries match", async () => {
135
+ await makeChronicle(tmpDir, {
136
+ entries: [{ topic: "auth", decision: "JWT", key_insight: "JWT" }],
137
+ })
138
+ const result = await toolQuery({ topic: "xyzzy nonexistent", projectRoot: tmpDir })
139
+ expect(result.count).toBe(0)
140
+ expect(result.entries).toHaveLength(0)
141
+ })
142
+ })
143
+
144
+ // ── toolChronicleBrief ────────────────────────────────────────────────────────
145
+
146
+ describe("toolBrief", () => {
147
+ let tmpDir
148
+
149
+ beforeEach(async () => { tmpDir = await makeTmp() })
150
+ afterEach(async () => { await rmrf(tmpDir) })
151
+
152
+ it("returns total and byStatus counts", async () => {
153
+ await makeChronicle(tmpDir, {
154
+ entries: [
155
+ { topic: "a", decision: "d", status: "validated", confidence: 0.9 },
156
+ { topic: "b", decision: "d", status: "validated", confidence: 0.8 },
157
+ { topic: "c", decision: "d", status: "open", confidence: 0.5 },
158
+ { topic: "d", decision: "d", status: "refuted", confidence: 0.3 },
159
+ ],
160
+ })
161
+
162
+ const result = await toolBrief({ projectRoot: tmpDir })
163
+
164
+ expect(result.total).toBe(4)
165
+ expect(result.byStatus.validated).toBe(2)
166
+ expect(result.byStatus.open).toBe(1)
167
+ expect(result.byStatus.refuted).toBe(1)
168
+ })
169
+
170
+ it("returns empty result on empty chronicle", async () => {
171
+ await makeChronicle(tmpDir)
172
+ const result = await toolBrief({ projectRoot: tmpDir })
173
+ expect(result.total).toBe(0)
174
+ expect(result.entries).toHaveLength(0)
175
+ })
176
+
177
+ it("entry summaries include expected fields", async () => {
178
+ await makeChronicle(tmpDir, {
179
+ entries: [{ topic: "db indexing", decision: "add composite index", key_insight: "composite index", status: "validated", confidence: 0.95, affected_areas: ["db/"] }],
180
+ })
181
+
182
+ const result = await toolBrief({ projectRoot: tmpDir })
183
+ const entry = result.entries[0]
184
+
185
+ expect(entry).toHaveProperty("topic", "db indexing")
186
+ expect(entry).toHaveProperty("status", "validated")
187
+ expect(entry).toHaveProperty("confidence", 0.95)
188
+ expect(entry.id).toHaveLength(8)
189
+ })
190
+ })
191
+
192
+ // ── toolChroniclePropose ──────────────────────────────────────────────────────
193
+
194
+ describe("toolStage", () => {
195
+ let tmpDir
196
+
197
+ beforeEach(async () => { tmpDir = await makeTmp() })
198
+ afterEach(async () => { await rmrf(tmpDir) })
199
+
200
+ it("throws when entry is missing", async () => {
201
+ await makeChronicle(tmpDir)
202
+ await expect(toolStage({ projectRoot: tmpDir })).rejects.toThrow("entry object is required")
203
+ })
204
+
205
+ it("throws when topic is missing", async () => {
206
+ await makeChronicle(tmpDir)
207
+ await expect(
208
+ toolStage({ entry: { decision: "d" }, projectRoot: tmpDir })
209
+ ).rejects.toThrow("entry.topic is required")
210
+ })
211
+
212
+ it("writes a proposal file and returns a proposalId", async () => {
213
+ await makeChronicle(tmpDir)
214
+ const result = await toolStage({
215
+ entry: { topic: "new design", decision: "go with option A" },
216
+ projectRoot: tmpDir,
217
+ })
218
+
219
+ expect(result).toHaveProperty("proposalId")
220
+ expect(result).toHaveProperty("topic", "new design")
221
+
222
+ const proposalPath = path.join(tmpDir, ".chronicle", "proposals", `${result.proposalId}.json`)
223
+ const written = JSON.parse(await fs.readFile(proposalPath, "utf8"))
224
+ expect(written.topic).toBe("new design")
225
+ expect(written.decision).toBe("go with option A")
226
+ expect(written.schema_version).toBe(2)
227
+ })
228
+
229
+ it("applies defaults for optional fields", async () => {
230
+ await makeChronicle(tmpDir)
231
+ const { proposalId } = await toolStage({
232
+ entry: { topic: "minimal", decision: "keep it simple" },
233
+ projectRoot: tmpDir,
234
+ })
235
+
236
+ const written = JSON.parse(
237
+ await fs.readFile(path.join(tmpDir, ".chronicle", "proposals", `${proposalId}.json`), "utf8")
238
+ )
239
+ expect(written.status).toBe("open")
240
+ expect(written.confidence).toBe(0.7)
241
+ expect(written.source_module).toBe("mcp")
242
+ expect(Array.isArray(written.affected_areas)).toBe(true)
243
+ })
244
+ })
245
+
246
+ // ── toolChroniclePending ──────────────────────────────────────────────────────
247
+
248
+ describe("toolPending", () => {
249
+ let tmpDir
250
+
251
+ beforeEach(async () => { tmpDir = await makeTmp() })
252
+ afterEach(async () => { await rmrf(tmpDir) })
253
+
254
+ it("returns 0 proposals on empty proposals dir", async () => {
255
+ await makeChronicle(tmpDir)
256
+ const result = await toolPending({ projectRoot: tmpDir })
257
+ expect(result.count).toBe(0)
258
+ expect(result.proposals).toHaveLength(0)
259
+ })
260
+
261
+ it("lists pending proposals with summary fields", async () => {
262
+ await makeChronicle(tmpDir, {
263
+ proposals: [
264
+ { topic: "proposal A", decision: "do A", status: "open", confidence: 0.8, affected_areas: ["src/"] },
265
+ { topic: "proposal B", decision: "do B", status: "open", confidence: 0.6, affected_areas: [] },
266
+ ],
267
+ })
268
+
269
+ const result = await toolPending({ projectRoot: tmpDir })
270
+
271
+ expect(result.count).toBe(2)
272
+ expect(result.proposals.every(p => p.topic)).toBe(true)
273
+ expect(result.proposals.every(p => "confidence" in p)).toBe(true)
274
+ })
275
+ })
276
+
277
+ // ── toolSentinelCoverage ──────────────────────────────────────────────────────
278
+
279
+ describe("toolCoverage", () => {
280
+ let tmpDir
281
+
282
+ beforeEach(async () => { tmpDir = await makeTmp() })
283
+ afterEach(async () => { await rmrf(tmpDir) })
284
+
285
+ it("returns 0% when no source files exist", async () => {
286
+ await makeChronicle(tmpDir)
287
+ const result = await toolCoverage({ projectRoot: tmpDir })
288
+ expect(result.percentage).toBe(0)
289
+ expect(result.totalFiles).toBe(0)
290
+ })
291
+
292
+ it("marks a file as covered when it appears in affected_areas", async () => {
293
+ await fs.mkdir(path.join(tmpDir, "src"), { recursive: true })
294
+ await fs.writeFile(path.join(tmpDir, "src", "main.ts"), "export const x = 1", "utf8")
295
+
296
+ await makeChronicle(tmpDir, {
297
+ entries: [
298
+ { topic: "main module", decision: "entry point", key_insight: "entry", affected_areas: ["src/main.ts"] },
299
+ ],
300
+ })
301
+
302
+ const result = await toolCoverage({ projectRoot: tmpDir })
303
+ const mainFile = result.coverageByFile.find(f => f.file === "src/main.ts")
304
+
305
+ expect(mainFile).toBeDefined()
306
+ expect(mainFile.covered).toBe(true)
307
+ expect(mainFile.entryIds.length).toBeGreaterThan(0)
308
+ })
309
+
310
+ it("marks a file as uncovered when absent from all affected_areas", async () => {
311
+ await fs.mkdir(path.join(tmpDir, "src"), { recursive: true })
312
+ await fs.writeFile(path.join(tmpDir, "src", "forgotten.ts"), "export const y = 2", "utf8")
313
+ await makeChronicle(tmpDir)
314
+
315
+ const result = await toolCoverage({ projectRoot: tmpDir })
316
+ const file = result.coverageByFile.find(f => f.file === "src/forgotten.ts")
317
+
318
+ expect(file.covered).toBe(false)
319
+ expect(file.entryIds).toHaveLength(0)
320
+ })
321
+
322
+ it("excludes test files and ignored directories from coverage counts", async () => {
323
+ await fs.mkdir(path.join(tmpDir, "src", "__tests__"), { recursive: true })
324
+ await fs.writeFile(path.join(tmpDir, "src", "__tests__", "main.test.ts"), "", "utf8")
325
+ await fs.mkdir(path.join(tmpDir, "dist"), { recursive: true })
326
+ await fs.writeFile(path.join(tmpDir, "dist", "main.js"), "", "utf8")
327
+ await fs.mkdir(path.join(tmpDir, "src"), { recursive: true })
328
+ await fs.writeFile(path.join(tmpDir, "src", "util.ts"), "export const z = 3", "utf8")
329
+ await makeChronicle(tmpDir)
330
+
331
+ const result = await toolCoverage({ projectRoot: tmpDir })
332
+ const files = result.coverageByFile.map(f => f.file)
333
+
334
+ expect(files).not.toContain(path.join("src", "__tests__", "main.test.ts").replace(/\\/g, "/"))
335
+ expect(files).not.toContain(path.join("dist", "main.js").replace(/\\/g, "/"))
336
+ expect(files).toContain("src/util.ts")
337
+ })
338
+ })
339
+
340
+ // ── commitProposal ────────────────────────────────────────────────────────────
341
+
342
+ describe("commitProposal", () => {
343
+ let tmpDir
344
+
345
+ beforeEach(async () => { tmpDir = await makeTmp() })
346
+ afterEach(async () => { await rmrf(tmpDir) })
347
+
348
+ it("moves proposal from proposals/ to committed/ and returns id + topic", async () => {
349
+ const { dir, proposalIds } = await makeChronicle(tmpDir, {
350
+ proposals: [{ topic: "test topic", decision: "test decision" }],
351
+ })
352
+
353
+ const result = await commitProposal(proposalIds[0], dir)
354
+
355
+ expect(result).toHaveProperty("topic", "test topic")
356
+ expect(result).toHaveProperty("id")
357
+
358
+ // proposal file removed
359
+ const remaining = await fs.readdir(path.join(dir, "proposals"))
360
+ expect(remaining.filter(f => f !== ".gitkeep")).toHaveLength(0)
361
+
362
+ // committed file exists
363
+ const committed = await fs.readdir(path.join(dir, "committed"))
364
+ expect(committed.some(f => f.endsWith(".json"))).toBe(true)
365
+ })
366
+
367
+ it("throws when proposal not found", async () => {
368
+ const { dir } = await makeChronicle(tmpDir)
369
+ await expect(commitProposal("nonexistent-id", dir)).rejects.toThrow("Proposal not found")
370
+ })
371
+
372
+ it("committed entry has a fresh id and timestamp", async () => {
373
+ const { dir, proposalIds } = await makeChronicle(tmpDir, {
374
+ proposals: [{ topic: "timestamped", decision: "check this" }],
375
+ })
376
+
377
+ const { id } = await commitProposal(proposalIds[0], dir)
378
+
379
+ const files = await fs.readdir(path.join(dir, "committed"))
380
+ const committed = JSON.parse(
381
+ await fs.readFile(path.join(dir, "committed", files[0]), "utf8")
382
+ )
383
+
384
+ expect(committed.id).toBe(id)
385
+ expect(committed.timestamp).toBeTruthy()
386
+ expect(new Date(committed.timestamp).getFullYear()).toBeGreaterThanOrEqual(2024)
387
+ })
388
+ })
389
+
390
+ // ── deleteProposal ────────────────────────────────────────────────────────────
391
+
392
+ describe("deleteProposal", () => {
393
+ let tmpDir
394
+
395
+ beforeEach(async () => { tmpDir = await makeTmp() })
396
+ afterEach(async () => { await rmrf(tmpDir) })
397
+
398
+ it("deletes the proposal file and returns the id", async () => {
399
+ const { dir, proposalIds } = await makeChronicle(tmpDir, {
400
+ proposals: [{ topic: "to delete", decision: "reject" }],
401
+ })
402
+
403
+ const result = await deleteProposal(proposalIds[0], dir)
404
+ expect(result.deleted).toBe(proposalIds[0])
405
+
406
+ const remaining = await fs.readdir(path.join(dir, "proposals"))
407
+ expect(remaining.filter(f => f !== ".gitkeep")).toHaveLength(0)
408
+ })
409
+
410
+ it("throws when proposal does not exist", async () => {
411
+ const { dir } = await makeChronicle(tmpDir)
412
+ await expect(deleteProposal("does-not-exist", dir)).rejects.toThrow("Proposal not found")
413
+ })
414
+ })
415
+
416
+ // ── toolGrowth ────────────────────────────────────────────────────────────────
417
+
418
+ describe("toolGrowth", () => {
419
+ let tmpDir
420
+
421
+ beforeEach(async () => { tmpDir = await makeTmp() })
422
+ afterEach(async () => { await rmrf(tmpDir) })
423
+
424
+ it("returns health=0 on empty chronicle", async () => {
425
+ await makeChronicle(tmpDir)
426
+ const result = await toolGrowth({ projectRoot: tmpDir })
427
+ expect(result.health).toBe(0)
428
+ expect(result.entries.total).toBe(0)
429
+ expect(result.proposals.pending).toBe(0)
430
+ })
431
+
432
+ it("returns health 100 for all-validated entries with no pending proposals", async () => {
433
+ await makeChronicle(tmpDir, {
434
+ entries: [
435
+ { topic: "a", decision: "d", status: "validated", confidence: 0.9 },
436
+ { topic: "b", decision: "d", status: "validated", confidence: 0.95 },
437
+ ],
438
+ })
439
+ const result = await toolGrowth({ projectRoot: tmpDir })
440
+ expect(result.health).toBe(100)
441
+ expect(result.entries.byStatus.validated).toBe(2)
442
+ expect(result.entries.avgConfidence).toBeGreaterThan(0)
443
+ })
444
+
445
+ it("penalises refuted entries and pending proposals in health score", async () => {
446
+ await makeChronicle(tmpDir, {
447
+ entries: [
448
+ { topic: "a", decision: "d", status: "validated", confidence: 0.9 },
449
+ { topic: "b", decision: "d", status: "refuted", confidence: 0.2 },
450
+ ],
451
+ proposals: [{ topic: "pending", decision: "p" }],
452
+ })
453
+ const result = await toolGrowth({ projectRoot: tmpDir })
454
+ expect(result.health).toBeLessThan(100)
455
+ })
456
+
457
+ it("includes a hint string", async () => {
458
+ await makeChronicle(tmpDir)
459
+ const result = await toolGrowth({ projectRoot: tmpDir })
460
+ expect(typeof result.hint).toBe("string")
461
+ expect(result.hint.length).toBeGreaterThan(0)
462
+ })
463
+ })
464
+
465
+ // ── toolHelp ──────────────────────────────────────────────────────────────────
466
+
467
+ describe("toolHelp", () => {
468
+ it("returns content for topic=index", async () => {
469
+ const result = await toolHelp({ topic: "index" })
470
+ expect(result.topic).toBe("index")
471
+ expect(typeof result.content).toBe("string")
472
+ expect(result.content.length).toBeGreaterThan(0)
473
+ })
474
+
475
+ it("returns content for a known section (oracle)", async () => {
476
+ const result = await toolHelp({ topic: "oracle" })
477
+ expect(result.topic).toBe("oracle")
478
+ expect(typeof result.content).toBe("string")
479
+ })
480
+
481
+ it("returns helpful fallback for unknown topic", async () => {
482
+ const result = await toolHelp({ topic: "xyzzy-nonexistent-section" })
483
+ expect(result.content).toMatch(/No section found|quorum_help/i)
484
+ })
485
+
486
+ it("defaults to index when called with no topic", async () => {
487
+ const result = await toolHelp({})
488
+ expect(result.topic).toBe("index")
489
+ })
490
+ })
491
+
492
+ // ── TODO placeholder tools ────────────────────────────────────────────────────
493
+
494
+ describe("toolAdvisor (TODO placeholder)", () => {
495
+ it("throws when question is missing", async () => {
496
+ await expect(toolAdvisor({})).rejects.toThrow("question is required")
497
+ })
498
+
499
+ it("returns status=todo with a message", async () => {
500
+ const result = await toolAdvisor({ question: "what was decided about retries?" })
501
+ expect(result.status).toBe("todo")
502
+ expect(result.message).toMatch(/todo|quorum advisor/i)
503
+ })
504
+ })
505
+
506
+ describe("toolCheck (TODO placeholder)", () => {
507
+ it("throws when neither outcome nor design is provided", async () => {
508
+ await expect(toolCheck({})).rejects.toThrow("outcome or design is required")
509
+ })
510
+
511
+ it("returns status=todo with a message", async () => {
512
+ const result = await toolCheck({ outcome: "ship safely", design: "use feature flags" })
513
+ expect(result.status).toBe("todo")
514
+ expect(result.message).toMatch(/todo|quorum check/i)
515
+ })
516
+ })
517
+
518
+ describe("toolCompass (TODO placeholder)", () => {
519
+ it("returns status=todo with a message", async () => {
520
+ const result = await toolCompass({ subcommand: "brief" })
521
+ expect(result.status).toBe("todo")
522
+ expect(result.message).toMatch(/todo|quorum compass/i)
523
+ })
524
+ })
525
+
@@ -0,0 +1,65 @@
1
+ /**
2
+ * quorum bootstrap [--from-git] [--since P90D] [--propose]
3
+ *
4
+ * Cold-start helper: seeds Chronicle with low-trust evidence from the project's
5
+ * own history. Currently supports --from-git (git commit history).
6
+ *
7
+ * Evidence is written to .chronicle/sources/ and .chronicle/evidence/.
8
+ * With --propose, draft proposals are also written to .chronicle/proposals/
9
+ * for review with: quorum commit --list
10
+ */
11
+
12
+ import { c } from "../shared/colors.js"
13
+ import { findChronicleDir } from "../shared/chronicle.js"
14
+
15
+ function parseArgs(argv) {
16
+ const args = { fromGit: false, since: "P90D", propose: false }
17
+ for (let i = 0; i < argv.length; i++) {
18
+ if (argv[i] === "--from-git") { args.fromGit = true; continue }
19
+ if (argv[i] === "--propose") { args.propose = true; continue }
20
+ if (argv[i] === "--since" && argv[i + 1]) { args.since = argv[++i]; continue }
21
+ if (argv[i].startsWith("--since=")) { args.since = argv[i].slice(8); continue }
22
+ }
23
+ return args
24
+ }
25
+
26
+ export async function run(argv) {
27
+ const args = parseArgs(argv)
28
+
29
+ if (!args.fromGit) {
30
+ console.error(c.red("Usage: quorum bootstrap --from-git [--since P90D] [--propose]"))
31
+ console.error(c.dim(""))
32
+ console.error(c.dim(" --from-git Bootstrap from git commit history"))
33
+ console.error(c.dim(" --since P90D ISO 8601 duration (P30D, P6M, P1Y) [default: P90D]"))
34
+ console.error(c.dim(" --propose Also stage evidence as Chronicle proposals"))
35
+ process.exit(1)
36
+ }
37
+
38
+ const chronicleDir = await findChronicleDir()
39
+ if (!chronicleDir) {
40
+ console.error(c.red("No .chronicle/ directory found. Run quorum init first."))
41
+ process.exit(1)
42
+ }
43
+
44
+ console.log(c.bold(`\nBootstrapping Chronicle from git history...`))
45
+ console.log(c.dim(` since: ${args.since} propose: ${args.propose}\n`))
46
+
47
+ const { run: runIngestGit } = await import("./ingest-git.js")
48
+ const ingestArgs = ["--since", args.since]
49
+ if (args.propose) ingestArgs.push("--propose")
50
+ await runIngestGit(ingestArgs)
51
+
52
+ console.log()
53
+ console.log(c.bold("Bootstrap complete."))
54
+ if (!args.propose) {
55
+ console.log(c.dim("\n Evidence is in .chronicle/evidence/ — low-trust drafts, not yet Chronicle."))
56
+ console.log(c.dim(" Next steps:"))
57
+ console.log(c.dim(" quorum bootstrap --from-git --propose stage evidence as proposals"))
58
+ console.log(c.dim(" quorum commit --list review pending proposals"))
59
+ console.log(c.dim(" quorum commit <id> approve a proposal"))
60
+ } else {
61
+ console.log(c.dim("\n Review and approve proposals:"))
62
+ console.log(c.dim(" quorum commit --list"))
63
+ console.log(c.dim(" quorum commit <id>"))
64
+ }
65
+ }