@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.
package/README.md CHANGED
@@ -141,6 +141,9 @@ Every PR merge posts a growth comment showing what Chronicle learned. `quorum ev
141
141
  | Approve what the agent should remember | `quorum commit <id>` |
142
142
  | See whether memory is growing | `quorum growth` |
143
143
  | Consolidate stale or duplicate entries | `quorum evolve` |
144
+ | Seed Chronicle from git history | `quorum bootstrap --from-git --propose` |
145
+ | Ingest docs and source files as evidence | `quorum ingest docs/ --recurse` |
146
+ | Ingest URLs as evidence | `quorum ingest-url https://example.com/rfc` |
144
147
  | Find undocumented areas | `quorum sentinel coverage` |
145
148
  | Understand what the product currently does | `quorum compass map` |
146
149
  | Generate product pathways toward a goal | `quorum compass pathways --goal "..."` |
@@ -151,6 +154,14 @@ Every PR merge posts a growth comment showing what Chronicle learned. `quorum ev
151
154
 
152
155
  ## Start small, then add guardrails
153
156
 
157
+ ### Level 0 — Cold start
158
+ New repo with no Chronicle yet? Seed it from git history:
159
+ ```bash
160
+ quorum bootstrap --from-git --since P90D --propose
161
+ quorum commit --list # review, then approve entries
162
+ ```
163
+ Each commit becomes a low-trust draft proposal (`confidence: 0.4`, `needs_human_summary: true`). Nothing is indexed until you run `quorum commit <id>`.
164
+
154
165
  ### Level 1 — Local memory
155
166
  Use `quorum advisor brief` and `quorum advisor query` at the start of AI sessions. No setup beyond `init`.
156
167
 
@@ -230,6 +241,66 @@ For most host-project use cases the CLI is sufficient and requires no loader. Se
230
241
 
231
242
  ## Command reference
232
243
 
244
+ ### `quorum ingest` — ingest files and folders
245
+
246
+ ```bash
247
+ quorum ingest README.md SETUP.md
248
+ quorum ingest docs/ --recurse
249
+ quorum ingest docs/ --recurse --propose # also stage as proposals
250
+ ```
251
+
252
+ Writes low-trust evidence to `.chronicle/sources/` and `.chronicle/evidence/`. Content-hash deduplication skips files that have not changed since the last ingest. Use `--propose` to also write to `.chronicle/proposals/` for review with `quorum commit --list`.
253
+
254
+ Supported extensions: `.md`, `.txt`, `.js`, `.ts`, `.json`, `.yaml`, `.html`, and other plain-text formats. Binary and unsupported files are recorded as sources with a fallback summary.
255
+
256
+ ---
257
+
258
+ ### `quorum ingest-git` — ingest git history
259
+
260
+ ```bash
261
+ quorum ingest-git --since P90D
262
+ quorum ingest-git --since P6M --propose # also stage commits as proposals
263
+ ```
264
+
265
+ `--since` accepts ISO 8601 durations: `P30D`, `P6M`, `P1Y`. Defaults to `P90D`. Each commit is stored as a source record and an evidence record containing the commit subject and changed files. Already-ingested commits are skipped by hash.
266
+
267
+ ---
268
+
269
+ ### `quorum ingest-url` — ingest URLs
270
+
271
+ ```bash
272
+ quorum ingest-url https://example.com/internal-rfc
273
+ quorum ingest-url https://example.com/rfc https://example.com/spec --propose
274
+ ```
275
+
276
+ Fetches each URL (http/https only), strips HTML, and stores a low-trust evidence record. Follows redirects. Deduplicates by URL on re-runs.
277
+
278
+ ---
279
+
280
+ ### `quorum bootstrap` — cold-start Chronicle from history
281
+
282
+ ```bash
283
+ quorum bootstrap --from-git
284
+ quorum bootstrap --from-git --since P6M --propose
285
+ ```
286
+
287
+ Convenience wrapper around `ingest-git`. Seeds a new Chronicle from the project's commit history. Without `--propose`, evidence sits in `.chronicle/evidence/` until you promote it. With `--propose`, every commit becomes a draft proposal ready for `quorum commit`.
288
+
289
+ All ingested evidence uses the same low-trust format as PR-merge proposals:
290
+
291
+ ```json
292
+ {
293
+ "source_quality": "metadata-derived",
294
+ "needs_human_summary": true,
295
+ "status": "open",
296
+ "confidence": 0.4
297
+ }
298
+ ```
299
+
300
+ This solves the cold-start problem: a repo gets candidate memory from real history without bypassing the human gate.
301
+
302
+ ---
303
+
233
304
  ### `quorum advisor` — ask Chronicle a question
234
305
 
235
306
  ```bash
@@ -300,6 +371,8 @@ Writes to `.chronicle/committed/`, updates `SUMMARY.md`, removes the proposal. A
300
371
 
