@balpal4495/quorum 0.2.0 → 0.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.
Files changed (38) hide show
  1. package/README.md +223 -11
  2. package/SETUP.md +30 -0
  3. package/bin/commands/check.js +122 -0
  4. package/bin/commands/commit.js +210 -0
  5. package/bin/commands/init.js +236 -0
  6. package/bin/commands/sentinel.js +160 -0
  7. package/bin/commands/status.js +117 -0
  8. package/bin/quorum.js +103 -0
  9. package/bin/shared/chronicle.js +129 -0
  10. package/bin/shared/colors.js +22 -0
  11. package/bin/shared/patterns.js +83 -0
  12. package/evals/__tests__/eval.test.ts +31 -0
  13. package/evals/cases/auth_hs256_rejected.json +46 -0
  14. package/evals/cases/auth_rs256_valid.json +30 -0
  15. package/evals/cases/cache_missing_lock.json +31 -0
  16. package/evals/cases/db_naive_not_null.json +32 -0
  17. package/evals/cases/logging_pii_leak.json +32 -0
  18. package/evals/cases/migration_with_rollback.json +43 -0
  19. package/evals/cases/no_evidence_novel_design.json +16 -0
  20. package/evals/cases/payment_no_idempotency.json +33 -0
  21. package/evals/cases/redis_session_rejected.json +32 -0
  22. package/evals/cases/safe_refactor.json +17 -0
  23. package/evals/runner.ts +226 -0
  24. package/modules/AGENTS.md +9 -5
  25. package/modules/CLAUDE.md +25 -2
  26. package/modules/README.md +153 -6
  27. package/modules/council/chairman.ts +84 -14
  28. package/modules/council/deliberate.ts +24 -4
  29. package/modules/council/index.ts +6 -1
  30. package/modules/council/risk.ts +89 -0
  31. package/modules/council/types.ts +63 -1
  32. package/modules/jury/evaluate.ts +32 -8
  33. package/modules/jury/index.ts +3 -1
  34. package/modules/jury/preflight.ts +101 -0
  35. package/modules/jury/schema.ts +9 -0
  36. package/modules/jury/types.ts +20 -1
  37. package/modules/shared/types.ts +8 -0
  38. package/package.json +3 -3
package/README.md CHANGED
@@ -22,6 +22,97 @@ That's the whole setup. Quorum copies its modules into `quorum/`, merges instruc
22
22
 
23
23
  ---
24
24
 
25
+ ## CLI commands
26
+
27
+ After `npm install -g @balpal4495/quorum` (or `npx @balpal4495/quorum`), you get:
28
+
29
+ | Command | What it does | LLM? |
30
+ |---|---|---|
31
+ | `quorum init` | Scaffold Quorum into a project | No |
32
+ | `quorum status` | Chronicle health — pending proposals, committed entries, recent activity | No |
33
+ | `quorum check --outcome X --design Y` | Deterministic preflight + risk classifier | No |
34
+ | `quorum commit <id>` | Approve and index a pending proposal | No |
35
+ | `quorum sentinel [coverage]` | Chronicle coverage of your source files | No |
36
+
37
+ ### `quorum check` — instant risk triage before the full pipeline
38
+
39
+ ```bash
40
+ quorum check \
41
+ --outcome "migrate auth from sessions to JWT" \
42
+ --design "replace session middleware with HS256 tokens on all routes"
43
+ ```
44
+
45
+ ```
46
+ Preflight
47
+ ⚠ Sensitive areas: auth
48
+ ✗ No rollback strategy mentioned
49
+ ✗ No test strategy mentioned
50
+
51
+ Risk
52
+ Level: CRITICAL
53
+ Council mode: full
54
+ Reasons:
55
+ · authentication or authorisation logic
56
+
57
+ ⚠ Critical risk — human architecture review required before proceeding.
58
+ ```
59
+
60
+ Exit codes: `0` = low/medium, `1` = high, `2` = critical — pipe into CI scripts directly.
61
+ Also accepts JSON on stdin: `echo '{"outcome":"…","design":"…"}' | quorum check --json`
62
+
63
+ ### `quorum status` — see what's pending and what's been learned
64
+
65
+ ```bash
66
+ quorum status
67
+ ```
68
+
69
+ ```
70
+ Chronicle status .chronicle/
71
+
72
+ 8 committed entries (6 accepted, 1 refuted, 1 other)
73
+ 2 pending proposals
74
+
75
+ Pending proposals (awaiting quorum commit <id>)
76
+ a1b2c3d4 JWT key rotation approach needs RS256 not HS256
77
+ oracle/propose.ts, modules/auth/
78
+
79
+ Recent entries
80
+ e5f6a7b8 [accepted] Shadow column migration avoids exclusive lock on 50M rows 2d ago
81
+ ```
82
+
83
+ ### `quorum commit <id>` — the human gate from your terminal
84
+
85
+ ```bash
86
+ quorum commit --list # see pending proposals with full detail
87
+ quorum commit a1b2c3d4 # approve and index (supports partial ID prefix)
88
+ quorum commit a1b2c3d4 --dry-run # preview without writing
89
+ ```
90
+
91
+ Embeds the entry via the local ONNX model, upserts to LanceDB, writes to `.chronicle/committed/`, updates `SUMMARY.md`, and removes the proposal — the full oracle commit in one command. Requires `@xenova/transformers` and `vectordb` to be installed (both are optional deps from `quorum init`).
92
+
93
+ ### `quorum sentinel coverage` — see where Chronicle goes dark
94
+
95
+ ```bash
96
+ quorum sentinel coverage --path modules
97
+ ```
98
+
99
+ ```
100
+ Chronicle coverage modules/
101
+
102
+ ████░░░░░░░░░░░░░░░░ 20% (6/30 files)
103
+
104
+ Covered
105
+ ✓ oracle/propose.ts (3 entries)
106
+ ✓ oracle/query.ts (1 entry)
107
+
108
+ Uncovered (no Chronicle entries reference these files)
109
+ ✗ council/chairman.ts
110
+ ✗ jury/evaluate.ts
111
+
112
+ ```
113
+
114
+ ---
115
+
25
116
  ## Then just talk to your AI
