@balpal4495/quorum 3.3.2 → 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,192 @@
1
+ /**
2
+ * quorum ingest-git [--since P90D] [--propose]
3
+ *
4
+ * Reads git commit history and ingests each commit as a low-trust evidence
5
+ * record in .chronicle/sources/ and .chronicle/evidence/.
6
+ *
7
+ * With --propose, each commit is also written to .chronicle/proposals/
8
+ * for review with: quorum commit --list
9
+ *
10
+ * --since accepts ISO 8601 durations: P90D (90 days), P6M (6 months), P1Y (1 year).
11
+ * Defaults to P90D.
12
+ */
13
+
14
+ import { execSync } from "child_process"
15
+ import { promises as fs } from "fs"
16
+ import path from "path"
17
+ import { randomUUID } from "crypto"
18
+ import { c } from "../shared/colors.js"
19
+ import { findChronicleDir } from "../shared/chronicle.js"
20
+
21
+ function parseArgs(argv) {
22
+ const args = { since: "P90D", propose: false }
23
+ for (let i = 0; i < argv.length; i++) {
24
+ if (argv[i] === "--since" && argv[i + 1]) { args.since = argv[++i]; continue }
25
+ if (argv[i].startsWith("--since=")) { args.since = argv[i].slice(8); continue }
26
+ if (argv[i] === "--propose") { args.propose = true }
27
+ }
28
+ return args
29
+ }
30
+
31
+ /**
32
+ * Parse a restricted subset of ISO 8601 duration (PnD, PnM, PnY) to a
33
+ * human-readable git --since value. Returns a safe numeric string like
34
+ * "90 days ago" — never passes raw user input to the shell.
35
+ */
36
+ export function parseDurationToGitSince(duration) {
37
+ const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?$/.exec(duration)
38
+ if (!match) return "90 days ago"
39
+ const years = parseInt(match[1] ?? "0", 10)
40
+ const months = parseInt(match[2] ?? "0", 10)
41
+ const days = parseInt(match[3] ?? "0", 10)
42
+ const total = years * 365 + months * 30 + days
43
+ return `${total > 0 ? total : 90} days ago`
44
+ }
45
+
46
+ function deriveScope(files) {
47
+ const scope = []
48
+ if (files.some(f => f.startsWith("modules/"))) scope.push("modules")
49
+ if (files.some(f => f.startsWith("bin/"))) scope.push("cli")
50
+ if (files.some(f => f.startsWith(".github/"))) scope.push("ci")
51
+ if (files.some(f => f === "README.md")) scope.push("docs")
52
+ if (files.some(f => f === "package.json")) scope.push("npm")
53
+ if (scope.length === 0) scope.push("general")
54
+ return scope
55
+ }
56
+
57
+ export async function run(argv) {
58
+ const args = parseArgs(argv)
59
+
60
+ const chronicleDir = await findChronicleDir()
61
+ if (!chronicleDir) {
62
+ console.error(c.red("No .chronicle/ directory found. Run quorum init first."))
63
+ process.exit(1)
64
+ }
65
+
66
+ const sourcesDir = path.join(chronicleDir, "sources")
67
+ const evidenceDir = path.join(chronicleDir, "evidence")
68
+ const proposalsDir = path.join(chronicleDir, "proposals")
69
+ await fs.mkdir(sourcesDir, { recursive: true })
70
+ await fs.mkdir(evidenceDir, { recursive: true })
71
+ if (args.propose) await fs.mkdir(proposalsDir, { recursive: true })
72
+
73
+ // Load already-ingested commit hashes to avoid duplicates
74
+ const existingRefs = new Set()
75
+ try {
76
+ for (const f of await fs.readdir(sourcesDir)) {
77
+ if (!f.endsWith(".json")) continue
78
+ try {
79
+ const src = JSON.parse(await fs.readFile(path.join(sourcesDir, f), "utf8"))
80
+ if (src.type === "git-commit" && src.ref) existingRefs.add(src.ref)
81
+ } catch { /* skip malformed */ }
82
+ }
83
+ } catch { /* no sources yet */ }
84
+
85
+ const gitSince = parseDurationToGitSince(args.since)
86
+ console.log(c.bold(`\nReading git history since "${gitSince}"...\n`))
87
+
88
+ let logRaw
89
+ try {
90
+ logRaw = execSync(
91
+ // Safe: gitSince is always "N days ago" from parseDurationToGitSince
92
+ `git log --since="${gitSince}" --pretty=format:"%H|||%ci|||%s" --no-merges`,
93
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
94
+ ).trim()
95
+ } catch {
96
+ console.error(c.red("Could not run git log. Ensure this is a git repository."))
97
+ process.exit(1)
98
+ }
99
+
100
+ if (!logRaw) {
101
+ console.log(c.dim(`No commits found since "${gitSince}"`))
102
+ return
103
+ }
104
+
105
+ const commits = logRaw
106
+ .split("\n")
107
+ .filter(Boolean)
108
+ .map(line => {
109
+ const [hash, date, ...parts] = line.split("|||")
110
+ return { hash: hash.trim(), date: date.trim(), subject: parts.join("|||").trim() }
111
+ })
112
+
113
+ let ingested = 0, skipped = 0, proposed = 0
114
+ const now = new Date().toISOString()
115
+
116
+ for (const { hash, date, subject } of commits) {
117
+ if (existingRefs.has(hash)) { skipped++; continue }
118
+
119
+ // Get changed files — hash is hex from git log, safe to interpolate
120
+ let changedFiles = []
121
+ try {
122
+ changedFiles = execSync(
123
+ `git diff-tree --no-commit-id -r --name-only ${hash}`,
124
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
125
+ ).trim().split("\n").filter(Boolean)
126
+ } catch { /* skip file list on error */ }
127
+
128
+ const scope = deriveScope(changedFiles)
129
+ const sourceId = randomUUID()
130
+ const evidenceId = randomUUID()
131
+
132
+ const sourceRecord = {
133
+ id: sourceId,
134
+ type: "git-commit",
135
+ ref: hash,
136
+ ingested_at: now,
137
+ metadata: { date, subject, files_changed: changedFiles.length },
138
+ }
139
+ await fs.writeFile(
140
+ path.join(sourcesDir, `${sourceId}.json`),
141
+ JSON.stringify(sourceRecord, null, 2),
142
+ )
143
+ existingRefs.add(hash)
144
+
145
+ const evidenceRecord = {
146
+ id: evidenceId,
147
+ source_id: sourceId,
148
+ schema_version: 2,
149
+ topic: `git: ${subject.slice(0, 80)}`,
150
+ key_insight: subject.slice(0, 150),
151
+ decision: subject.slice(0, 150),
152
+ affected_areas: changedFiles.slice(0, 10),
153
+ scope,
154
+ alternatives_considered: [],
155
+ rejected_reason: [],
156
+ status: "open",
157
+ confidence: 0.4,
158
+ source_quality: "metadata-derived",
159
+ needs_human_summary: true,
160
+ source_module: "ingest-git",
161
+ work_ref: { type: "git-commit", ref: hash, date },
162
+ ingested_at: now,
163
+ }
164
+ await fs.writeFile(
165
+ path.join(evidenceDir, `${evidenceId}.json`),
166
+ JSON.stringify(evidenceRecord, null, 2),
167
+ )
168
+
169
+ if (args.propose) {
170
+ const proposalId = randomUUID()
171
+ const { id: _id, ingested_at: _ts, ...proposalBody } = evidenceRecord
172
+ await fs.writeFile(
173
+ path.join(proposalsDir, `${proposalId}.json`),
174
+ JSON.stringify(proposalBody, null, 2),
175
+ )
176
+ proposed++
177
+ console.log(c.green(` ✓ propose ${hash.slice(0, 7)} ${subject.slice(0, 60)}`))
178
+ } else {
179
+ console.log(c.green(` ✓ ingest ${hash.slice(0, 7)} ${subject.slice(0, 60)}`))
180
+ }
181
+ ingested++
182
+ }
183
+
184
+ const suffix = args.propose ? ` ${proposed} proposed` : ""
185
+ console.log(`\n${c.bold("Done.")} ${ingested} commits ingested ${skipped} already ingested${suffix}`)
186
+ if (ingested > 0 && !args.propose) {
187
+ console.log(c.dim(`\n Evidence in .chronicle/evidence/`))
188
+ console.log(c.dim(` Re-run with --propose to stage as Chronicle proposals.`))
189
+ } else if (proposed > 0) {
190
+ console.log(c.dim(`\n Review proposals: quorum commit --list`))
191
+ }
192
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * quorum ingest-url <url...> [--propose]
3
+ *
4
+ * Fetches one or more URLs and stores each as a low-trust evidence record in
5
+ * .chronicle/sources/ and .chronicle/evidence/.
6
+ *
7
+ * With --propose, each URL is also written to .chronicle/proposals/
8
+ * for review with: quorum commit --list
9
+ *
10
+ * Only http:// and https:// URLs are accepted.
11
+ */
12
+
13
+ import { createHash, randomUUID } from "crypto"
14
+ import { promises as fs } from "fs"
15
+ import https from "https"
16
+ import http from "http"
17
+ import path from "path"
18
+ import { c } from "../shared/colors.js"
19
+ import { findChronicleDir } from "../shared/chronicle.js"
20
+
21
+ function parseArgs(argv) {
22
+ const args = { urls: [], propose: false }
23
+ for (const arg of argv) {
24
+ if (arg === "--propose") args.propose = true
25
+ else if (arg.startsWith("http://") || arg.startsWith("https://")) args.urls.push(arg)
26
+ else if (!arg.startsWith("-")) {
27
+ console.warn(c.dim(` skip: ${arg} (not an http/https URL)`))
28
+ }
29
+ }
30
+ return args
31
+ }
32
+
33
+ /**
34
+ * Validate that a URL uses only http or https — no file://, ftp://, etc.
35
+ */
36
+ function validateUrl(raw) {
37
+ let u
38
+ try { u = new URL(raw) } catch { return null }
39
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null
40
+ return u
41
+ }
42
+
43
+ /**
44
+ * Fetch a URL, following one redirect, returning raw body as a string.
45
+ * Resolves with the text body or rejects with an Error.
46
+ */
47
+ function fetchUrl(url, redirectDepth = 0) {
48
+ if (redirectDepth > 3) return Promise.reject(new Error("Too many redirects"))
49
+ const parsed = validateUrl(url)
50
+ if (!parsed) return Promise.reject(new Error(`Rejected URL scheme: ${url}`))
51
+
52
+ return new Promise((resolve, reject) => {
53
+ const protocol = parsed.protocol === "https:" ? https : http
54
+ const req = protocol.get(url, { headers: { "User-Agent": "quorum-ingest/1.0" } }, (res) => {
55
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
56
+ // Follow redirect — re-validate the Location header
57
+ const location = res.headers.location
58
+ res.resume()
59
+ fetchUrl(location, redirectDepth + 1).then(resolve).catch(reject)
60
+ return
61
+ }
62
+ if (res.statusCode < 200 || res.statusCode >= 300) {
63
+ res.resume()
64
+ reject(new Error(`HTTP ${res.statusCode}`))
65
+ return
66
+ }
67
+ const chunks = []
68
+ res.on("data", chunk => chunks.push(chunk))
69
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
70
+ res.on("error", reject)
71
+ })
72
+ req.on("error", reject)
73
+ req.setTimeout(15_000, () => { req.destroy(); reject(new Error("Request timeout")) })
74
+ })
75
+ }
76
+
77
+ function stripHtml(html) {
78
+ return html
79
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
80
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
81
+ .replace(/<[^>]+>/g, " ")
82
+ .replace(/&[a-z]+;/gi, " ")
83
+ .replace(/\s+/g, " ")
84
+ .trim()
85
+ .slice(0, 3000)
86
+ }
87
+
88
+ function extractTitle(html) {
89
+ const m = /<title[^>]*>([^<]+)<\/title>/i.exec(html)
90
+ return m ? m[1].trim().slice(0, 150) : ""
91
+ }
92
+
93
+ function deriveScope(url) {
94
+ try {
95
+ const u = new URL(url)
96
+ const first = u.pathname.split("/").filter(Boolean)[0]
97
+ return ["external", ...(first ? [first.slice(0, 20)] : [])]
98
+ } catch {
99
+ return ["external"]
100
+ }
101
+ }
102
+
103
+ export async function run(argv) {
104
+ const args = parseArgs(argv)
105
+ if (args.urls.length === 0) {
106
+ console.error(c.red("Usage: quorum ingest-url <url...> [--propose]"))
107
+ console.error(c.dim(" Only http:// and https:// URLs are supported."))
108
+ process.exit(1)
109
+ }
110
+
111
+ const chronicleDir = await findChronicleDir()
112
+ if (!chronicleDir) {
113
+ console.error(c.red("No .chronicle/ directory found. Run quorum init first."))
114
+ process.exit(1)
115
+ }
116
+
117
+ const sourcesDir = path.join(chronicleDir, "sources")
118
+ const evidenceDir = path.join(chronicleDir, "evidence")
119
+ const proposalsDir = path.join(chronicleDir, "proposals")
120
+ await fs.mkdir(sourcesDir, { recursive: true })
121
+ await fs.mkdir(evidenceDir, { recursive: true })
122
+ if (args.propose) await fs.mkdir(proposalsDir, { recursive: true })
123
+
124
+ // Load already-ingested URLs to skip duplicates
125
+ const existingRefs = new Set()
126
+ try {
127
+ for (const f of await fs.readdir(sourcesDir)) {
128
+ if (!f.endsWith(".json")) continue
129
+ try {
130
+ const src = JSON.parse(await fs.readFile(path.join(sourcesDir, f), "utf8"))
131
+ if (src.type === "url" && src.ref) existingRefs.add(src.ref)
132
+ } catch { /* skip malformed */ }
133
+ }
134
+ } catch { /* no sources yet */ }
135
+
136
+ let ingested = 0, skipped = 0, proposed = 0
137
+ const now = new Date().toISOString()
138
+ console.log(c.bold(`\nIngesting ${args.urls.length} URL(s)...\n`))
139
+
140
+ for (const url of args.urls) {
141
+ if (existingRefs.has(url)) {
142
+ skipped++
143
+ console.log(c.dim(` ≡ skip ${url} (already ingested)`))
144
+ continue
145
+ }
146
+
147
+ let rawHtml
148
+ try {
149
+ rawHtml = await fetchUrl(url)
150
+ } catch (err) {
151
+ console.warn(c.red(` ✗ error ${url} — ${err.message}`))
152
+ continue
153
+ }
154
+
155
+ const title = extractTitle(rawHtml) || url
156
+ const text = stripHtml(rawHtml)
157
+ const hashKey = `sha256:${createHash("sha256").update(text).digest("hex")}`
158
+ const scope = deriveScope(url)
159
+ const summary = `${title} — ${text.slice(0, 120)}`
160
+ const sourceId = randomUUID()
161
+ const evidenceId = randomUUID()
162
+
163
+ const sourceRecord = {
164
+ id: sourceId,
165
+ type: "url",
166
+ ref: url,
167
+ ingested_at: now,
168
+ content_hash: hashKey,
169
+ metadata: { title, content_length: text.length },
170
+ }
171
+ await fs.writeFile(
172
+ path.join(sourcesDir, `${sourceId}.json`),
173
+ JSON.stringify(sourceRecord, null, 2),
174
+ )
175
+ existingRefs.add(url)
176
+
177
+ const evidenceRecord = {
178
+ id: evidenceId,
179
+ source_id: sourceId,
180
+ schema_version: 2,
181
+ topic: `url: ${title.slice(0, 80)}`,
182
+ key_insight: summary.slice(0, 150),
183
+ decision: summary.slice(0, 150),
184
+ affected_areas: [url],
185
+ scope,
186
+ alternatives_considered: [],
187
+ rejected_reason: [],
188
+ status: "open",
189
+ confidence: 0.4,
190
+ source_quality: "metadata-derived",
191
+ needs_human_summary: true,
192
+ source_module: "ingest-url",
193
+ work_ref: { type: "url", ref: url },
194
+ ingested_at: now,
195
+ }
196
+ await fs.writeFile(
197
+ path.join(evidenceDir, `${evidenceId}.json`),
198
+ JSON.stringify(evidenceRecord, null, 2),
199
+ )
200
+
201
+ if (args.propose) {
202
+ const proposalId = randomUUID()
203
+ const { id: _id, ingested_at: _ts, ...proposalBody } = evidenceRecord
204
+ await fs.writeFile(
205
+ path.join(proposalsDir, `${proposalId}.json`),
206
+ JSON.stringify(proposalBody, null, 2),
207
+ )
208
+ proposed++
209
+ console.log(c.green(` ✓ propose ${url}`))
210
+ } else {
211
+ console.log(c.green(` ✓ ingest ${url}`))
212
+ }
213
+ ingested++
214
+ }
215
+
216
+ const suffix = args.propose ? ` ${proposed} proposed` : ""
217
+ console.log(`\n${c.bold("Done.")} ${ingested} ingested ${skipped} already ingested${suffix}`)
218
+ if (ingested > 0 && !args.propose) {
219
+ console.log(c.dim(`\n Evidence in .chronicle/evidence/`))
220
+ console.log(c.dim(` Re-run with --propose to stage as Chronicle proposals.`))
221
+ } else if (proposed > 0) {
222
+ console.log(c.dim(`\n Review proposals: quorum commit --list`))
223
+ }
224
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * quorum ingest <paths...> [--recurse] [--propose]
3
+ *
4
+ * Ingests files and directories into .chronicle/sources/ and .chronicle/evidence/
5
+ * as low-trust, metadata-derived records (confidence 0.4, needs_human_summary: true).
6
+ *
7
+ * With --propose, each evidence item is also written to .chronicle/proposals/
8
+ * for review with: quorum commit --list
9
+ */
10
+
11
+ import { createHash, randomUUID } from "crypto"
12
+ import { promises as fs } from "fs"
13
+ import path from "path"
14
+ import { c } from "../shared/colors.js"
15
+ import { findChronicleDir } from "../shared/chronicle.js"
16
+
17
+ function parseArgs(argv) {
18
+ const args = { paths: [], recurse: false, propose: false }
19
+ for (const arg of argv) {
20
+ if (arg === "--recurse" || arg === "-r") args.recurse = true
21
+ else if (arg === "--propose") args.propose = true
22
+ else if (!arg.startsWith("-")) args.paths.push(arg)
23
+ }
24
+ return args
25
+ }
26
+
27
+ async function* walkDir(dir) {
28
+ let entries
29
+ try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
30
+ for (const entry of entries) {
31
+ const full = path.join(dir, entry.name)
32
+ if (entry.isDirectory()) {
33
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue
34
+ yield* walkDir(full)
35
+ } else {
36
+ yield full
37
+ }
38
+ }
39
+ }
40
+
41
+ async function collectFiles(targetPaths, recurse) {
42
+ const files = []
43
+ for (const p of targetPaths) {
44
+ const stat = await fs.stat(p).catch(() => null)
45
+ if (!stat) { console.warn(c.dim(` skip: ${p} (not found)`)); continue }
46
+ if (stat.isDirectory()) {
47
+ if (recurse) {
48
+ for await (const f of walkDir(p)) files.push(f)
49
+ } else {
50
+ console.warn(c.dim(` skip: ${p} is a directory — use --recurse`))
51
+ }
52
+ } else {
53
+ files.push(p)
54
+ }
55
+ }
56
+ return files
57
+ }
58
+
59
+ const TEXT_EXTS = new Set([
60
+ ".md", ".txt", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
61
+ ".json", ".yaml", ".yml", ".toml", ".sh", ".bash",
62
+ ".html", ".htm", ".css", ".scss", ".svg", ".xml", ".csv",
63
+ ".rst", ".adoc", ".env",
64
+ ])
65
+
66
+ async function extractContent(filePath) {
67
+ const ext = path.extname(filePath).toLowerCase()
68
+ if (!TEXT_EXTS.has(ext)) return null
69
+ try {
70
+ const raw = await fs.readFile(filePath, "utf8")
71
+ return raw.slice(0, 3000)
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ function deriveScope(filePath) {
78
+ const scope = []
79
+ const rel = filePath.replace(/\\/g, "/")
80
+ if (rel.match(/\/docs?\//i) || rel.endsWith(".md") || rel.endsWith(".rst")) scope.push("docs")
81
+ if (rel.match(/\/(src|lib|modules)\//)) scope.push("source")
82
+ if (rel.match(/\/(tests?|__tests__)\//i) || rel.match(/\.(test|spec)\./)) scope.push("tests")
83
+ if (rel.includes("/.github/") || rel.includes("/ci/")) scope.push("ci")
84
+ if (rel.includes("/bin/") || rel.endsWith(".sh")) scope.push("cli")
85
+ if (scope.length === 0) scope.push("general")
86
+ return scope
87
+ }
88
+
89
+ function summariseContent(filePath, content) {
90
+ if (!content) return `Ingested ${path.basename(filePath)}`
91
+ const lines = content.split("\n").map(l => l.trim()).filter(Boolean)
92
+ const heading = lines.find(l => l.startsWith("#")) ?? lines[0] ?? ""
93
+ return heading.replace(/^#+\s*/, "").slice(0, 150) || `Content from ${path.basename(filePath)}`
94
+ }
95
+
96
+ export async function run(argv) {
97
+ const args = parseArgs(argv)
98
+ if (args.paths.length === 0) {
99
+ console.error(c.red("Usage: quorum ingest <paths...> [--recurse] [--propose]"))
100
+ process.exit(1)
101
+ }
102
+
103
+ const chronicleDir = await findChronicleDir()
104
+ if (!chronicleDir) {
105
+ console.error(c.red("No .chronicle/ directory found. Run quorum init first."))
106
+ process.exit(1)
107
+ }
108
+
109
+ const sourcesDir = path.join(chronicleDir, "sources")
110
+ const evidenceDir = path.join(chronicleDir, "evidence")
111
+ const proposalsDir = path.join(chronicleDir, "proposals")
112
+ await fs.mkdir(sourcesDir, { recursive: true })
113
+ await fs.mkdir(evidenceDir, { recursive: true })
114
+ if (args.propose) await fs.mkdir(proposalsDir, { recursive: true })
115
+
116
+ // Load existing content hashes to skip unchanged files
117
+ const existingHashes = new Set()
118
+ try {
119
+ for (const f of await fs.readdir(sourcesDir)) {
120
+ if (!f.endsWith(".json")) continue
121
+ try {
122
+ const src = JSON.parse(await fs.readFile(path.join(sourcesDir, f), "utf8"))
123
+ if (src.type === "file" && src.content_hash) existingHashes.add(src.content_hash)
124
+ } catch { /* skip malformed */ }
125
+ }
126
+ } catch { /* no sources yet */ }
127
+
128
+ const files = await collectFiles(args.paths, args.recurse)
129
+ let ingested = 0, skipped = 0, proposed = 0
130
+ const now = new Date().toISOString()
131
+
132
+ console.log(c.bold(`\nIngesting ${files.length} file(s)...\n`))
133
+
134
+ for (const filePath of files) {
135
+ const content = await extractContent(filePath)
136
+ const text = content ?? ""
137
+ const hashKey = `sha256:${createHash("sha256").update(text).digest("hex")}`
138
+
139
+ if (existingHashes.has(hashKey)) {
140
+ skipped++
141
+ console.log(c.dim(` ≡ skip ${path.relative(process.cwd(), filePath)} (unchanged)`))
142
+ continue
143
+ }
144
+
145
+ const relPath = path.relative(process.cwd(), filePath)
146
+ const scope = deriveScope(filePath)
147
+ const summary = summariseContent(filePath, content)
148
+ const sourceId = randomUUID()
149
+ const evidenceId = randomUUID()
150
+
151
+ const sourceRecord = {
152
+ id: sourceId,
153
+ type: "file",
154
+ ref: relPath,
155
+ ingested_at: now,
156
+ content_hash: hashKey,
157
+ metadata: { size_bytes: text.length, ext: path.extname(filePath) },
158
+ }
159
+ await fs.writeFile(
160
+ path.join(sourcesDir, `${sourceId}.json`),
161
+ JSON.stringify(sourceRecord, null, 2),
162
+ )
163
+ existingHashes.add(hashKey)
164
+
165
+ const evidenceRecord = {
166
+ id: evidenceId,
167
+ source_id: sourceId,
168
+ schema_version: 2,
169
+ topic: relPath,
170
+ key_insight: summary,
171
+ decision: summary,
172
+ affected_areas: [relPath],
173
+ scope,
174
+ alternatives_considered: [],
175
+ rejected_reason: [],
176
+ status: "open",
177
+ confidence: 0.4,
178
+ source_quality: "metadata-derived",
179
+ needs_human_summary: true,
180
+ source_module: "ingest",
181
+ work_ref: { type: "file", ref: relPath },
182
+ ingested_at: now,
183
+ }
184
+ await fs.writeFile(
185
+ path.join(evidenceDir, `${evidenceId}.json`),
186
+ JSON.stringify(evidenceRecord, null, 2),
187
+ )
188
+
189
+ if (args.propose) {
190
+ const proposalId = randomUUID()
191
+ const { id: _id, ingested_at: _ts, ...proposalBody } = evidenceRecord
192
+ await fs.writeFile(
193
+ path.join(proposalsDir, `${proposalId}.json`),
194
+ JSON.stringify(proposalBody, null, 2),
195
+ )
196
+ proposed++
197
+ console.log(c.green(` ✓ propose ${relPath}`))
198
+ } else {
199
+ console.log(c.green(` ✓ ingest ${relPath}`))
200
+ }
201
+ ingested++
202
+ }
203
+
204
+ const suffix = args.propose ? ` ${proposed} proposed` : ""
205
+ console.log(`\n${c.bold("Done.")} ${ingested} ingested ${skipped} unchanged${suffix}`)
206
+ if (ingested > 0 && !args.propose) {
207
+ console.log(c.dim(`\n Evidence in .chronicle/evidence/`))
208
+ console.log(c.dim(` Re-run with --propose to stage as Chronicle proposals.`))
209
+ } else if (proposed > 0) {
210
+ console.log(c.dim(`\n Review proposals: quorum commit --list`))
211
+ }
212
+ }
@@ -0,0 +1,52 @@
1
+ import { c } from "../shared/colors.js"
2
+ import { findChronicleDir } from "../shared/chronicle.js"
3
+ import { createServer } from "../mcp/server.js"
4
+
5
+ function parseArgs(argv) {
6
+ const portIdx = argv.indexOf("--port")
7
+ const port = portIdx !== -1
8
+ ? Number(argv[portIdx + 1])
9
+ : Number(argv.find(a => /^\d{2,5}$/.test(a)) ?? 3000)
10
+ const hostIdx = argv.indexOf("--host")
11
+ const host = hostIdx !== -1 ? argv[hostIdx + 1] : "localhost"
12
+ return { port: isNaN(port) ? 3000 : port, host }
13
+ }
14
+
15
+ export async function run(argv) {
16
+ const { port, host } = parseArgs(argv)
17
+
18
+ const projectRoot = process.cwd()
19
+ const chronicleDir = await findChronicleDir(projectRoot)
20
+
21
+ if (!chronicleDir) {
22
+ console.error(`\n${c.red("No .chronicle/ directory found.")} Run ${c.bold("quorum init")} first.\n`)
23
+ process.exit(1)
24
+ }
25
+
26
+ const server = await createServer({ projectRoot, chronicleDir })
27
+
28
+ server.listen(port, host, () => {
29
+ const base = `http://${host}:${port}`
30
+ console.log(`\n${c.bold("Quorum")} ${c.dim(`serving ${projectRoot}`)}\n`)
31
+ console.log(` ${c.cyan("UI")} ${c.dim(base + "/")}`)
32
+ console.log(` ${c.cyan("MCP")} ${c.dim(base + "/mcp")}`)
33
+ console.log(` ${c.cyan("Chronicle")} ${c.dim(chronicleDir)}\n`)
34
+ console.log(c.bold("Claude Desktop") + c.dim(" — add to claude_desktop_config.json:"))
35
+ console.log(c.dim(JSON.stringify({
36
+ mcpServers: { quorum: { type: "streamable-http", url: `${base}/mcp` } }
37
+ }, null, 2)))
38
+ console.log(`\n${c.dim("Press Ctrl+C to stop.")}\n`)
39
+ })
40
+
41
+ server.on("error", err => {
42
+ if (err.code === "EADDRINUSE") {
43
+ console.error(`\n${c.red(`Port ${port} is already in use.`)} Try ${c.bold(`quorum serve --port ${port + 1}`)}\n`)
44
+ } else {
45
+ console.error(`\n${c.red("Server error:")} ${err.message}\n`)
46
+ }
47
+ process.exit(1)
48
+ })
49
+
50
+ process.on("SIGINT", () => { server.close(); process.exit(0) })
51
+ process.on("SIGTERM", () => { server.close(); process.exit(0) })
52
+ }