301
372
  ```
302
373
  .chronicle/
374
+ sources/ ← raw ingested source records (files, URLs, git commits)
375
+ evidence/ ← low-trust extracted insights, not yet Chronicle
303
376
  proposals/ ← AI-staged entries waiting for your approval
304
377
  committed/ ← approved entries, indexed and searchable
305
378
  SUMMARY.md ← auto-generated context for your AI to read
@@ -0,0 +1,220 @@
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
+
6
+ // ── helpers ───────────────────────────────────────────────────────────────────
7
+
8
+ async function makeTmpDir() {
9
+ return fs.mkdtemp(path.join(os.tmpdir(), "quorum-ingest-"))
10
+ }
11
+
12
+ async function rmrf(dir) {
13
+ await fs.rm(dir, { recursive: true, force: true })
14
+ }
15
+
16
+ async function makeChronicle(root) {
17
+ const chronicleDir = path.join(root, ".chronicle")
18
+ await fs.mkdir(chronicleDir, { recursive: true })
19
+ return chronicleDir
20
+ }
21
+
22
+ async function readJsonDir(dir) {
23
+ let files
24
+ try { files = await fs.readdir(dir) } catch { return [] }
25
+ const results = []
26
+ for (const f of files) {
27
+ if (!f.endsWith(".json")) continue
28
+ results.push(JSON.parse(await fs.readFile(path.join(dir, f), "utf8")))
29
+ }
30
+ return results
31
+ }
32
+
33
+ // ── parseDurationToGitSince ───────────────────────────────────────────────────
34
+
35
+ import { parseDurationToGitSince } from "../commands/ingest-git.js"
36
+
37
+ describe("parseDurationToGitSince", () => {
38
+ it("parses P90D to '90 days ago'", () => {
39
+ expect(parseDurationToGitSince("P90D")).toBe("90 days ago")
40
+ })
41
+ it("parses P30D to '30 days ago'", () => {
42
+ expect(parseDurationToGitSince("P30D")).toBe("30 days ago")
43
+ })
44
+ it("parses P1Y to '365 days ago'", () => {
45
+ expect(parseDurationToGitSince("P1Y")).toBe("365 days ago")
46
+ })
47
+ it("parses P6M to '180 days ago'", () => {
48
+ expect(parseDurationToGitSince("P6M")).toBe("180 days ago")
49
+ })
50
+ it("parses P1Y6M to '545 days ago'", () => {
51
+ expect(parseDurationToGitSince("P1Y6M")).toBe("545 days ago")
52
+ })
53
+ it("falls back to '90 days ago' for invalid input", () => {
54
+ expect(parseDurationToGitSince("not-valid")).toBe("90 days ago")
55
+ expect(parseDurationToGitSince("")).toBe("90 days ago")
56
+ expect(parseDurationToGitSince("P")).toBe("90 days ago")
57
+ })
58
+ })
59
+
60
+ // ── ingest command ────────────────────────────────────────────────────────────
61
+
62
+ import { run as runIngest } from "../commands/ingest.js"
63
+ import { findChronicleDir, readEvidence, readSources } from "../shared/chronicle.js"
64
+
65
+ describe("quorum ingest", () => {
66
+ let tmpDir, chronicleDir, origCwd
67
+
68
+ beforeEach(async () => {
69
+ tmpDir = await makeTmpDir()
70
+ chronicleDir = await makeChronicle(tmpDir)
71
+ origCwd = process.cwd()
72
+ process.chdir(tmpDir)
73
+ })
74
+
75
+ afterEach(async () => {
76
+ process.chdir(origCwd)
77
+ await rmrf(tmpDir)
78
+ })
79
+
80
+ it("ingests a single text file — writes source + evidence records", async () => {
81
+ const file = path.join(tmpDir, "README.md")
82
+ await fs.writeFile(file, "# My Project\n\nAn example project.")
83
+
84
+ await runIngest(["README.md"])
85
+
86
+ const sources = await readSources(chronicleDir)
87
+ const evidence = await readEvidence(chronicleDir)
88
+
89
+ expect(sources).toHaveLength(1)
90
+ expect(sources[0].type).toBe("file")
91
+ expect(sources[0].ref).toBe("README.md")
92
+ expect(sources[0].content_hash).toMatch(/^sha256:/)
93
+
94
+ expect(evidence).toHaveLength(1)
95
+ expect(evidence[0].source_quality).toBe("metadata-derived")
96
+ expect(evidence[0].needs_human_summary).toBe(true)
97
+ expect(evidence[0].confidence).toBe(0.4)
98
+ expect(evidence[0].status).toBe("open")
99
+ expect(evidence[0].source_module).toBe("ingest")
100
+ expect(evidence[0].affected_areas).toContain("README.md")
101
+ })
102
+
103
+ it("skips unchanged files on second ingest (content-hash dedup)", async () => {
104
+ const file = path.join(tmpDir, "notes.md")
105
+ await fs.writeFile(file, "# Notes\nSome content.")
106
+
107
+ await runIngest(["notes.md"])
108
+ await runIngest(["notes.md"])
109
+
110
+ const sources = await readSources(chronicleDir)
111
+ const evidence = await readEvidence(chronicleDir)
112
+ expect(sources).toHaveLength(1)
113
+ expect(evidence).toHaveLength(1)
114
+ })
115
+
116
+ it("writes proposals when --propose flag is set", async () => {
117
+ const file = path.join(tmpDir, "design.md")
118
+ await fs.writeFile(file, "# Design\nDecision records go here.")
119
+
120
+ await runIngest(["design.md", "--propose"])
121
+
122
+ const proposals = await readJsonDir(path.join(chronicleDir, "proposals"))
123
+ expect(proposals).toHaveLength(1)
124
+ expect(proposals[0].source_quality).toBe("metadata-derived")
125
+ expect(proposals[0].confidence).toBe(0.4)
126
+ expect(proposals[0].needs_human_summary).toBe(true)
127
+ // proposal must NOT contain id or ingested_at (stripped before writing)
128
+ expect(proposals[0].id).toBeUndefined()
129
+ expect(proposals[0].ingested_at).toBeUndefined()
130
+ })
131
+
132
+ it("does not write proposals without --propose", async () => {
133
+ const file = path.join(tmpDir, "spec.md")
134
+ await fs.writeFile(file, "# Spec")
135
+
136
+ await runIngest(["spec.md"])
137
+
138
+ const proposalDir = path.join(chronicleDir, "proposals")
139
+ const files = await fs.readdir(proposalDir).catch(() => [])
140
+ expect(files.filter(f => f.endsWith(".json"))).toHaveLength(0)
141
+ })
142
+
143
+ it("skips non-text files but writes a source record without content", async () => {
144
+ // Binary-like file with unsupported extension
145
+ const file = path.join(tmpDir, "icon.png")
146
+ await fs.writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
147
+
148
+ await runIngest(["icon.png"])
149
+
150
+ // Should have skipped writing (no TEXT_EXTS match → null content)
151
+ // The command writes evidence regardless, with a fallback summary
152
+ const sources = await readSources(chronicleDir)
153
+ const evidence = await readEvidence(chronicleDir)
154
+ // .png not in TEXT_EXTS → content null → evidence summary is fallback
155
+ expect(sources).toHaveLength(1)
156
+ expect(evidence[0].key_insight).toContain("icon.png")
157
+ })
158
+
159
+ it("walks directories recursively with --recurse", async () => {
160
+ const subDir = path.join(tmpDir, "docs")
161
+ await fs.mkdir(subDir)
162
+ await fs.writeFile(path.join(subDir, "api.md"), "# API")
163
+ await fs.writeFile(path.join(subDir, "guide.md"), "# Guide")
164
+
165
+ await runIngest(["docs", "--recurse"])
166
+
167
+ const evidence = await readEvidence(chronicleDir)
168
+ expect(evidence).toHaveLength(2)
169
+ })
170
+ })
171
+
172
+ // ── readEvidence / readSources helpers ───────────────────────────────────────
173
+
174
+ describe("readEvidence and readSources", () => {
175
+ let tmpDir, chronicleDir
176
+
177
+ beforeEach(async () => {
178
+ tmpDir = await makeTmpDir()
179
+ chronicleDir = await makeChronicle(tmpDir)
180
+ })
181
+
182
+ afterEach(async () => {
183
+ await rmrf(tmpDir)
184
+ })
185
+
186
+ it("readEvidence returns empty array when directory does not exist", async () => {
187
+ const result = await readEvidence(chronicleDir)
188
+ expect(result).toEqual([])
189
+ })
190
+
191
+ it("readSources returns empty array when directory does not exist", async () => {
192
+ const result = await readSources(chronicleDir)
193
+ expect(result).toEqual([])
194
+ })
195
+
196
+ it("readEvidence reads written evidence records", async () => {
197
+ const dir = path.join(chronicleDir, "evidence")
198
+ await fs.mkdir(dir, { recursive: true })
199
+ await fs.writeFile(
200
+ path.join(dir, "abc123.json"),
201
+ JSON.stringify({ id: "abc123", key_insight: "test", ingested_at: "2026-01-01T00:00:00.000Z" }),
202
+ )
203
+ const result = await readEvidence(chronicleDir)
204
+ expect(result).toHaveLength(1)
205
+ expect(result[0].id).toBe("abc123")
206
+ })
207
+
208
+ it("readEvidence skips malformed JSON files", async () => {
209
+ const dir = path.join(chronicleDir, "evidence")
210
+ await fs.mkdir(dir, { recursive: true })
211
+ await fs.writeFile(path.join(dir, "bad.json"), "not json {{{{")
212
+ await fs.writeFile(
213
+ path.join(dir, "good.json"),
214
+ JSON.stringify({ id: "good", key_insight: "ok", ingested_at: "2026-01-01T00:00:00.000Z" }),
215
+ )
216
+ const result = await readEvidence(chronicleDir)
217
+ expect(result).toHaveLength(1)
218
+ expect(result[0].id).toBe("good")
219
+ })
220
+ })