26
117
 
27
118
  Once initialized, open your AI in agent mode and tell it:
@@ -54,7 +145,14 @@ Every proposal goes through Jury (confidence scoring against evidence) and Counc
54
145
 
55
146
  ### You approve what gets remembered
56
147
 
57
- When a decision is made, your AI stages a Chronicle entry using `oracle.propose()`. You approve it with `oracle.commit(proposalId)`. Nothing is indexed without your explicit sign-off.
148
+ When a decision is made, your AI stages a Chronicle entry using `oracle.propose()`. You approve it from the terminal:
149
+
150
+ ```bash
151
+ quorum commit --list # see what's pending
152
+ quorum commit <id> # approve and index
153
+ ```
154
+
155
+ Nothing is indexed without your explicit sign-off.
58
156
 
59
157
  ```
60
158
  .chronicle/
@@ -65,6 +163,12 @@ When a decision is made, your AI stages a Chronicle entry using `oracle.propose(
65
163
 
66
164
  Commit `.chronicle/committed/` to git. Future sessions — and your teammates' sessions — start with that context.
67
165
 
166
+ ### Every merged PR creates a Chronicle proposal automatically
167
+
168
+ A GitHub Actions workflow fires when any PR merges to main. It creates a Chronicle proposal capturing the decision, which files changed, and any explicitly deferred items from the PR description. The proposal sits in `proposals/` until you commit it — nothing is auto-indexed.
169
+
170
+ This means the gap between "PR merged" and "Chronicle knows about it" is now zero.
171
+
68
172
  ---
69
173
 
70
174
  ## Real examples
@@ -111,15 +215,36 @@ gaps: ["no lock strategy documented", "no rollback plan"]
111
215
  council_brief: challenge
112
216
  ```
113
217
 
114
- Council's Chairman gives a verdict:
115
-
116
- ```
117
- satisfied: false
118
- verdict: "On a table this size, a naive ALTER TABLE takes an exclusive lock for minutes.
119
- Specify a shadow column pattern or pg_repack. No rollback plan documented."
218
+ Council's Chairman gives a structured verdict:
219
+
220
+ ```json
221
+ {
222
+ "satisfied": false,
223
+ "blockers": [
224
+ {
225
+ "issue": "Naive ALTER TABLE takes an exclusive lock for minutes on a 50M-row table",
226
+ "evidence": ["db-017"],
227
+ "required_fix": "Use shadow column pattern or pg_repack. Add rollback path."
228
+ }
229
+ ],
230
+ "warnings": [],
231
+ "advisor_split": { "proceed": 0, "redesign": 4, "investigate-more": 1 }
232
+ }
120
233
  ```
121
234
 
122
- The agent revises the plan. You approve the Chronicle entry once it's solid. The reasoning is on record for the next time someone touches that table.
235
+ The agent revises the plan. You approve the Chronicle entry once it's solid. The reasoning — including alternatives considered and why they were rejected — is on record for the next time someone touches that table:
236
+
237
+ ```json
238
+ {
239
+ "decision": "Use shadow column pattern for NOT NULL migration on users table",
240
+ "alternatives_considered": ["naive ALTER TABLE", "pg_repack"],
241
+ "rejected_reason": ["ALTER TABLE takes exclusive lock for minutes on 50M rows"],
242
+ "scope": ["database", "migrations"],
243
+ "affected_areas": ["db/migrations/", "src/models/user.ts"],
244
+ "validation_plan": ["Confirm 100% backfill before applying NOT NULL constraint", "Test rollback path on staging"],
245
+ "review_after": "2026-08-01"
246
+ }
247
+ ```
123
248
 
124
249
  ---
125
250
 
@@ -130,21 +255,108 @@ Four portable TypeScript modules installed into `quorum/modules/`:
130
255
  | Module | What it does |
131
256
  |---|---|
132
257
  | **Oracle** | Query and write interface to Chronicle. No LLM required. |
133
- | **Jury** | Evaluates a proposed design against Chronicle evidence. Returns a confidence score. |
134
- | **Council** | A panel of advisors challenges the design independently, reviewers critique anonymously, a Chairman gives a final verdict. |
258
+ | **Jury** | Evaluates a proposed design against Chronicle evidence. Returns a decomposed confidence score and hard-blocker gaps. |
259
+ | **Council** | A panel of advisors challenges the design independently, reviewers critique anonymously, a Chairman gives a structured verdict with blockers and warnings. |
135
260
  | **Sentinel** | Shows which files the AI knows nothing about, flags stale knowledge, and posts a coverage map on every PR. |
136
261
 
137
262
  The modules live in your repo — readable by any AI working in the codebase. Nothing is hidden in `node_modules`.
138
263
 
139
264
  ---
140
265
 
266
+ ## How Jury works
267
+
268
+ Before calling the LLM, Jury runs a **deterministic preflight** — no LLM required — that checks whether the design touches sensitive areas (auth, database migrations, crypto, payments, PII, secrets), mentions a rollback strategy, and whether any refuted Chronicle entries conflict with the design. These facts are injected into the Jury prompt as hard ground truth.
269
+
270
+ The LLM then scores the design across four dimensions:
271
+
272
+ | Dimension | What it measures |
273
+ |---|---|
274
+ | Evidence support | Do validated Chronicle entries confirm this approach works here? |
275
+ | Feasibility | Do Chronicle entries suggest this is achievable? |
276
+ | Risk | How well does the design address known failure modes? |
277
+ | Completeness | Does the design cover the full outcome? |
278
+
279
+ Confidence is recomputed as the exact average of those four scores — the LLM's stated confidence is discarded. Jury also separates `blocking_gaps` (must resolve before proceeding) from `gaps` (useful but not critical).
280
+
281
+ ---
282
+
283
+ ## How Council works
284
+
285
+ Before running the full panel, a **risk classifier** reads the design text and Chronicle evidence and assigns a risk level:
286
+
287
+ | Risk | Council mode | LLM calls |
288
+ |---|---|---|
289
+ | Low | 1 advisor + 1 reviewer | 4 |
290
+ | Medium | 1 advisor + 2 reviewers | 5 |
291
+ | High | 5 advisors + 5 reviewers | 12 |
292
+ | Critical | 5 advisors + 5 reviewers (+ human architecture flag) | 12 |
293
+
294
+ Auth, crypto, payments, and data deletion trigger Critical. Database migrations, PII, permissions trigger High. Cache, queues, deployments trigger Medium. Everything else is Low.
295
+
296
+ The Chairman's verdict is **structured**:
297
+
298
+ ```json
299
+ {
300
+ "blockers": [
301
+ {
302
+ "issue": "No rollback plan for destructive migration",
303
+ "evidence": ["db-017"],
304
+ "required_fix": "Add shadow-column migration and rollback path before execution"
305
+ }
306
+ ],
307
+ "warnings": [
308
+ {
309
+ "issue": "No integration test for token expiry edge case",
310
+ "suggested_fix": "Add test covering token rotation during concurrent requests"
311
+ }
312
+ ],
313
+ "advisor_split": { "proceed": 2, "redesign": 2, "investigate-more": 1 }
314
+ }
315
+ ```
316
+
317
+ Blockers must be resolved before the human gate. Warnings can be ticketed. High `advisor_split` disagreement is surfaced explicitly — it means genuine uncertainty, not a safe proceed.
318
+
319
+ Every Oracle ID cited in the verdict is also validated against the evidence pack that was actually sent. Hallucinated citations are flagged in `citation_validation.hallucinated_ids` and stripped from the Chronicle proposal.
320
+
321
+ ---
322
+
323
+ ## Eval suite
324
+
325
+ `evals/` contains canonical test cases — known-bad proposals that Council should block and known-good ones it should pass:
326
+
327
+ | Case | Expected outcome |
328
+ |---|---|
329
+ | Naive NOT NULL migration on large table | Block — no lock strategy |
330
+ | HS256 JWT when RS256 was already chosen | Block — cites refuted entry auth-022 |
331
+ | PII fields logged to stdout | Block — GDPR violation in evidence |
332
+ | Payment charge without idempotency key | Block — duplicate charge risk |
333
+ | Redis sessions (previously removed) | Block — memory overhead already documented |
334
+ | Cache without stampede protection | Block — prior incident in Chronicle |
335
+ | Safe internal rename | Proceed — low risk, no conflicts |
336
+ | RS256 JWT (approved pattern) | Proceed — matches validated Chronicle entry |
337
+ | Migration with rollback + shadow column | Proceed — addresses documented failure mode |
338
+ | Novel WebSocket design, no evidence | Investigate-more — no Chronicle evidence either way |
339
+
340
+ Deterministic assertions (preflight, risk classifier) run on every CI pass. LLM-dependent assertions (confidence bounds, Council recommendation) activate with `EVAL_LLM=1`.
341
+
342
+ ```bash
343
+ npx vitest run evals/
344
+ ```
345
+
346
+ ---
347
+
141
348
  ## Sentinel — coverage and drift
142
349
 
143
350
  Sentinel surfaces two things Chronicle can't tell you about itself.
144
351
 
145
352
  **Coverage** — which parts of your codebase has the AI never documented?
146
353
 
147
- **Drift** — do existing Chronicle entries still accurately describe the code, or have they gone stale?
354
+ ```bash
355
+ quorum sentinel coverage --path src # quick check from the terminal
356
+ quorum sentinel coverage --json # machine-readable, for scripts
357
+ ```
358
+
359
+ **Drift** — do existing Chronicle entries still accurately describe the code, or have they gone stale? Drift detection requires an LLM; use `sentinelAssertions({ llm })` in your test suite (the CLI surfaces the message and directs you there).
148
360
 
149
361
  Add `sentinel-pr.yml` (included in `quorum/`) to your GitHub Actions and every PR gets a comment showing a full-project coverage table and a colour-coded heatmap. Changed modules are highlighted. Reviewers see exactly where knowledge is solid and where it goes dark.
150
362
 
package/SETUP.md CHANGED
@@ -176,6 +176,16 @@ It must be called once before any `oracle.query()`, `evaluate()`, or `deliberate
176
176
 
177
177
  If no entry point exists yet, note that `setup()` must be called before first use — do not inline it.
178
178
 
179
+ **Approving Chronicle proposals:** after an agent calls `oracle.propose()`, approve and index the entry from the terminal:
180
+
181
+ ```bash
182
+ quorum commit --list # see pending proposals
183
+ quorum commit <id> # approve and index a proposal
184
+ quorum commit <id> --dry-run # preview without writing
185
+ ```
186
+
187
+ Requires `@xenova/transformers` and `vectordb` (both added in Step 3).
188
+
179
189
  ---
180
190
 
181
191
  ## Step 7 — Verify Chronicle is created
@@ -197,11 +207,17 @@ If the directory is not created, re-check that `setup()` is being awaited correc
197
207
  Confirm the modules are working in this environment:
198
208
 
199
209
  ```bash
210
+ # Module unit tests
200
211
  npx vitest run quorum/modules/
212
+
213
+ # Eval suite — deterministic assertions, no LLM required
214
+ npx vitest run quorum/evals/
201
215
  ```
202
216
 
203
217
  All tests should pass. If they fail due to missing dependencies, re-run Step 3.
204
218
 
219
+ The eval suite runs canonical test cases (known-bad proposals that should block, known-good ones that should pass) through the deterministic preflight and risk classifier. These pass without any LLM. If you later want to test Jury confidence and Council recommendations against a real LLM, set `EVAL_LLM=1` when running.
220
+
205
221
  ---
206
222
 
207
223
  ## Step 9 — Report what was done
@@ -254,3 +270,17 @@ Key reminders:
254
270
  - **Query Oracle before proposing anything.** `oracle.query("what you're about to do")` first.
255
271
  - **Never call `oracle.commit()` autonomously.** Only `oracle.propose()`. A human commits.
256
272
  - **Chronicle entries are ground truth.** Respect `refuted` entries — do not retry what has already failed.
273
+
274
+ ### CLI quick reference
275
+
276
+ These commands are available globally after `npm install -g @balpal4495/quorum`:
277
+
278
+ | Command | What it does |
279
+ |---|---|
280
+ | `quorum status` | Chronicle health — pending proposals, committed entries |
281
+ | `quorum check --outcome X --design Y` | Preflight + risk classifier (no LLM) |
282
+ | `quorum commit --list` | List pending proposals |
283
+ | `quorum commit <id>` | Approve and index a proposal |
284
+ | `quorum sentinel coverage [--path <dir>]` | Chronicle coverage of source files |
285
+
286
+ `quorum check` exit codes: `0` = low/medium risk · `1` = high · `2` = critical
@@ -0,0 +1,122 @@
1
+ import { createInterface } from "readline"
2
+ import { c } from "../shared/colors.js"
3
+ import { runPreflight, classifyRisk } from "../shared/patterns.js"
4
+
5
+ function parseArgs(argv) {
6
+ const args = { outcome: "", design: "", json: false }
7
+ for (let i = 0; i < argv.length; i++) {
8
+ if ((argv[i] === "--outcome" || argv[i] === "-o") && argv[i + 1]) { args.outcome = argv[++i]; continue }
9
+ if ((argv[i] === "--design" || argv[i] === "-d") && argv[i + 1]) { args.design = argv[++i]; continue }
10
+ if (argv[i] === "--json") { args.json = true; continue }
11
+ }
12
+ return args
13
+ }
14
+
15
+ async function readStdin() {
16
+ if (process.stdin.isTTY) return null
17
+ return new Promise((resolve) => {
18
+ let data = ""
19
+ const rl = createInterface({ input: process.stdin })
20
+ rl.on("line", (line) => { data += line + "\n" })
21
+ rl.on("close", () => resolve(data.trim()))
22
+ })
23
+ }
24
+
25
+ function riskColor(level) {
26
+ switch (level) {
27
+ case "low": return c.green(level.toUpperCase())
28
+ case "medium": return c.yellow(level.toUpperCase())
29
+ case "high": return c.red(level.toUpperCase())
30
+ case "critical": return `${c.bold(c.red("CRITICAL"))}`
31
+ default: return level.toUpperCase()
32
+ }
33
+ }
34
+
35
+ function exitCodeForLevel(level) {
36
+ if (level === "critical") return 2
37
+ if (level === "high") return 1
38
+ return 0
39
+ }
40
+
41
+ export async function run(argv) {
42
+ const args = parseArgs(argv)
43
+
44
+ // Accept JSON from stdin if no flags provided
45
+ if (!args.outcome && !args.design) {
46
+ const stdin = await readStdin()
47
+ if (stdin) {
48
+ try {
49
+ const parsed = JSON.parse(stdin)
50
+ args.outcome = parsed.outcome ?? ""
51
+ args.design = parsed.design ?? ""
52
+ } catch {
53
+ console.error(c.red("stdin: expected JSON with { outcome, design } or use --outcome / --design flags"))
54
+ process.exit(1)
55
+ }
56
+ }
57
+ }
58
+
59
+ if (!args.outcome && !args.design) {
60
+ console.error(`\n${c.bold("quorum check")} — run preflight and risk classifier (no LLM required)\n`)
61
+ console.error("Usage:")
62
+ console.error(` quorum check --outcome "what you want to achieve" --design "how you plan to do it"`)
63
+ console.error(` echo '{"outcome":"...","design":"..."}' | quorum check`)
64
+ console.error(` quorum check --outcome "..." --design "..." --json\n`)
65
+ console.error("Exit codes: 0 = low/medium risk 1 = high risk 2 = critical risk\n")
66
+ process.exit(1)
67
+ }
68
+
69
+ const preflight = runPreflight(args.outcome, args.design)
70
+ const risk = classifyRisk(args.outcome, args.design)
71
+
72
+ if (args.json) {
73
+ console.log(JSON.stringify({ preflight, risk }, null, 2))
74
+ process.exit(exitCodeForLevel(risk.level))
75
+ }
76
+
77
+ // ── Human-readable output ─────────────────────────────────────────────────
78
+ console.log(`\n${c.bold("Preflight")}`)
79
+
80
+ if (preflight.touches_sensitive_area) {
81
+ console.log(` ${c.yellow("⚠")} Sensitive areas: ${c.yellow(preflight.sensitive_areas.join(", "))}`)
82
+ } else {
83
+ console.log(` ${c.green("✓")} No sensitive areas detected`)
84
+ }
85
+
86
+ console.log(preflight.rollback_mentioned
87
+ ? ` ${c.green("✓")} Rollback strategy mentioned`
88
+ : ` ${c.dim("✗")} No rollback strategy mentioned`)
89
+
90
+ console.log(preflight.test_strategy_mentioned
91
+ ? ` ${c.green("✓")} Test strategy mentioned`
92
+ : ` ${c.dim("✗")} No test strategy mentioned`)
93
+
94
+ console.log(`\n${c.bold("Risk")}`)
95
+ console.log(` Level: ${riskColor(risk.level)}`)
96
+ console.log(` Council mode: ${c.dim(risk.council_mode)}`)
97
+
98
+ if (risk.reasons.length > 0 && risk.reasons[0] !== "no sensitive patterns detected") {
99
+ console.log(` Reasons:`)
100
+ for (const reason of risk.reasons) {
101
+ console.log(` ${c.dim("·")} ${reason}`)
102
+ }
103
+ }
104
+
105
+ // ── Actionable guidance ───────────────────────────────────────────────────
106
+ if (risk.level === "critical") {
107
+ console.log(`\n ${c.red("⚠ Critical risk — human architecture review required before proceeding.")}`)
108
+ console.log(` ${c.dim(" Run the full Jury + Council pipeline and get explicit approval.")}`)
109
+ } else if (risk.level === "high") {
110
+ console.log(`\n ${c.yellow("⚠ High risk — full Council deliberation recommended.")}`)
111
+ if (!preflight.rollback_mentioned) {
112
+ console.log(` ${c.dim(" Add a rollback strategy before submitting for review.")}`)
113
+ }
114
+ } else if (risk.level === "medium") {
115
+ console.log(`\n ${c.dim(" Medium risk — Jury + lite Council review.")}`)
116
+ } else {
117
+ console.log(`\n ${c.dim(" Low risk — Jury-only review sufficient.")}`)
118
+ }
119
+
120
+ console.log("")
121
+ process.exit(exitCodeForLevel(risk.level))
122
+ }
@@ -0,0 +1,210 @@
1
+ import { promises as fs } from "fs"
2
+ import path from "path"
3
+ import { randomUUID } from "crypto"
4
+ import { exec } from "child_process"
5
+ import { promisify } from "util"
6
+ import { c } from "../shared/colors.js"
7
+ import { findChronicleDir, entryText, updateSummary } from "../shared/chronicle.js"
8
+
9
+ const execAsync = promisify(exec)
10
+
11
+ function parseArgs(argv) {
12
+ const args = { id: null, dryRun: false, list: false }
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i] === "--dry-run") { args.dryRun = true; continue }
15
+ if (argv[i] === "--list") { args.list = true; continue }
16
+ if (!argv[i].startsWith("-")) args.id = argv[i]
17
+ }
18
+ return args
19
+ }
20
+
21
+ async function checkDep(name) {
22
+ try {
23
+ await import(name)
24
+ return true
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ function spinner(msg) {
31
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
32
+ let i = 0
33
+ const interval = setInterval(() => {
34
+ process.stdout.write(`\r ${c.cyan(frames[i++ % frames.length])} ${msg}`)
35
+ }, 80)
36
+ return { stop: (finalMsg) => { clearInterval(interval); process.stdout.write(`\r ${finalMsg}\n`) } }
37
+ }
38
+
39
+ export async function run(argv) {
40
+ const args = parseArgs(argv)
41
+
42
+ const chronicleDir = await findChronicleDir(process.cwd())
43
+ if (!chronicleDir) {
44
+ console.error(`\n${c.red("No .chronicle/ directory found.")} Run ${c.bold("quorum init")} first.\n`)
45
+ process.exit(1)
46
+ }
47
+
48
+ // ── --list: show pending proposals ────────────────────────────────────────
49
+ if (args.list || (!args.id && argv.length === 0)) {
50
+ const proposalsDir = path.join(chronicleDir, "proposals")
51
+ let files
52
+ try { files = await fs.readdir(proposalsDir) } catch { files = [] }
53
+ const proposals = []
54
+ for (const f of files) {
55
+ if (!f.endsWith(".json")) continue
56
+ try {
57
+ const raw = await fs.readFile(path.join(proposalsDir, f), "utf8")
58
+ proposals.push({ id: f.replace(".json", ""), ...JSON.parse(raw) })
59
+ } catch { /* skip */ }
60
+ }
61
+ if (proposals.length === 0) {
62
+ console.log(`\n${c.dim("No pending proposals.")}\n`)
63
+ return
64
+ }
65
+ console.log(`\n${c.bold("Pending proposals")}\n`)
66
+ for (const p of proposals) {
67
+ console.log(` ${c.cyan(p.id)}`)
68
+ console.log(` ${c.bold("key_insight:")} ${entryText(p)}`)
69
+ console.log(` ${c.bold("areas:")} ${(p.affected_areas ?? []).join(", ")}`)
70
+ console.log(` ${c.bold("confidence:")} ${p.confidence}`)
71
+ if (p.status) console.log(` ${c.bold("status:")} ${p.status}`)
72
+ console.log("")
73
+ }
74
+ console.log(c.dim(` quorum commit <id> to approve and index a proposal`))
75
+ console.log("")
76
+ return
77
+ }
78
+
79
+ if (!args.id) {
80
+ console.error(`\n${c.bold("quorum commit")} — approve and index a Chronicle proposal\n`)
81
+ console.error("Usage:")
82
+ console.error(` quorum commit <proposalId> Commit and index the proposal`)
83
+ console.error(` quorum commit <proposalId> --dry-run Preview without writing`)
84
+ console.error(` quorum commit --list List pending proposals\n`)
85
+ process.exit(1)
86
+ }
87
+
88
+ // ── Find proposal (supports partial ID prefix) ────────────────────────────
89
+ const proposalsDir = path.join(chronicleDir, "proposals")
90
+ let files
91
+ try { files = await fs.readdir(proposalsDir) } catch { files = [] }
92
+
93
+ const match = files.find(f => f === `${args.id}.json` || f.startsWith(args.id))
94
+ if (!match) {
95
+ console.error(`\n${c.red("Proposal not found:")} ${args.id}`)
96
+ console.error(c.dim(` Run ${c.bold("quorum commit --list")} to see pending proposals.\n`))
97
+ process.exit(1)
98
+ }
99
+
100
+ const proposalId = match.replace(".json", "")
101
+ const proposalPath = path.join(proposalsDir, match)
102
+
103
+ let raw
104
+ try { raw = await fs.readFile(proposalPath, "utf8") } catch {
105
+ console.error(`\n${c.red("Could not read proposal:")} ${proposalPath}\n`)
106
+ process.exit(1)
107
+ }
108
+ const partial = JSON.parse(raw)
109
+
110
+ // ── Dry run ────────────────────────────────────────────────────────────────
111
+ if (args.dryRun) {
112
+ console.log(`\n${c.bold("Dry run")} — would commit proposal ${c.cyan(proposalId.slice(0, 8))}\n`)
113
+ console.log(` ${c.bold("key_insight:")} ${entryText(partial)}`)
114
+ console.log(` ${c.bold("areas:")} ${(partial.affected_areas ?? []).join(", ")}`)
115
+ console.log(` ${c.bold("status:")} ${partial.status}`)
116
+ console.log(` ${c.bold("confidence:")} ${partial.confidence}`)
117
+ if (partial.scope?.length) console.log(` ${c.bold("scope:")} ${partial.scope.join(", ")}`)
118
+ console.log(`\n ${c.dim("(No changes made.)")}\n`)
119
+ return
120
+ }
121
+
122
+ // ── Check optional dependencies ────────────────────────────────────────────
123
+ console.log(`\n${c.bold("Checking dependencies")}`)
124
+ const hasXenova = await checkDep("@xenova/transformers")
125
+ const hasLanceDB = await checkDep("vectordb")
126
+
127
+ if (!hasXenova) {
128
+ console.error(`\n ${c.red("✗")} @xenova/transformers not installed`)
129
+ console.error(c.dim(" Run: npm install @xenova/transformers\n"))
130
+ process.exit(1)
131
+ }
132
+ if (!hasLanceDB) {
133
+ console.error(`\n ${c.red("✗")} vectordb not installed`)
134
+ console.error(c.dim(" Run: npm install vectordb\n"))
135
+ process.exit(1)
136
+ }
137
+ console.log(` ${c.green("✓")} @xenova/transformers`)
138
+ console.log(` ${c.green("✓")} vectordb`)
139
+
140
+ // ── Build entry ────────────────────────────────────────────────────────────
141
+ const entry = {
142
+ ...partial,
143
+ id: randomUUID(),
144
+ timestamp: new Date().toISOString(),
145
+ }
146
+
147
+ // ── Embed ──────────────────────────────────────────────────────────────────
148
+ const spin = spinner("Loading embedder (first run may take 30s)…")
149
+ let vector
150
+ try {
151
+ const { pipeline } = (await import("@xenova/transformers")).default ?? await import("@xenova/transformers")
152
+ const embedder = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2")
153
+ const embeddingText = [
154
+ entryText(entry),
155
+ ...(entry.affected_areas ?? []),
156
+ ...(entry.scope ?? []),
157
+ ].join(" ")
158
+ const output = await embedder(embeddingText, { pooling: "mean", normalize: true })
159
+ vector = Array.from(output.data)
160
+ spin.stop(`${c.green("✓")} Embedded (${vector.length}-dim)`)
161
+ } catch (err) {
162
+ spin.stop(`${c.red("✗")} Embedding failed`)
163
+ console.error(c.red(`\n ${err.message}\n`))
164
+ process.exit(1)
165
+ }
166
+
167
+ // ── Store in LanceDB ───────────────────────────────────────────────────────
168
+ const storeSpin = spinner("Indexing in Chronicle…")
169
+ try {
170
+ const lancedb = (await import("vectordb")).default ?? (await import("vectordb"))
171
+ const tableDir = path.join(chronicleDir, "entries")
172
+ const db = await lancedb.connect(tableDir)
173
+ const row = { id: entry.id, vector, payload: JSON.stringify(entry) }
174
+ const names = await db.tableNames()
175
+ if (names.includes("entries")) {
176
+ const table = await db.openTable("entries")
177
+ await table.delete(`id = '${entry.id.replace(/'/g, "''")}'`)
178
+ await table.add([row])
179
+ } else {
180
+ await db.createTable("entries", [row], { metric: "cosine" })
181
+ }
182
+ storeSpin.stop(`${c.green("✓")} Indexed in vector store`)
183
+ } catch (err) {
184
+ storeSpin.stop(`${c.red("✗")} Vector store write failed`)
185
+ console.error(c.red(`\n ${err.message}\n`))
186
+ process.exit(1)
187
+ }
188
+
189
+ // ── Write committed file ───────────────────────────────────────────────────
190
+ const committedDir = path.join(chronicleDir, "committed")
191
+ const committedPath = path.join(committedDir, `${entry.id}.json`)
192
+ await fs.mkdir(committedDir, { recursive: true })
193
+ await fs.writeFile(committedPath, JSON.stringify(entry, null, 2), "utf8")
194
+
195
+ // Git add — best-effort
196
+ try { await execAsync(`git add "${committedPath}"`) } catch { /* not in git, or git unavailable */ }
197
+
198
+ // Update SUMMARY.md — best-effort
199
+ try { await updateSummary(chronicleDir) } catch { /* never fail a commit */ }
200
+
201
+ // Remove proposal
202
+ await fs.unlink(proposalPath)
203
+
204
+ // ── Result ─────────────────────────────────────────────────────────────────
205
+ console.log(`\n${c.green("✓ Committed")} ${c.dim(entry.id)}`)
206
+ console.log(` ${c.bold("key_insight:")} ${entryText(entry)}`)
207
+ console.log(` ${c.bold("areas:")} ${(entry.affected_areas ?? []).join(", ")}`)
208
+ console.log(` ${c.bold("status:")} ${entry.status}`)
209
+ console.log("")
210
+ }