@balpal4495/quorum 3.7.0 → 3.8.1

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/bin/mcp/server.js CHANGED
@@ -10,6 +10,10 @@
10
10
  * GET /api/growth Memory health report
11
11
  * POST /api/proposals/:id/commit Human-gate: approve a proposal
12
12
  * DELETE /api/proposals/:id Reject / delete a proposal
13
+ * POST /api/advisor Ask a question answered from Chronicle (LLM)
14
+ * POST /api/check Instant risk triage (no LLM)
15
+ * POST /api/ingest Ingest files, git history, or URLs
16
+ * GET /api/sentinel/drift Structural drift check
13
17
  *
14
18
  * MCP also exposes resources:
15
19
  * chronicle://summary chronicle://proposals
@@ -28,6 +32,10 @@ import {
28
32
  toolCoverage,
29
33
  toolGrowth,
30
34
  toolCompass,
35
+ toolAdvisor,
36
+ toolCheck,
37
+ toolIngest,
38
+ toolSentinelDrift,
31
39
  commitProposal,
32
40
  deleteProposal,
33
41
  updateProposal,
@@ -318,6 +326,33 @@ export async function createServer({ projectRoot, chronicleDir, llm = null }) {
318
326
  return json(res, 200, result)
319
327
  }
320
328
 
329
+ // ── REST: advisor ───────────────────────────────────────────────────────
330
+ if (pathname === "/api/advisor" && req.method === "POST") {
331
+ const body = await readBody(req)
332
+ const result = await toolAdvisor({ question: body.question, projectRoot })
333
+ return json(res, 200, result)
334
+ }
335
+
336
+ // ── REST: check ─────────────────────────────────────────────────────────
337
+ if (pathname === "/api/check" && req.method === "POST") {
338
+ const body = await readBody(req)
339
+ const result = await toolCheck({ outcome: body.outcome ?? "", design: body.design ?? "", projectRoot })
340
+ return json(res, 200, result)
341
+ }
342
+
343
+ // ── REST: ingest ────────────────────────────────────────────────────────
344
+ if (pathname === "/api/ingest" && req.method === "POST") {
345
+ const body = await readBody(req)
346
+ const result = await toolIngest({ ...body, projectRoot })
347
+ return json(res, 200, result)
348
+ }
349
+
350
+ // ── REST: sentinel drift ────────────────────────────────────────────────
351
+ if (pathname === "/api/sentinel/drift" && req.method === "GET") {
352
+ const result = await toolSentinelDrift({ projectRoot })
353
+ return json(res, 200, result)
354
+ }
355
+
321
356
  // ── Web UI ──────────────────────────────────────────────────────────────
322
357
  if ((pathname === "/" || pathname === "/index.html") && req.method === "GET") {
323
358
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
package/bin/mcp/tools.js CHANGED
@@ -288,7 +288,8 @@ export async function toolAdvisor({ question, projectRoot } = {}) {
288
288
 
289
289
  const store = await createLanceDBStore(chronicleDir)
290
290
  const oracle = createOracleClient({ store, embed: xenovaEmbed })
291
- const result = await ask({ question, oracle, llm: _llm })
291
+ const evidence = await oracle.query(question)
292
+ const result = await ask({ question, evidence }, { llm: _llm })
292
293
  return result
293
294
  }
294
295
 
@@ -326,6 +327,163 @@ export async function toolCompass({ subcommand = "brief", goal, idea, projectRoo
326
327
  return { subcommand, data, output: data ? null : raw }
327
328
  }
328
329
 
330
+ /**
331
+ * Ingest files, git history, or a URL into .chronicle/sources/ and
332
+ * .chronicle/evidence/ as low-trust drafts (confidence 0.4).
333
+ * Returns { added, skipped, items } — no console output, no process.exit.
334
+ */
335
+ export async function toolIngest({ type = "git", paths, since = "P90D", urls, propose = false, projectRoot } = {}) {
336
+ const { promisify } = await import("util")
337
+ const { execFile } = await import("child_process")
338
+ const { createHash, randomUUID: uuid } = await import("crypto")
339
+ const execFileAsync = promisify(execFile)
340
+
341
+ const { projectRoot: root, chronicleDir } = await resolve(projectRoot)
342
+ const sourcesDir = path.join(chronicleDir, "sources")
343
+ const evidenceDir = path.join(chronicleDir, "evidence")
344
+ const proposalsDir = path.join(chronicleDir, "proposals")
345
+ await fs.mkdir(sourcesDir, { recursive: true })
346
+ await fs.mkdir(evidenceDir, { recursive: true })
347
+ if (propose) await fs.mkdir(proposalsDir, { recursive: true })
348
+
349
+ // Load existing content hashes to skip duplicates
350
+ const existingHashes = new Set()
351
+ for (const f of await fs.readdir(sourcesDir).catch(() => [])) {
352
+ if (!f.endsWith(".json")) continue
353
+ try {
354
+ const s = JSON.parse(await fs.readFile(path.join(sourcesDir, f), "utf8"))
355
+ if (s.content_hash) existingHashes.add(s.content_hash)
356
+ } catch { /* skip malformed */ }
357
+ }
358
+
359
+ async function writeRecord({ hash, kind, sourceRef, title, summary, scope }) {
360
+ const id = uuid()
361
+ const ts = new Date().toISOString()
362
+ await fs.writeFile(path.join(sourcesDir, `${id}.json`), JSON.stringify(
363
+ { id, kind, source_ref: sourceRef, content_hash: hash, ingested_at: ts, schema_version: 2 }, null, 2), "utf8")
364
+
365
+ const evidenceId = uuid()
366
+ const evidence = {
367
+ id: evidenceId, schema_version: 2,
368
+ topic: `ingest/${kind}/${title.slice(0, 40).replace(/\s+/g, "-").toLowerCase()}`,
369
+ key_insight: summary.slice(0, 200), decision: summary.slice(0, 200),
370
+ scope, affected_areas: [], status: "open", confidence: 0.4,
371
+ source_quality: "metadata-derived", needs_human_summary: true,
372
+ source_module: "ingest", evidence_cited: [],
373
+ alternatives_considered: [], rejected_reason: [],
374
+ ingested_at: ts, source_id: id,
375
+ }
376
+ await fs.writeFile(path.join(evidenceDir, `${evidenceId}.json`), JSON.stringify(evidence, null, 2), "utf8")
377
+ if (propose) {
378
+ const propId = uuid()
379
+ await fs.writeFile(path.join(proposalsDir, `${propId}.json`), JSON.stringify({ ...evidence, id: propId }, null, 2), "utf8")
380
+ }
381
+ }
382
+
383
+ let added = 0, skipped = 0
384
+ const items = []
385
+
386
+ if (type === "git") {
387
+ // Parse ISO 8601 duration PnD/PnM/PnY safely — never raw user input to shell
388
+ const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?$/.exec(since ?? "P90D")
389
+ const days = match ? (parseInt(match[1] ?? "0") * 365 + parseInt(match[2] ?? "0") * 30 + parseInt(match[3] ?? "0")) : 90
390
+ const sinceArg = `${days > 0 ? days : 90} days ago`
391
+
392
+ let stdout
393
+ try {
394
+ const res = await execFileAsync("git", ["log", `--since=${sinceArg}`, "--format=%H|%s|%ae|%ad", "--date=iso"], { cwd: root })
395
+ stdout = res.stdout.trim()
396
+ } catch { return { added: 0, skipped: 0, items: [], error: "git log failed — is this a git repository?" } }
397
+
398
+ for (const line of stdout.split("\n").filter(Boolean)) {
399
+ const [commitHash, subject = ""] = line.split("|")
400
+ if (!commitHash) continue
401
+ const fingerprint = createHash("sha256").update(commitHash).digest("hex").slice(0, 16)
402
+ if (existingHashes.has(fingerprint)) { skipped++; continue }
403
+ const short = commitHash.slice(0, 7)
404
+ await writeRecord({ hash: fingerprint, kind: "git-commit", sourceRef: commitHash, title: subject, summary: `${short}: ${subject}`, scope: ["source", "git"] })
405
+ items.push({ ref: short, summary: subject.slice(0, 80) })
406
+ added++
407
+ }
408
+ } else if (type === "url") {
409
+ const urlList = Array.isArray(urls) ? urls : (urls ? [urls] : [])
410
+ for (const u of urlList.filter(Boolean)) {
411
+ let parsed
412
+ try { parsed = new URL(u) } catch { skipped++; continue }
413
+ if (!["http:", "https:"].includes(parsed.protocol)) { skipped++; continue }
414
+ const fingerprint = createHash("sha256").update(u).digest("hex").slice(0, 16)
415
+ if (existingHashes.has(fingerprint)) { skipped++; continue }
416
+ let text = ""
417
+ try {
418
+ const res = await fetch(u, { signal: AbortSignal.timeout(15000) })
419
+ const html = await res.text()
420
+ text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 2000)
421
+ } catch { skipped++; continue }
422
+ const title = parsed.pathname.split("/").filter(Boolean).pop() ?? parsed.hostname
423
+ const summary = text.slice(0, 200) || u
424
+ await writeRecord({ hash: fingerprint, kind: "url", sourceRef: u, title, summary, scope: ["docs"] })
425
+ items.push({ ref: u.slice(0, 60), summary: summary.slice(0, 80) })
426
+ added++
427
+ }
428
+ } else if (type === "files") {
429
+ const TEXT_EXTS = new Set([".md", ".txt", ".js", ".mjs", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", ".toml", ".sh", ".html", ".css", ".csv"])
430
+ const pathList = Array.isArray(paths) ? paths : (paths ? String(paths).split(",").map(p => p.trim()) : [])
431
+ for (const p of pathList.filter(Boolean)) {
432
+ const abs = path.isAbsolute(p) ? p : path.join(root, p)
433
+ if (!TEXT_EXTS.has(path.extname(abs).toLowerCase())) { skipped++; continue }
434
+ let content
435
+ try { content = await fs.readFile(abs, "utf8") } catch { skipped++; continue }
436
+ const fingerprint = createHash("sha256").update(content.slice(0, 3000)).digest("hex").slice(0, 16)
437
+ if (existingHashes.has(fingerprint)) { skipped++; continue }
438
+ const rel = path.relative(root, abs).replace(/\\/g, "/")
439
+ const lines = content.split("\n").map(l => l.trim()).filter(Boolean)
440
+ const summary = (lines.find(l => l.startsWith("#")) ?? lines[0] ?? rel).replace(/^#+\s*/, "").slice(0, 200)
441
+ await writeRecord({ hash: fingerprint, kind: "file", sourceRef: rel, title: path.basename(abs), summary, scope: ["docs"] })
442
+ items.push({ ref: rel, summary: summary.slice(0, 80) })
443
+ added++
444
+ }
445
+ }
446
+
447
+ return { added, skipped, items: items.slice(0, 30) }
448
+ }
449
+
450
+ /**
451
+ * Structural drift check — Chronicle entries whose affected_areas paths no
452
+ * longer exist as files in the codebase. LLM-free and fast.
453
+ */
454
+ export async function toolSentinelDrift({ projectRoot } = {}) {
455
+ const { projectRoot: root, chronicleDir } = await resolve(projectRoot)
456
+ const entries = await readCommitted(chronicleDir)
457
+
458
+ const flags = []
459
+ for (const entry of entries) {
460
+ if (!entry.affected_areas?.length) continue
461
+ const missingFiles = []
462
+ for (const area of entry.affected_areas) {
463
+ const abs = path.join(root, area)
464
+ const exists = await fs.access(abs).then(() => true).catch(() => false)
465
+ if (!exists) missingFiles.push(area)
466
+ }
467
+ if (missingFiles.length > 0) {
468
+ flags.push({
469
+ entryId: (entry.id ?? "").slice(0, 8),
470
+ topic: entry.topic,
471
+ decision: (entry.decision ?? entry.key_insight ?? "").slice(0, 160),
472
+ missingFiles,
473
+ confidence: entry.confidence,
474
+ status: entry.status,
475
+ })
476
+ }
477
+ }
478
+
479
+ return {
480
+ total: entries.length,
481
+ flagged: flags.length,
482
+ flags,
483
+ note: "Structural check: entries whose affected_areas paths no longer exist. For semantic drift (did the code meaning change?) run: quorum sentinel --drift from the CLI.",
484
+ }
485
+ }
486
+
329
487
  /**
330
488
  * Call once at server startup to wire the LLM provider into LLM-powered tools.
331
489
  */
package/bin/ui/app.html CHANGED
@@ -553,6 +553,173 @@
553
553
  border-radius: 0 var(--radius) var(--radius) 0;
554
554
  }
555
555
 
556
+ /* ── Advisor chat ── */
557
+ .chat-wrap {
558
+ display: flex;
559
+ flex-direction: column;
560
+ gap: 16px;
561
+ max-height: 520px;
562
+ overflow-y: auto;
563
+ padding-bottom: 4px;
564
+ margin-bottom: 14px;
565
+ }
566
+ .chat-bubble {
567
+ display: flex;
568
+ flex-direction: column;
569
+ gap: 5px;
570
+ max-width: 92%;
571
+ }
572
+ .chat-bubble--user { align-self: flex-end; }
573
+ .chat-bubble--ai { align-self: flex-start; }
574
+ .chat-label {
575
+ font-size: 11px;
576
+ font-weight: 700;
577
+ text-transform: uppercase;
578
+ letter-spacing: .06em;
579
+ color: var(--muted);
580
+ }
581
+ .chat-bubble--user .chat-label { text-align: right; }
582
+ .chat-body {
583
+ font-size: 13px;
584
+ line-height: 1.6;
585
+ padding: 10px 14px;
586
+ border-radius: var(--radius);
587
+ }
588
+ .chat-bubble--user .chat-body {
589
+ background: rgba(124,110,255,.15);
590
+ color: var(--text);
591
+ border-radius: var(--radius) var(--radius) 2px var(--radius);
592
+ }
593
+ .chat-bubble--ai .chat-body {
594
+ background: var(--surface);
595
+ border: 1px solid var(--border);
596
+ color: var(--text);
597
+ border-radius: 2px var(--radius) var(--radius) var(--radius);
598
+ }
599
+ .chat-citations {
600
+ display: flex;
601
+ flex-wrap: wrap;
602
+ gap: 4px;
603
+ margin-top: 4px;
604
+ }
605
+ .chat-citation {
606
+ font-size: 11px;
607
+ font-family: var(--mono);
608
+ padding: 2px 6px;
609
+ border-radius: 4px;
610
+ background: rgba(82,168,224,.1);
611
+ color: var(--blue);
612
+ }
613
+ .chat-confidence {
614
+ font-size: 11px;
615
+ color: var(--muted);
616
+ margin-top: 4px;
617
+ }
618
+ .chat-input-row {
619
+ display: flex;
620
+ gap: 8px;
621
+ }
622
+ .chat-input-row input {
623
+ flex: 1;
624
+ padding: 10px 14px;
625
+ background: var(--surface);
626
+ border: 1px solid var(--border);
627
+ border-radius: var(--radius);
628
+ color: var(--text);
629
+ font: inherit;
630
+ font-size: 13px;
631
+ outline: none;
632
+ transition: border-color .15s;
633
+ }
634
+ .chat-input-row input:focus { border-color: var(--accent); }
635
+
636
+ /* ── Check ── */
637
+ .risk-banner {
638
+ display: flex;
639
+ align-items: center;
640
+ gap: 12px;
641
+ padding: 14px 16px;
642
+ border-radius: var(--radius);
643
+ margin-bottom: 16px;
644
+ border: 1px solid var(--border);
645
+ }
646
+ .risk-level {
647
+ font-size: 18px;
648
+ font-weight: 800;
649
+ text-transform: uppercase;
650
+ letter-spacing: .06em;
651
+ }
652
+ .risk-critical { background: rgba(224,82,82,.12); color: var(--red); border-color: rgba(224,82,82,.3); }
653
+ .risk-high { background: rgba(224,185,82,.12); color: var(--yellow); border-color: rgba(224,185,82,.3); }
654
+ .risk-medium { background: rgba(82,168,224,.10); color: var(--blue); border-color: rgba(82,168,224,.3); }
655
+ .risk-low { background: rgba(52,201,122,.10); color: var(--green); border-color: rgba(52,201,122,.3); }
656
+ .preflight-row {
657
+ display: flex;
658
+ align-items: center;
659
+ gap: 8px;
660
+ font-size: 13px;
661
+ margin-bottom: 6px;
662
+ }
663
+ .preflight-ok { color: var(--green); }
664
+ .preflight-bad { color: var(--yellow); }
665
+
666
+ /* ── Ingest ── */
667
+ .ingest-section {
668
+ background: var(--surface);
669
+ border: 1px solid var(--border);
670
+ border-radius: var(--radius);
671
+ padding: 16px;
672
+ margin-bottom: 14px;
673
+ }
674
+ .ingest-section-title {
675
+ font-size: 13px;
676
+ font-weight: 700;
677
+ margin-bottom: 10px;
678
+ color: var(--text);
679
+ }
680
+ .ingest-result {
681
+ margin-top: 10px;
682
+ font-size: 12px;
683
+ font-family: var(--mono);
684
+ color: var(--muted);
685
+ line-height: 1.8;
686
+ }
687
+
688
+ /* ── Sentinel drift ── */
689
+ .drift-flag {
690
+ background: var(--surface);
691
+ border: 1px solid var(--border);
692
+ border-left: 3px solid var(--yellow);
693
+ border-radius: 0 var(--radius) var(--radius) 0;
694
+ padding: 12px 14px;
695
+ margin-bottom: 10px;
696
+ }
697
+ .drift-flag-title {
698
+ font-size: 13px;
699
+ font-weight: 600;
700
+ color: var(--text);
701
+ margin-bottom: 4px;
702
+ }
703
+ .drift-flag-body {
704
+ font-size: 12px;
705
+ color: var(--muted);
706
+ line-height: 1.5;
707
+ }
708
+ .drift-missing {
709
+ display: flex;
710
+ flex-wrap: wrap;
711
+ gap: 4px;
712
+ margin-top: 6px;
713
+ }
714
+ .drift-path {
715
+ font-family: var(--mono);
716
+ font-size: 11px;
717
+ padding: 2px 6px;
718
+ background: rgba(224,82,82,.1);
719
+ color: var(--red);
720
+ border-radius: 4px;
721
+ }
722
+
556
723
  /* ── Edit modal ── */
557
724
  .modal-overlay {
558
725
  display: none;
@@ -667,6 +834,10 @@
667
834
  <button onclick="showTab('coverage')">Coverage</button>
668
835
  <button onclick="showTab('growth')">Growth</button>
669
836
  <button onclick="showTab('compass')">Compass</button>
837
+ <button onclick="showTab('advisor')">Advisor</button>
838
+ <button onclick="showTab('check')">Check</button>
839
+ <button onclick="showTab('ingest')">Ingest</button>
840
+ <button onclick="showTab('sentinel')">Sentinel</button>
670
841
  </nav>
671
842
  </header>
672
843
 
@@ -715,6 +886,98 @@
715
886
  </div>
716
887
  <div id="compassView"><div class="empty">Select a subcommand and click Run.<small>Requires an LLM provider configured for quorum serve.</small></div></div>
717
888
  </div>
889
+
890
+ <!-- ── Advisor tab ──────────────────────────────────────────────── -->
891
+ <div id="tab-advisor" class="tab">
892
+ <h2 class="section-heading">Advisor</h2>
893
+ <p class="section-sub">Ask plain-language questions answered from Chronicle memory.</p>
894
+ <div class="chat-wrap" id="advisorChat"></div>
895
+ <div class="chat-input-row">
896
+ <input id="advisorInput" type="text" placeholder="Ask anything about your codebase…" autocomplete="off"
897
+ onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendAdvisorMsg()}">
898
+ <button class="btn" onclick="sendAdvisorMsg()">Ask</button>
899
+ </div>
900
+ <p style="font-size:12px;color:var(--muted);margin-top:8px">Requires an LLM provider. Answers are grounded in Chronicle entries.</p>
901
+ </div>
902
+
903
+ <!-- ── Check tab ────────────────────────────────────────────────── -->
904
+ <div id="tab-check" class="tab">
905
+ <h2 class="section-heading">Risk Check</h2>
906
+ <p class="section-sub">Instant risk triage against known sensitive patterns — no LLM required.</p>
907
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
908
+ <div class="field">
909
+ <label>Outcome (what changes)</label>
910
+ <textarea id="checkOutcome" rows="5" placeholder="e.g. Add JWT refresh token rotation to the auth service…"></textarea>
911
+ </div>
912
+ <div class="field">
913
+ <label>Design / approach</label>
914
+ <textarea id="checkDesign" rows="5" placeholder="e.g. Store tokens in Redis with 7-day TTL, invalidate on logout…"></textarea>
915
+ </div>
916
+ </div>
917
+ <button class="btn" onclick="runCheck()">Check risk</button>
918
+ <div id="checkResult" style="margin-top:16px"></div>
919
+ </div>
920
+
921
+ <!-- ── Ingest tab ───────────────────────────────────────────────── -->
922
+ <div id="tab-ingest" class="tab">
923
+ <h2 class="section-heading">Ingest</h2>
924
+ <p class="section-sub">Add low-trust evidence to Chronicle from git history, files, or URLs. Nothing is committed automatically — run <code style="font-family:var(--mono);font-size:12px">quorum commit --list</code> to review.</p>
925
+
926
+ <div class="ingest-section">
927
+ <div class="ingest-section-title">Git history</div>
928
+ <div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
929
+ <div class="field" style="margin:0;flex:1;min-width:140px">
930
+ <label>Since (ISO 8601 duration)</label>
931
+ <input id="ingestGitSince" type="text" value="P90D" placeholder="P90D">
932
+ </div>
933
+ <div style="display:flex;align-items:center;gap:6px;padding-bottom:1px">
934
+ <input type="checkbox" id="ingestGitPropose">
935
+ <label for="ingestGitPropose" style="font-size:13px">Also stage as proposals</label>
936
+ </div>
937
+ <button class="btn" style="flex:none" onclick="runIngest('git')">Ingest commits</button>
938
+ </div>
939
+ <div class="ingest-result" id="ingestGitResult"></div>
940
+ </div>
941
+
942
+ <div class="ingest-section">
943
+ <div class="ingest-section-title">Files</div>
944
+ <div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
945
+ <div class="field" style="margin:0;flex:1;min-width:200px">
946
+ <label>Paths (comma-separated, relative to project root)</label>
947
+ <input id="ingestFilePaths" type="text" placeholder="docs/RFC.md, src/lib/triage.ts">
948
+ </div>
949
+ <div style="display:flex;align-items:center;gap:6px;padding-bottom:1px">
950
+ <input type="checkbox" id="ingestFilePropose">
951
+ <label for="ingestFilePropose" style="font-size:13px">Also stage as proposals</label>
952
+ </div>
953
+ <button class="btn" style="flex:none" onclick="runIngest('files')">Ingest files</button>
954
+ </div>
955
+ <div class="ingest-result" id="ingestFileResult"></div>
956
+ </div>
957
+
958
+ <div class="ingest-section">
959
+ <div class="ingest-section-title">URL</div>
960
+ <div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
961
+ <div class="field" style="margin:0;flex:1;min-width:260px">
962
+ <label>URL (http/https only)</label>
963
+ <input id="ingestUrl" type="text" placeholder="https://example.com/rfc">
964
+ </div>
965
+ <div style="display:flex;align-items:center;gap:6px;padding-bottom:1px">
966
+ <input type="checkbox" id="ingestUrlPropose">
967
+ <label for="ingestUrlPropose" style="font-size:13px">Also stage as proposals</label>
968
+ </div>
969
+ <button class="btn" style="flex:none" onclick="runIngest('url')">Ingest URL</button>
970
+ </div>
971
+ <div class="ingest-result" id="ingestUrlResult"></div>
972
+ </div>
973
+ </div>
974
+
975
+ <!-- ── Sentinel tab ─────────────────────────────────────────────── -->
976
+ <div id="tab-sentinel" class="tab">
977
+ <h2 class="section-heading">Sentinel</h2>
978
+ <p class="section-sub">Chronicle entries whose referenced files no longer exist — structural drift detection.</p>
979
+ <div id="sentinelView"><div class="loading">Loading…</div></div>
980
+ </div>
718
981
  </main>
719
982
 
720
983
  <!-- ── Edit proposal modal ───────────────────────────────────────────── -->
@@ -771,7 +1034,7 @@ window.addEventListener("DOMContentLoaded", () => {
771
1034
 
772
1035
  // ── Tab switching ──────────────────────────────────────────────────────────
773
1036
 
774
- const TAB_NAMES = ["chronicle", "proposals", "coverage", "growth", "compass"]
1037
+ const TAB_NAMES = ["chronicle", "proposals", "coverage", "growth", "compass", "advisor", "check", "ingest", "sentinel"]
775
1038
 
776
1039
  function showTab(name) {
777
1040
  document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"))
@@ -780,8 +1043,9 @@ function showTab(name) {
780
1043
  })
781
1044
  document.getElementById(`tab-${name}`).classList.add("active")
782
1045
  activeTab = name
783
- if (name === "coverage" && !coverageData) loadCoverage()
784
- if (name === "growth" && !growthData) loadGrowth()
1046
+ if (name === "coverage" && !coverageData) loadCoverage()
1047
+ if (name === "growth" && !growthData) loadGrowth()
1048
+ if (name === "sentinel" && !sentinelLoaded) loadSentinel()
785
1049
  }
786
1050
 
787
1051
  // ── Toast ──────────────────────────────────────────────────────────────────
@@ -1315,6 +1579,220 @@ function renderCompass(data, subcommand) {
1315
1579
 
1316
1580
  el.innerHTML = `<div style="padding-bottom:16px">${inner}</div>`
1317
1581
  }
1582
+
1583
+ // ── Advisor ───────────────────────────────────────────────────────────────
1584
+
1585
+ let advisorHistory = [] // { role: "user"|"ai", text, citations, confidence }
1586
+
1587
+ function renderAdvisorHistory() {
1588
+ const wrap = document.getElementById("advisorChat")
1589
+ if (!advisorHistory.length) {
1590
+ wrap.innerHTML = `<div class="empty" style="margin:0">No messages yet. Ask a question above.<small>e.g. "What decisions have been made about auth?" or "What should I tackle next?"</small></div>`
1591
+ return
1592
+ }
1593
+ wrap.innerHTML = advisorHistory.map(m => {
1594
+ if (m.role === "user") {
1595
+ return `<div class="chat-bubble chat-bubble--user">
1596
+ <span class="chat-label">You</span>
1597
+ <div class="chat-body">${esc(m.text)}</div>
1598
+ </div>`
1599
+ }
1600
+ const confPct = m.confidence != null ? Math.round(m.confidence * 100) : null
1601
+ const confHtml = confPct != null ? `<div class="chat-confidence">${confPct}% confidence</div>` : ""
1602
+ const citHtml = m.citations?.length
1603
+ ? `<div class="chat-citations">${m.citations.map(c => `<span class="chat-citation">${esc(c)}</span>`).join("")}</div>`
1604
+ : ""
1605
+ const parts = []
1606
+ if (m.what_we_know) parts.push(`<strong>What we know:</strong> ${esc(m.what_we_know)}`)
1607
+ if (m.recommendation) parts.push(`<strong>Recommendation:</strong> ${esc(m.recommendation)}`)
1608
+ if (m.next_step) parts.push(`<strong>Next step:</strong> ${esc(m.next_step)}`)
1609
+ if (m.risks?.length) parts.push(`<strong>Risks:</strong> ${m.risks.map(r => esc(r)).join("; ")}`)
1610
+ return `<div class="chat-bubble chat-bubble--ai">
1611
+ <span class="chat-label">Advisor</span>
1612
+ <div class="chat-body">${parts.length ? parts.join("<br>") : esc(m.text)}</div>
1613
+ ${confHtml}${citHtml}
1614
+ </div>`
1615
+ }).join("")
1616
+ wrap.scrollTop = wrap.scrollHeight
1617
+ }
1618
+
1619
+ async function sendAdvisorMsg() {
1620
+ const input = document.getElementById("advisorInput")
1621
+ const question = input.value.trim()
1622
+ if (!question) return
1623
+ input.value = ""
1624
+ advisorHistory.push({ role: "user", text: question })
1625
+ renderAdvisorHistory()
1626
+
1627
+ // Placeholder while waiting
1628
+ advisorHistory.push({ role: "ai", text: "Thinking…", _pending: true })
1629
+ renderAdvisorHistory()
1630
+
1631
+ try {
1632
+ const res = await fetch("/api/advisor", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ question }) })
1633
+ const data = await res.json()
1634
+ // Remove placeholder
1635
+ advisorHistory = advisorHistory.filter(m => !m._pending)
1636
+
1637
+ if (data.status === "no-llm") {
1638
+ advisorHistory.push({ role: "ai", text: "No LLM provider configured. " + data.message })
1639
+ } else if (data.error) {
1640
+ advisorHistory.push({ role: "ai", text: "Error: " + data.error })
1641
+ } else {
1642
+ const citations = (data.evidence ?? []).map(e => (e.id ?? "").slice(0, 8)).filter(Boolean)
1643
+ advisorHistory.push({
1644
+ role: "ai",
1645
+ what_we_know: data.what_we_know,
1646
+ recommendation: data.recommendation,
1647
+ next_step: data.next_step,
1648
+ risks: data.risks,
1649
+ confidence: data.confidence,
1650
+ citations,
1651
+ })
1652
+ }
1653
+ } catch (err) {
1654
+ advisorHistory = advisorHistory.filter(m => !m._pending)
1655
+ advisorHistory.push({ role: "ai", text: "Request failed: " + err.message })
1656
+ }
1657
+ renderAdvisorHistory()
1658
+ }
1659
+
1660
+ // ── Check ─────────────────────────────────────────────────────────────────
1661
+
1662
+ async function runCheck() {
1663
+ const outcome = document.getElementById("checkOutcome").value.trim()
1664
+ const design = document.getElementById("checkDesign").value.trim()
1665
+ if (!outcome && !design) { toast("Enter an outcome or design first", "err"); return }
1666
+
1667
+ const el = document.getElementById("checkResult")
1668
+ el.innerHTML = `<div class="loading">Analysing…</div>`
1669
+
1670
+ try {
1671
+ const res = await fetch("/api/check", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ outcome, design }) })
1672
+ const data = await res.json()
1673
+ if (data.error) { el.innerHTML = `<div class="empty">Error: ${esc(data.error)}</div>`; return }
1674
+
1675
+ const { risk, preflight } = data
1676
+ const level = risk?.level ?? "low"
1677
+ const riskLabel = { critical: "Critical", high: "High", medium: "Medium", low: "Low" }[level] ?? level
1678
+ const reasons = (risk?.reasons ?? []).filter(r => r !== "no sensitive patterns detected")
1679
+
1680
+ let html = `<div class="risk-banner risk-${level}">
1681
+ <span class="risk-level">${riskLabel}</span>
1682
+ <span style="font-size:13px">${reasons.length ? reasons.join(" · ") : "No sensitive patterns detected"}</span>
1683
+ </div>`
1684
+
1685
+ html += `<div style="display:flex;flex-wrap:wrap;gap:16px">`
1686
+
1687
+ // Preflight flags
1688
+ html += `<div style="min-width:200px">`
1689
+ html += `<div class="cp-section-label" style="margin-bottom:8px">Preflight flags</div>`
1690
+ const sensitive = preflight?.sensitive_areas ?? []
1691
+ if (sensitive.length) {
1692
+ html += sensitive.map(a => `<div class="preflight-row preflight-bad"><span>⚠</span> ${esc(a)}</div>`).join("")
1693
+ } else {
1694
+ html += `<div class="preflight-row preflight-ok"><span>✓</span> No sensitive areas</div>`
1695
+ }
1696
+ html += `<div class="preflight-row ${preflight?.rollback_mentioned ? "preflight-ok" : "preflight-bad"}">
1697
+ <span>${preflight?.rollback_mentioned ? "✓" : "·"}</span> Rollback ${preflight?.rollback_mentioned ? "mentioned" : "not mentioned"}
1698
+ </div>`
1699
+ html += `<div class="preflight-row ${preflight?.test_strategy_mentioned ? "preflight-ok" : "preflight-bad"}">
1700
+ <span>${preflight?.test_strategy_mentioned ? "✓" : "·"}</span> Tests ${preflight?.test_strategy_mentioned ? "mentioned" : "not mentioned"}
1701
+ </div>`
1702
+ html += `</div></div>`
1703
+
1704
+ el.innerHTML = html
1705
+ } catch (err) {
1706
+ el.innerHTML = `<div class="empty">Request failed: ${esc(err.message)}</div>`
1707
+ }
1708
+ }
1709
+
1710
+ // ── Ingest ────────────────────────────────────────────────────────────────
1711
+
1712
+ async function runIngest(type) {
1713
+ let body = { type }
1714
+ let resultEl
1715
+
1716
+ if (type === "git") {
1717
+ body.since = document.getElementById("ingestGitSince").value.trim() || "P90D"
1718
+ body.propose = document.getElementById("ingestGitPropose").checked
1719
+ resultEl = document.getElementById("ingestGitResult")
1720
+ } else if (type === "files") {
1721
+ const raw = document.getElementById("ingestFilePaths").value.trim()
1722
+ if (!raw) { toast("Enter at least one file path", "err"); return }
1723
+ body.paths = raw
1724
+ body.propose = document.getElementById("ingestFilePropose").checked
1725
+ resultEl = document.getElementById("ingestFileResult")
1726
+ } else if (type === "url") {
1727
+ const u = document.getElementById("ingestUrl").value.trim()
1728
+ if (!u) { toast("Enter a URL", "err"); return }
1729
+ body.urls = u
1730
+ body.propose = document.getElementById("ingestUrlPropose").checked
1731
+ resultEl = document.getElementById("ingestUrlResult")
1732
+ }
1733
+
1734
+ resultEl.textContent = "Running…"
1735
+ try {
1736
+ const res = await fetch("/api/ingest", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
1737
+ const data = await res.json()
1738
+ if (data.error) { resultEl.textContent = "Error: " + data.error; return }
1739
+
1740
+ let out = `✓ ${data.added} added, ${data.skipped} skipped`
1741
+ if (body.propose && data.added > 0) out += ` — proposals staged, run quorum commit --list to review`
1742
+ if (data.items?.length) {
1743
+ out += "\n" + data.items.slice(0, 8).map(i => ` ${i.ref} ${i.summary}`).join("\n")
1744
+ if (data.items.length > 8) out += `\n … and ${data.items.length - 8} more`
1745
+ }
1746
+ resultEl.textContent = out
1747
+ if (data.added > 0) toast(`Ingested ${data.added} ${type} item${data.added !== 1 ? "s" : ""}`)
1748
+ } catch (err) {
1749
+ resultEl.textContent = "Request failed: " + err.message
1750
+ }
1751
+ }
1752
+
1753
+ // ── Sentinel ──────────────────────────────────────────────────────────────
1754
+
1755
+ let sentinelLoaded = false
1756
+
1757
+ async function loadSentinel() {
1758
+ sentinelLoaded = true
1759
+ const el = document.getElementById("sentinelView")
1760
+ try {
1761
+ const res = await fetch("/api/sentinel/drift")
1762
+ const data = await res.json()
1763
+ renderSentinel(data)
1764
+ } catch (err) {
1765
+ el.innerHTML = `<div class="empty">Failed to load sentinel data<small>${esc(err.message)}</small></div>`
1766
+ }
1767
+ }
1768
+
1769
+ function renderSentinel(data) {
1770
+ const el = document.getElementById("sentinelView")
1771
+ const { total = 0, flagged = 0, flags = [], note = "" } = data
1772
+
1773
+ let html = `<div style="display:flex;gap:20px;margin-bottom:18px;flex-wrap:wrap">`
1774
+ html += `<div style="text-align:center;min-width:100px"><div style="font-size:28px;font-weight:800;color:var(--text)">${total}</div><div style="font-size:12px;color:var(--muted)">Chronicle entries</div></div>`
1775
+ html += `<div style="text-align:center;min-width:100px"><div style="font-size:28px;font-weight:800;color:${flagged > 0 ? "var(--yellow)" : "var(--green)"}">${flagged}</div><div style="font-size:12px;color:var(--muted)">Structurally drifted</div></div>`
1776
+ html += `</div>`
1777
+
1778
+ if (note) html += `<p style="font-size:12px;color:var(--muted);margin-bottom:16px">${esc(note)}</p>`
1779
+
1780
+ if (!flags.length) {
1781
+ html += `<div class="empty">No drift detected — all affected_areas paths resolve to existing files.</div>`
1782
+ } else {
1783
+ for (const f of flags) {
1784
+ html += `<div class="drift-flag">
1785
+ <div class="drift-flag-title">[${esc(f.entryId)}] ${esc(f.topic)}</div>
1786
+ <div class="drift-flag-body">${esc(f.decision)}</div>
1787
+ <div class="drift-missing">
1788
+ ${f.missingFiles.map(p => `<span class="drift-path">${esc(p)}</span>`).join("")}
1789
+ </div>
1790
+ </div>`
1791
+ }
1792
+ }
1793
+
1794
+ el.innerHTML = html
1795
+ }
1318
1796
  </script>
1319
1797
  </body>
1320
1798
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@balpal4495/quorum",
3
- "version": "3.7.0",
3
+ "version": "3.8.1",
4
4
  "description": "Git-backed memory and design review for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",