@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 +73 -0
- package/bin/__tests__/ingest.test.js +220 -0
- package/bin/__tests__/mcp-server.test.js +739 -0
- package/bin/__tests__/mcp-tools.test.js +525 -0
- package/bin/commands/bootstrap.js +65 -0
- package/bin/commands/ingest-git.js +192 -0
- package/bin/commands/ingest-url.js +224 -0
- package/bin/commands/ingest.js +212 -0
- package/bin/commands/serve.js +52 -0
- package/bin/mcp/server.js +301 -0
- package/bin/mcp/tools.js +454 -0
- package/bin/quorum.js +51 -0
- package/bin/shared/chronicle.js +40 -0
- package/bin/ui/app.html +676 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|