@balpal4495/quorum 3.6.0 → 3.8.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/bin/commands/compass.js +9 -3
- package/bin/mcp/server.js +35 -0
- package/bin/mcp/tools.js +167 -6
- package/bin/ui/app.html +726 -13
- package/package.json +1 -1
package/bin/commands/compass.js
CHANGED
|
@@ -753,7 +753,13 @@ async function loadLastArtifact(chronicleDir) {
|
|
|
753
753
|
|
|
754
754
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
755
755
|
|
|
756
|
-
|
|
756
|
+
/**
|
|
757
|
+
* @param {string[]} argv
|
|
758
|
+
* @param {Function|null} [injectedLlm] - Pre-detected LLM provider. When
|
|
759
|
+
* supplied, provider detection is skipped entirely (avoids the ~1.5 s Ollama
|
|
760
|
+
* probe on every MCP/serve request).
|
|
761
|
+
*/
|
|
762
|
+
export async function run(argv, injectedLlm) {
|
|
757
763
|
const [subcommand, ...rest] = argv
|
|
758
764
|
|
|
759
765
|
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
@@ -794,8 +800,8 @@ export async function run(argv) {
|
|
|
794
800
|
}
|
|
795
801
|
|
|
796
802
|
const NO_LLM_CMDS = new Set(["map", "opportunities", "behavior", "propose", "outcome"])
|
|
797
|
-
const
|
|
798
|
-
|
|
803
|
+
const llm = injectedLlm
|
|
804
|
+
?? (NO_LLM_CMDS.has(subcommand) ? null : (await detectProvider())?.llm)
|
|
799
805
|
|
|
800
806
|
// ── Shared context helper ─────────────────────────────────────────────────
|
|
801
807
|
|
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
|
@@ -304,22 +304,183 @@ export async function toolCheck({ outcome, design, projectRoot } = {}) {
|
|
|
304
304
|
export async function toolCompass({ subcommand = "brief", goal, idea, projectRoot } = {}) {
|
|
305
305
|
if (!_llm) return NO_LLM("quorum_compass")
|
|
306
306
|
|
|
307
|
-
const { chronicleDir } = await resolve(projectRoot)
|
|
308
|
-
// Delegate to the compass CLI command handler for now
|
|
309
307
|
const { run: compassRun } = await import("../commands/compass.js")
|
|
310
|
-
|
|
308
|
+
|
|
309
|
+
// Capture stdout — always request JSON so there are no ANSI codes
|
|
311
310
|
const captured = []
|
|
312
311
|
const origWrite = process.stdout.write.bind(process.stdout)
|
|
313
312
|
process.stdout.write = (chunk, ...rest) => { captured.push(String(chunk)); return true }
|
|
314
313
|
try {
|
|
315
|
-
const extraArgs = []
|
|
314
|
+
const extraArgs = ["--json"]
|
|
316
315
|
if (subcommand === "pathways" && goal) extraArgs.push("--goal", goal)
|
|
317
316
|
if (subcommand === "score" && idea) extraArgs.push("--idea", idea)
|
|
318
|
-
|
|
317
|
+
// Pass _llm directly to skip the ~1.5 s provider re-detection on every request
|
|
318
|
+
await compassRun([subcommand, ...extraArgs], _llm)
|
|
319
319
|
} finally {
|
|
320
320
|
process.stdout.write = origWrite
|
|
321
321
|
}
|
|
322
|
-
|
|
322
|
+
|
|
323
|
+
const raw = captured.join("").trim()
|
|
324
|
+
let data = null
|
|
325
|
+
try { data = JSON.parse(raw) } catch { /* fallback to raw string below */ }
|
|
326
|
+
return { subcommand, data, output: data ? null : raw }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Ingest files, git history, or a URL into .chronicle/sources/ and
|
|
331
|
+
* .chronicle/evidence/ as low-trust drafts (confidence 0.4).
|
|
332
|
+
* Returns { added, skipped, items } — no console output, no process.exit.
|
|
333
|
+
*/
|
|
334
|
+
export async function toolIngest({ type = "git", paths, since = "P90D", urls, propose = false, projectRoot } = {}) {
|
|
335
|
+
const { promisify } = await import("util")
|
|
336
|
+
const { execFile } = await import("child_process")
|
|
337
|
+
const { createHash, randomUUID: uuid } = await import("crypto")
|
|
338
|
+
const execFileAsync = promisify(execFile)
|
|
339
|
+
|
|
340
|
+
const { projectRoot: root, chronicleDir } = await resolve(projectRoot)
|
|
341
|
+
const sourcesDir = path.join(chronicleDir, "sources")
|
|
342
|
+
const evidenceDir = path.join(chronicleDir, "evidence")
|
|
343
|
+
const proposalsDir = path.join(chronicleDir, "proposals")
|
|
344
|
+
await fs.mkdir(sourcesDir, { recursive: true })
|
|
345
|
+
await fs.mkdir(evidenceDir, { recursive: true })
|
|
346
|
+
if (propose) await fs.mkdir(proposalsDir, { recursive: true })
|
|
347
|
+
|
|
348
|
+
// Load existing content hashes to skip duplicates
|
|
349
|
+
const existingHashes = new Set()
|
|
350
|
+
for (const f of await fs.readdir(sourcesDir).catch(() => [])) {
|
|
351
|
+
if (!f.endsWith(".json")) continue
|
|
352
|
+
try {
|
|
353
|
+
const s = JSON.parse(await fs.readFile(path.join(sourcesDir, f), "utf8"))
|
|
354
|
+
if (s.content_hash) existingHashes.add(s.content_hash)
|
|
355
|
+
} catch { /* skip malformed */ }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function writeRecord({ hash, kind, sourceRef, title, summary, scope }) {
|
|
359
|
+
const id = uuid()
|
|
360
|
+
const ts = new Date().toISOString()
|
|
361
|
+
await fs.writeFile(path.join(sourcesDir, `${id}.json`), JSON.stringify(
|
|
362
|
+
{ id, kind, source_ref: sourceRef, content_hash: hash, ingested_at: ts, schema_version: 2 }, null, 2), "utf8")
|
|
363
|
+
|
|
364
|
+
const evidenceId = uuid()
|
|
365
|
+
const evidence = {
|
|
366
|
+
id: evidenceId, schema_version: 2,
|
|
367
|
+
topic: `ingest/${kind}/${title.slice(0, 40).replace(/\s+/g, "-").toLowerCase()}`,
|
|
368
|
+
key_insight: summary.slice(0, 200), decision: summary.slice(0, 200),
|
|
369
|
+
scope, affected_areas: [], status: "open", confidence: 0.4,
|
|
370
|
+
source_quality: "metadata-derived", needs_human_summary: true,
|
|
371
|
+
source_module: "ingest", evidence_cited: [],
|
|
372
|
+
alternatives_considered: [], rejected_reason: [],
|
|
373
|
+
ingested_at: ts, source_id: id,
|
|
374
|
+
}
|
|
375
|
+
await fs.writeFile(path.join(evidenceDir, `${evidenceId}.json`), JSON.stringify(evidence, null, 2), "utf8")
|
|
376
|
+
if (propose) {
|
|
377
|
+
const propId = uuid()
|
|
378
|
+
await fs.writeFile(path.join(proposalsDir, `${propId}.json`), JSON.stringify({ ...evidence, id: propId }, null, 2), "utf8")
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let added = 0, skipped = 0
|
|
383
|
+
const items = []
|
|
384
|
+
|
|
385
|
+
if (type === "git") {
|
|
386
|
+
// Parse ISO 8601 duration PnD/PnM/PnY safely — never raw user input to shell
|
|
387
|
+
const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?$/.exec(since ?? "P90D")
|
|
388
|
+
const days = match ? (parseInt(match[1] ?? "0") * 365 + parseInt(match[2] ?? "0") * 30 + parseInt(match[3] ?? "0")) : 90
|
|
389
|
+
const sinceArg = `${days > 0 ? days : 90} days ago`
|
|
390
|
+
|
|
391
|
+
let stdout
|
|
392
|
+
try {
|
|
393
|
+
const res = await execFileAsync("git", ["log", `--since=${sinceArg}`, "--format=%H|%s|%ae|%ad", "--date=iso"], { cwd: root })
|
|
394
|
+
stdout = res.stdout.trim()
|
|
395
|
+
} catch { return { added: 0, skipped: 0, items: [], error: "git log failed — is this a git repository?" } }
|
|
396
|
+
|
|
397
|
+
for (const line of stdout.split("\n").filter(Boolean)) {
|
|
398
|
+
const [commitHash, subject = ""] = line.split("|")
|
|
399
|
+
if (!commitHash) continue
|
|
400
|
+
const fingerprint = createHash("sha256").update(commitHash).digest("hex").slice(0, 16)
|
|
401
|
+
if (existingHashes.has(fingerprint)) { skipped++; continue }
|
|
402
|
+
const short = commitHash.slice(0, 7)
|
|
403
|
+
await writeRecord({ hash: fingerprint, kind: "git-commit", sourceRef: commitHash, title: subject, summary: `${short}: ${subject}`, scope: ["source", "git"] })
|
|
404
|
+
items.push({ ref: short, summary: subject.slice(0, 80) })
|
|
405
|
+
added++
|
|
406
|
+
}
|
|
407
|
+
} else if (type === "url") {
|
|
408
|
+
const urlList = Array.isArray(urls) ? urls : (urls ? [urls] : [])
|
|
409
|
+
for (const u of urlList.filter(Boolean)) {
|
|
410
|
+
let parsed
|
|
411
|
+
try { parsed = new URL(u) } catch { skipped++; continue }
|
|
412
|
+
if (!["http:", "https:"].includes(parsed.protocol)) { skipped++; continue }
|
|
413
|
+
const fingerprint = createHash("sha256").update(u).digest("hex").slice(0, 16)
|
|
414
|
+
if (existingHashes.has(fingerprint)) { skipped++; continue }
|
|
415
|
+
let text = ""
|
|
416
|
+
try {
|
|
417
|
+
const res = await fetch(u, { signal: AbortSignal.timeout(15000) })
|
|
418
|
+
const html = await res.text()
|
|
419
|
+
text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 2000)
|
|
420
|
+
} catch { skipped++; continue }
|
|
421
|
+
const title = parsed.pathname.split("/").filter(Boolean).pop() ?? parsed.hostname
|
|
422
|
+
const summary = text.slice(0, 200) || u
|
|
423
|
+
await writeRecord({ hash: fingerprint, kind: "url", sourceRef: u, title, summary, scope: ["docs"] })
|
|
424
|
+
items.push({ ref: u.slice(0, 60), summary: summary.slice(0, 80) })
|
|
425
|
+
added++
|
|
426
|
+
}
|
|
427
|
+
} else if (type === "files") {
|
|
428
|
+
const TEXT_EXTS = new Set([".md", ".txt", ".js", ".mjs", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", ".toml", ".sh", ".html", ".css", ".csv"])
|
|
429
|
+
const pathList = Array.isArray(paths) ? paths : (paths ? String(paths).split(",").map(p => p.trim()) : [])
|
|
430
|
+
for (const p of pathList.filter(Boolean)) {
|
|
431
|
+
const abs = path.isAbsolute(p) ? p : path.join(root, p)
|
|
432
|
+
if (!TEXT_EXTS.has(path.extname(abs).toLowerCase())) { skipped++; continue }
|
|
433
|
+
let content
|
|
434
|
+
try { content = await fs.readFile(abs, "utf8") } catch { skipped++; continue }
|
|
435
|
+
const fingerprint = createHash("sha256").update(content.slice(0, 3000)).digest("hex").slice(0, 16)
|
|
436
|
+
if (existingHashes.has(fingerprint)) { skipped++; continue }
|
|
437
|
+
const rel = path.relative(root, abs).replace(/\\/g, "/")
|
|
438
|
+
const lines = content.split("\n").map(l => l.trim()).filter(Boolean)
|
|
439
|
+
const summary = (lines.find(l => l.startsWith("#")) ?? lines[0] ?? rel).replace(/^#+\s*/, "").slice(0, 200)
|
|
440
|
+
await writeRecord({ hash: fingerprint, kind: "file", sourceRef: rel, title: path.basename(abs), summary, scope: ["docs"] })
|
|
441
|
+
items.push({ ref: rel, summary: summary.slice(0, 80) })
|
|
442
|
+
added++
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { added, skipped, items: items.slice(0, 30) }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Structural drift check — Chronicle entries whose affected_areas paths no
|
|
451
|
+
* longer exist as files in the codebase. LLM-free and fast.
|
|
452
|
+
*/
|
|
453
|
+
export async function toolSentinelDrift({ projectRoot } = {}) {
|
|
454
|
+
const { projectRoot: root, chronicleDir } = await resolve(projectRoot)
|
|
455
|
+
const entries = await readCommitted(chronicleDir)
|
|
456
|
+
|
|
457
|
+
const flags = []
|
|
458
|
+
for (const entry of entries) {
|
|
459
|
+
if (!entry.affected_areas?.length) continue
|
|
460
|
+
const missingFiles = []
|
|
461
|
+
for (const area of entry.affected_areas) {
|
|
462
|
+
const abs = path.join(root, area)
|
|
463
|
+
const exists = await fs.access(abs).then(() => true).catch(() => false)
|
|
464
|
+
if (!exists) missingFiles.push(area)
|
|
465
|
+
}
|
|
466
|
+
if (missingFiles.length > 0) {
|
|
467
|
+
flags.push({
|
|
468
|
+
entryId: (entry.id ?? "").slice(0, 8),
|
|
469
|
+
topic: entry.topic,
|
|
470
|
+
decision: (entry.decision ?? entry.key_insight ?? "").slice(0, 160),
|
|
471
|
+
missingFiles,
|
|
472
|
+
confidence: entry.confidence,
|
|
473
|
+
status: entry.status,
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
total: entries.length,
|
|
480
|
+
flagged: flags.length,
|
|
481
|
+
flags,
|
|
482
|
+
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.",
|
|
483
|
+
}
|
|
323
484
|
}
|
|
324
485
|
|
|
325
486
|
/**
|
package/bin/ui/app.html
CHANGED
|
@@ -408,18 +408,316 @@
|
|
|
408
408
|
.hint-stalled { color: var(--red); }
|
|
409
409
|
|
|
410
410
|
/* ── Compass ── */
|
|
411
|
-
.
|
|
411
|
+
.cp-header {
|
|
412
|
+
display: flex;
|
|
413
|
+
align-items: baseline;
|
|
414
|
+
gap: 10px;
|
|
415
|
+
margin-bottom: 14px;
|
|
416
|
+
}
|
|
417
|
+
.cp-title {
|
|
418
|
+
font-size: 15px;
|
|
419
|
+
font-weight: 700;
|
|
420
|
+
color: var(--text);
|
|
421
|
+
}
|
|
422
|
+
.cp-badge {
|
|
423
|
+
font-size: 11px;
|
|
424
|
+
padding: 2px 8px;
|
|
425
|
+
border-radius: 20px;
|
|
426
|
+
font-weight: 600;
|
|
427
|
+
letter-spacing: .03em;
|
|
428
|
+
}
|
|
429
|
+
.cp-badge--hi { background: rgba(52,201,122,.15); color: var(--green); }
|
|
430
|
+
.cp-badge--mid { background: rgba(224,185,82,.15); color: var(--yellow); }
|
|
431
|
+
.cp-badge--lo { background: rgba(224,82,82,.15); color: var(--red); }
|
|
432
|
+
.cp-direction {
|
|
433
|
+
font-size: 14px;
|
|
434
|
+
font-weight: 500;
|
|
435
|
+
color: var(--text);
|
|
436
|
+
line-height: 1.6;
|
|
437
|
+
margin-bottom: 20px;
|
|
438
|
+
padding: 12px 14px;
|
|
439
|
+
background: rgba(124,110,255,.08);
|
|
440
|
+
border-left: 3px solid var(--accent);
|
|
441
|
+
border-radius: 0 var(--radius) var(--radius) 0;
|
|
442
|
+
}
|
|
443
|
+
.cp-section {
|
|
444
|
+
margin-bottom: 16px;
|
|
445
|
+
}
|
|
446
|
+
.cp-section-label {
|
|
447
|
+
font-size: 11px;
|
|
448
|
+
font-weight: 700;
|
|
449
|
+
text-transform: uppercase;
|
|
450
|
+
letter-spacing: .08em;
|
|
451
|
+
color: var(--muted);
|
|
452
|
+
margin-bottom: 6px;
|
|
453
|
+
}
|
|
454
|
+
.cp-list {
|
|
455
|
+
list-style: none;
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-direction: column;
|
|
458
|
+
gap: 5px;
|
|
459
|
+
}
|
|
460
|
+
.cp-item {
|
|
461
|
+
display: flex;
|
|
462
|
+
gap: 8px;
|
|
463
|
+
font-size: 13px;
|
|
464
|
+
line-height: 1.5;
|
|
465
|
+
}
|
|
466
|
+
.cp-icon {
|
|
467
|
+
flex-shrink: 0;
|
|
468
|
+
width: 16px;
|
|
469
|
+
text-align: center;
|
|
470
|
+
margin-top: 1px;
|
|
471
|
+
}
|
|
472
|
+
.cp-known .cp-icon { color: var(--green); }
|
|
473
|
+
.cp-inferred .cp-icon { color: var(--yellow); }
|
|
474
|
+
.cp-unknown .cp-icon { color: var(--muted); }
|
|
475
|
+
.cp-next-step {
|
|
476
|
+
margin-top: 20px;
|
|
477
|
+
padding: 12px 14px;
|
|
412
478
|
background: var(--surface);
|
|
413
479
|
border: 1px solid var(--border);
|
|
414
480
|
border-radius: var(--radius);
|
|
415
|
-
|
|
416
|
-
|
|
481
|
+
display: flex;
|
|
482
|
+
flex-direction: column;
|
|
483
|
+
gap: 4px;
|
|
484
|
+
}
|
|
485
|
+
.cp-next-label {
|
|
486
|
+
font-size: 11px;
|
|
487
|
+
font-weight: 700;
|
|
488
|
+
text-transform: uppercase;
|
|
489
|
+
letter-spacing: .08em;
|
|
490
|
+
color: var(--muted);
|
|
491
|
+
}
|
|
492
|
+
.cp-next-text {
|
|
493
|
+
font-size: 13px;
|
|
494
|
+
color: var(--text);
|
|
495
|
+
line-height: 1.5;
|
|
496
|
+
}
|
|
497
|
+
/* map / bets / opportunities */
|
|
498
|
+
.cp-card {
|
|
499
|
+
background: var(--surface);
|
|
500
|
+
border: 1px solid var(--border);
|
|
501
|
+
border-radius: var(--radius);
|
|
502
|
+
padding: 14px;
|
|
503
|
+
margin-bottom: 10px;
|
|
504
|
+
}
|
|
505
|
+
.cp-card-header {
|
|
506
|
+
display: flex;
|
|
507
|
+
align-items: center;
|
|
508
|
+
gap: 10px;
|
|
509
|
+
margin-bottom: 6px;
|
|
510
|
+
}
|
|
511
|
+
.cp-card-title {
|
|
512
|
+
font-size: 13px;
|
|
513
|
+
font-weight: 600;
|
|
514
|
+
color: var(--text);
|
|
515
|
+
flex: 1;
|
|
516
|
+
}
|
|
517
|
+
.cp-card-score {
|
|
417
518
|
font-size: 12px;
|
|
418
|
-
|
|
519
|
+
font-weight: 700;
|
|
520
|
+
padding: 2px 8px;
|
|
521
|
+
border-radius: 20px;
|
|
522
|
+
}
|
|
523
|
+
.cp-score-hi { background: rgba(52,201,122,.15); color: var(--green); }
|
|
524
|
+
.cp-score-mid { background: rgba(224,185,82,.15); color: var(--yellow); }
|
|
525
|
+
.cp-score-lo { background: rgba(110,110,126,.15); color: var(--muted); }
|
|
526
|
+
.cp-card-body {
|
|
527
|
+
font-size: 13px;
|
|
419
528
|
color: var(--muted);
|
|
420
|
-
|
|
529
|
+
line-height: 1.5;
|
|
530
|
+
}
|
|
531
|
+
.cp-card-meta {
|
|
532
|
+
font-size: 12px;
|
|
533
|
+
color: var(--muted);
|
|
534
|
+
margin-top: 6px;
|
|
535
|
+
}
|
|
536
|
+
.cp-card-meta span { margin-right: 12px; }
|
|
537
|
+
.cp-area-tag {
|
|
538
|
+
display: inline-block;
|
|
539
|
+
font-size: 11px;
|
|
540
|
+
padding: 1px 6px;
|
|
541
|
+
border-radius: 4px;
|
|
542
|
+
background: rgba(82,168,224,.12);
|
|
543
|
+
color: var(--blue);
|
|
544
|
+
margin-right: 4px;
|
|
545
|
+
}
|
|
546
|
+
.cp-gap-item {
|
|
547
|
+
font-size: 13px;
|
|
548
|
+
color: var(--yellow);
|
|
549
|
+
padding: 8px 12px;
|
|
550
|
+
border-left: 2px solid var(--yellow);
|
|
551
|
+
margin-bottom: 6px;
|
|
552
|
+
background: rgba(224,185,82,.05);
|
|
553
|
+
border-radius: 0 var(--radius) var(--radius) 0;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* ── Advisor chat ── */
|
|
557
|
+
.chat-wrap {
|
|
558
|
+
display: flex;
|
|
559
|
+
flex-direction: column;
|
|
560
|
+
gap: 16px;
|
|
561
|
+
max-height: 520px;
|
|
421
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;
|
|
422
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;
|
|
423
721
|
}
|
|
424
722
|
|
|
425
723
|
/* ── Edit modal ── */
|
|
@@ -536,6 +834,14 @@
|
|
|
536
834
|
<button onclick="showTab('coverage')">Coverage</button>
|
|
537
835
|
<button onclick="showTab('growth')">Growth</button>
|
|
538
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>
|
|
841
|
+
<button onclick="showTab('advisor')">Advisor</button>
|
|
842
|
+
<button onclick="showTab('check')">Check</button>
|
|
843
|
+
<button onclick="showTab('ingest')">Ingest</button>
|
|
844
|
+
<button onclick="showTab('sentinel')">Sentinel</button>
|
|
539
845
|
</nav>
|
|
540
846
|
</header>
|
|
541
847
|
|
|
@@ -584,6 +890,98 @@
|
|
|
584
890
|
</div>
|
|
585
891
|
<div id="compassView"><div class="empty">Select a subcommand and click Run.<small>Requires an LLM provider configured for quorum serve.</small></div></div>
|
|
586
892
|
</div>
|
|
893
|
+
|
|
894
|
+
<!-- ── Advisor tab ──────────────────────────────────────────────── -->
|
|
895
|
+
<div id="tab-advisor" class="tab">
|
|
896
|
+
<h2 class="section-heading">Advisor</h2>
|
|
897
|
+
<p class="section-sub">Ask plain-language questions answered from Chronicle memory.</p>
|
|
898
|
+
<div class="chat-wrap" id="advisorChat"></div>
|
|
899
|
+
<div class="chat-input-row">
|
|
900
|
+
<input id="advisorInput" type="text" placeholder="Ask anything about your codebase…" autocomplete="off"
|
|
901
|
+
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendAdvisorMsg()}">
|
|
902
|
+
<button class="btn" onclick="sendAdvisorMsg()">Ask</button>
|
|
903
|
+
</div>
|
|
904
|
+
<p style="font-size:12px;color:var(--muted);margin-top:8px">Requires an LLM provider. Answers are grounded in Chronicle entries.</p>
|
|
905
|
+
</div>
|
|
906
|
+
|
|
907
|
+
<!-- ── Check tab ────────────────────────────────────────────────── -->
|
|
908
|
+
<div id="tab-check" class="tab">
|
|
909
|
+
<h2 class="section-heading">Risk Check</h2>
|
|
910
|
+
<p class="section-sub">Instant risk triage against known sensitive patterns — no LLM required.</p>
|
|
911
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
|
912
|
+
<div class="field">
|
|
913
|
+
<label>Outcome (what changes)</label>
|
|
914
|
+
<textarea id="checkOutcome" rows="5" placeholder="e.g. Add JWT refresh token rotation to the auth service…"></textarea>
|
|
915
|
+
</div>
|
|
916
|
+
<div class="field">
|
|
917
|
+
<label>Design / approach</label>
|
|
918
|
+
<textarea id="checkDesign" rows="5" placeholder="e.g. Store tokens in Redis with 7-day TTL, invalidate on logout…"></textarea>
|
|
919
|
+
</div>
|
|
920
|
+
</div>
|
|
921
|
+
<button class="btn" onclick="runCheck()">Check risk</button>
|
|
922
|
+
<div id="checkResult" style="margin-top:16px"></div>
|
|
923
|
+
</div>
|
|
924
|
+
|
|
925
|
+
<!-- ── Ingest tab ───────────────────────────────────────────────── -->
|
|
926
|
+
<div id="tab-ingest" class="tab">
|
|
927
|
+
<h2 class="section-heading">Ingest</h2>
|
|
928
|
+
<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>
|
|
929
|
+
|
|
930
|
+
<div class="ingest-section">
|
|
931
|
+
<div class="ingest-section-title">Git history</div>
|
|
932
|
+
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
|
933
|
+
<div class="field" style="margin:0;flex:1;min-width:140px">
|
|
934
|
+
<label>Since (ISO 8601 duration)</label>
|
|
935
|
+
<input id="ingestGitSince" type="text" value="P90D" placeholder="P90D">
|
|
936
|
+
</div>
|
|
937
|
+
<div style="display:flex;align-items:center;gap:6px;padding-bottom:1px">
|
|
938
|
+
<input type="checkbox" id="ingestGitPropose">
|
|
939
|
+
<label for="ingestGitPropose" style="font-size:13px">Also stage as proposals</label>
|
|
940
|
+
</div>
|
|
941
|
+
<button class="btn" style="flex:none" onclick="runIngest('git')">Ingest commits</button>
|
|
942
|
+
</div>
|
|
943
|
+
<div class="ingest-result" id="ingestGitResult"></div>
|
|
944
|
+
</div>
|
|
945
|
+
|
|
946
|
+
<div class="ingest-section">
|
|
947
|
+
<div class="ingest-section-title">Files</div>
|
|
948
|
+
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
|
949
|
+
<div class="field" style="margin:0;flex:1;min-width:200px">
|
|
950
|
+
<label>Paths (comma-separated, relative to project root)</label>
|
|
951
|
+
<input id="ingestFilePaths" type="text" placeholder="docs/RFC.md, src/lib/triage.ts">
|
|
952
|
+
</div>
|
|
953
|
+
<div style="display:flex;align-items:center;gap:6px;padding-bottom:1px">
|
|
954
|
+
<input type="checkbox" id="ingestFilePropose">
|
|
955
|
+
<label for="ingestFilePropose" style="font-size:13px">Also stage as proposals</label>
|
|
956
|
+
</div>
|
|
957
|
+
<button class="btn" style="flex:none" onclick="runIngest('files')">Ingest files</button>
|
|
958
|
+
</div>
|
|
959
|
+
<div class="ingest-result" id="ingestFileResult"></div>
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
<div class="ingest-section">
|
|
963
|
+
<div class="ingest-section-title">URL</div>
|
|
964
|
+
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
|
965
|
+
<div class="field" style="margin:0;flex:1;min-width:260px">
|
|
966
|
+
<label>URL (http/https only)</label>
|
|
967
|
+
<input id="ingestUrl" type="text" placeholder="https://example.com/rfc">
|
|
968
|
+
</div>
|
|
969
|
+
<div style="display:flex;align-items:center;gap:6px;padding-bottom:1px">
|
|
970
|
+
<input type="checkbox" id="ingestUrlPropose">
|
|
971
|
+
<label for="ingestUrlPropose" style="font-size:13px">Also stage as proposals</label>
|
|
972
|
+
</div>
|
|
973
|
+
<button class="btn" style="flex:none" onclick="runIngest('url')">Ingest URL</button>
|
|
974
|
+
</div>
|
|
975
|
+
<div class="ingest-result" id="ingestUrlResult"></div>
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
|
|
979
|
+
<!-- ── Sentinel tab ─────────────────────────────────────────────── -->
|
|
980
|
+
<div id="tab-sentinel" class="tab">
|
|
981
|
+
<h2 class="section-heading">Sentinel</h2>
|
|
982
|
+
<p class="section-sub">Chronicle entries whose referenced files no longer exist — structural drift detection.</p>
|
|
983
|
+
<div id="sentinelView"><div class="loading">Loading…</div></div>
|
|
984
|
+
</div>
|
|
587
985
|
</main>
|
|
588
986
|
|
|
589
987
|
<!-- ── Edit proposal modal ───────────────────────────────────────────── -->
|
|
@@ -640,7 +1038,7 @@ window.addEventListener("DOMContentLoaded", () => {
|
|
|
640
1038
|
|
|
641
1039
|
// ── Tab switching ──────────────────────────────────────────────────────────
|
|
642
1040
|
|
|
643
|
-
const TAB_NAMES = ["chronicle", "proposals", "coverage", "growth", "compass"]
|
|
1041
|
+
const TAB_NAMES = ["chronicle", "proposals", "coverage", "growth", "compass", "advisor", "check", "ingest", "sentinel"]
|
|
644
1042
|
|
|
645
1043
|
function showTab(name) {
|
|
646
1044
|
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"))
|
|
@@ -649,8 +1047,9 @@ function showTab(name) {
|
|
|
649
1047
|
})
|
|
650
1048
|
document.getElementById(`tab-${name}`).classList.add("active")
|
|
651
1049
|
activeTab = name
|
|
652
|
-
if (name === "coverage"
|
|
653
|
-
if (name === "growth"
|
|
1050
|
+
if (name === "coverage" && !coverageData) loadCoverage()
|
|
1051
|
+
if (name === "growth" && !growthData) loadGrowth()
|
|
1052
|
+
if (name === "sentinel" && !sentinelLoaded) loadSentinel()
|
|
654
1053
|
}
|
|
655
1054
|
|
|
656
1055
|
// ── Toast ──────────────────────────────────────────────────────────────────
|
|
@@ -1072,17 +1471,331 @@ async function loadCompass() {
|
|
|
1072
1471
|
}
|
|
1073
1472
|
}
|
|
1074
1473
|
|
|
1474
|
+
function confidenceBadge(conf) {
|
|
1475
|
+
const pct = Math.round((conf ?? 0) * 100)
|
|
1476
|
+
const cls = pct >= 70 ? 'cp-badge--hi' : pct >= 45 ? 'cp-badge--mid' : 'cp-badge--lo'
|
|
1477
|
+
return `<span class="cp-badge ${cls}">${pct}% confidence</span>`
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function cpList(items, cssClass, icon) {
|
|
1481
|
+
if (!items?.length) return ''
|
|
1482
|
+
return `<ul class="cp-list">${items.map(t => `<li class="cp-item ${cssClass}"><span class="cp-icon">${icon}</span><span>${esc(t)}</span></li>`).join('')}</ul>`
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function scoreClass(total) {
|
|
1486
|
+
return total >= 75 ? 'cp-score-hi' : total >= 55 ? 'cp-score-mid' : 'cp-score-lo'
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function renderBrief(d) {
|
|
1490
|
+
let html = `<div class="cp-header"><span class="cp-title">Direction Brief</span>${confidenceBadge(d.confidence)}</div>`
|
|
1491
|
+
if (d.product_direction) html += `<div class="cp-direction">${esc(d.product_direction)}</div>`
|
|
1492
|
+
|
|
1493
|
+
if (d.known_from_chronicle?.length) {
|
|
1494
|
+
html += `<div class="cp-section"><div class="cp-section-label">From Chronicle</div>${cpList(d.known_from_chronicle, 'cp-known', '✓')}</div>`
|
|
1495
|
+
}
|
|
1496
|
+
if (d.known_from_behavior?.length) {
|
|
1497
|
+
html += `<div class="cp-section"><div class="cp-section-label">From code / docs</div>${cpList(d.known_from_behavior, 'cp-known', '✓')}</div>`
|
|
1498
|
+
}
|
|
1499
|
+
if (d.inferred?.length) {
|
|
1500
|
+
html += `<div class="cp-section"><div class="cp-section-label">Inferred</div>${cpList(d.inferred, 'cp-inferred', '~')}</div>`
|
|
1501
|
+
}
|
|
1502
|
+
if (d.unknowns?.length) {
|
|
1503
|
+
html += `<div class="cp-section"><div class="cp-section-label">Unknowns</div>${cpList(d.unknowns, 'cp-unknown', '?')}</div>`
|
|
1504
|
+
}
|
|
1505
|
+
if (d.assumptions?.length) {
|
|
1506
|
+
html += `<div class="cp-section"><div class="cp-section-label">Assumptions</div>${cpList(d.assumptions, 'cp-unknown', '·')}</div>`
|
|
1507
|
+
}
|
|
1508
|
+
if (d.recommended_next_step) {
|
|
1509
|
+
html += `<div class="cp-next-step"><span class="cp-next-label">Next step</span><span class="cp-next-text">${esc(d.recommended_next_step)}</span></div>`
|
|
1510
|
+
}
|
|
1511
|
+
return html
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function renderMap(d) {
|
|
1515
|
+
let html = `<div class="cp-header"><span class="cp-title">Behaviour Map</span>${confidenceBadge(d.confidence)}</div>`
|
|
1516
|
+
if (d.behaviors?.length) {
|
|
1517
|
+
html += `<div class="cp-section"><div class="cp-section-label">Behaviours (${d.behaviors.length})</div>`
|
|
1518
|
+
for (const b of d.behaviors.slice(0, 30)) {
|
|
1519
|
+
html += `<div class="cp-card" style="padding:10px 14px">`
|
|
1520
|
+
html += `<div class="cp-card-header"><span class="cp-card-title">${esc(b.name)}</span><span class="cp-area-tag">${esc(b.area)}</span></div>`
|
|
1521
|
+
html += `<div class="cp-card-body">${esc(b.current_behavior)}</div></div>`
|
|
1522
|
+
}
|
|
1523
|
+
html += `</div>`
|
|
1524
|
+
}
|
|
1525
|
+
if (d.gaps?.length) {
|
|
1526
|
+
html += `<div class="cp-section"><div class="cp-section-label">Gaps (${d.gaps.length})</div>`
|
|
1527
|
+
for (const g of d.gaps) html += `<div class="cp-gap-item">${esc(g.gap)}</div>`
|
|
1528
|
+
html += `</div>`
|
|
1529
|
+
}
|
|
1530
|
+
if (!d.behaviors?.length && !d.gaps?.length) html += `<div class="empty">No behaviours found in this project.</div>`
|
|
1531
|
+
return html
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function renderBets(items) {
|
|
1535
|
+
if (!items?.length) return `<div class="empty">No bets generated.</div>`
|
|
1536
|
+
let html = `<div class="cp-header"><span class="cp-title">Strategic Bets</span></div>`
|
|
1537
|
+
for (const b of items) {
|
|
1538
|
+
const total = b.scores?.total ?? 0
|
|
1539
|
+
html += `<div class="cp-card">`
|
|
1540
|
+
html += `<div class="cp-card-header"><span class="cp-card-title">${esc(b.title)}</span><span class="cp-card-score ${scoreClass(total)}">${total}</span></div>`
|
|
1541
|
+
if (b.thesis) html += `<div class="cp-card-body">${esc(b.thesis)}</div>`
|
|
1542
|
+
if (b.first_experiment) html += `<div class="cp-card-meta"><span>First test: ${esc(b.first_experiment)}</span></div>`
|
|
1543
|
+
if (b.kill_criteria?.[0]) html += `<div class="cp-card-meta" style="color:var(--red)">Kill if: ${esc(b.kill_criteria[0])}</div>`
|
|
1544
|
+
if (b.assumptions?.length) html += `<div class="cp-card-meta">Assumes: ${esc(b.assumptions[0])}</div>`
|
|
1545
|
+
html += `</div>`
|
|
1546
|
+
}
|
|
1547
|
+
return html
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function renderOpportunities(items) {
|
|
1551
|
+
if (!items?.length) return `<div class="empty">No gaps or opportunities found.</div>`
|
|
1552
|
+
let html = `<div class="cp-header"><span class="cp-title">Opportunities (${items.length})</span></div>`
|
|
1553
|
+
for (const o of items) {
|
|
1554
|
+
html += `<div class="cp-card">`
|
|
1555
|
+
html += `<div class="cp-card-header"><span class="cp-card-title">${esc(o.title ?? o.gap)}</span><span class="cp-area-tag">${esc(o.area)}</span></div>`
|
|
1556
|
+
if (o.why_it_matters) html += `<div class="cp-card-body">${esc(o.why_it_matters)}</div>`
|
|
1557
|
+
if (o.suggested_next_step) html += `<div class="cp-card-meta">Next: ${esc(o.suggested_next_step)}</div>`
|
|
1558
|
+
html += `</div>`
|
|
1559
|
+
}
|
|
1560
|
+
return html
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1075
1563
|
function renderCompass(data, subcommand) {
|
|
1076
1564
|
const el = document.getElementById("compassView")
|
|
1077
1565
|
if (data.status === "no-llm") {
|
|
1078
1566
|
el.innerHTML = `<div class="empty">${esc(data.message)}<small>Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY and restart quorum serve.</small></div>`
|
|
1079
1567
|
return
|
|
1080
1568
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1569
|
+
|
|
1570
|
+
const d = data.data // structured JSON from compass --json
|
|
1571
|
+
if (!d) {
|
|
1572
|
+
// fallback: raw text (shouldn't happen, but safe)
|
|
1573
|
+
el.innerHTML = `<pre style="font-size:12px;line-height:1.6;white-space:pre-wrap;color:var(--muted)">${esc(data.output ?? JSON.stringify(data, null, 2))}</pre>`
|
|
1574
|
+
return
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
let inner = ''
|
|
1578
|
+
if (subcommand === 'brief') inner = renderBrief(d)
|
|
1579
|
+
else if (subcommand === 'map') inner = renderMap(d)
|
|
1580
|
+
else if (subcommand === 'bets') inner = renderBets(Array.isArray(d) ? d : d.bets ?? [d])
|
|
1581
|
+
else if (subcommand === 'opportunities') inner = renderOpportunities(Array.isArray(d) ? d : [])
|
|
1582
|
+
else inner = `<pre style="font-size:12px;line-height:1.6;white-space:pre-wrap;color:var(--muted)">${esc(JSON.stringify(d, null, 2))}</pre>`
|
|
1583
|
+
|
|
1584
|
+
el.innerHTML = `<div style="padding-bottom:16px">${inner}</div>`
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// ── Advisor ───────────────────────────────────────────────────────────────
|
|
1588
|
+
|
|
1589
|
+
let advisorHistory = [] // { role: "user"|"ai", text, citations, confidence }
|
|
1590
|
+
|
|
1591
|
+
function renderAdvisorHistory() {
|
|
1592
|
+
const wrap = document.getElementById("advisorChat")
|
|
1593
|
+
if (!advisorHistory.length) {
|
|
1594
|
+
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>`
|
|
1595
|
+
return
|
|
1596
|
+
}
|
|
1597
|
+
wrap.innerHTML = advisorHistory.map(m => {
|
|
1598
|
+
if (m.role === "user") {
|
|
1599
|
+
return `<div class="chat-bubble chat-bubble--user">
|
|
1600
|
+
<span class="chat-label">You</span>
|
|
1601
|
+
<div class="chat-body">${esc(m.text)}</div>
|
|
1602
|
+
</div>`
|
|
1603
|
+
}
|
|
1604
|
+
const confPct = m.confidence != null ? Math.round(m.confidence * 100) : null
|
|
1605
|
+
const confHtml = confPct != null ? `<div class="chat-confidence">${confPct}% confidence</div>` : ""
|
|
1606
|
+
const citHtml = m.citations?.length
|
|
1607
|
+
? `<div class="chat-citations">${m.citations.map(c => `<span class="chat-citation">${esc(c)}</span>`).join("")}</div>`
|
|
1608
|
+
: ""
|
|
1609
|
+
const parts = []
|
|
1610
|
+
if (m.what_we_know) parts.push(`<strong>What we know:</strong> ${esc(m.what_we_know)}`)
|
|
1611
|
+
if (m.recommendation) parts.push(`<strong>Recommendation:</strong> ${esc(m.recommendation)}`)
|
|
1612
|
+
if (m.next_step) parts.push(`<strong>Next step:</strong> ${esc(m.next_step)}`)
|
|
1613
|
+
if (m.risks?.length) parts.push(`<strong>Risks:</strong> ${m.risks.map(r => esc(r)).join("; ")}`)
|
|
1614
|
+
return `<div class="chat-bubble chat-bubble--ai">
|
|
1615
|
+
<span class="chat-label">Advisor</span>
|
|
1616
|
+
<div class="chat-body">${parts.length ? parts.join("<br>") : esc(m.text)}</div>
|
|
1617
|
+
${confHtml}${citHtml}
|
|
1618
|
+
</div>`
|
|
1619
|
+
}).join("")
|
|
1620
|
+
wrap.scrollTop = wrap.scrollHeight
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
async function sendAdvisorMsg() {
|
|
1624
|
+
const input = document.getElementById("advisorInput")
|
|
1625
|
+
const question = input.value.trim()
|
|
1626
|
+
if (!question) return
|
|
1627
|
+
input.value = ""
|
|
1628
|
+
advisorHistory.push({ role: "user", text: question })
|
|
1629
|
+
renderAdvisorHistory()
|
|
1630
|
+
|
|
1631
|
+
// Placeholder while waiting
|
|
1632
|
+
advisorHistory.push({ role: "ai", text: "Thinking…", _pending: true })
|
|
1633
|
+
renderAdvisorHistory()
|
|
1634
|
+
|
|
1635
|
+
try {
|
|
1636
|
+
const res = await fetch("/api/advisor", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ question }) })
|
|
1637
|
+
const data = await res.json()
|
|
1638
|
+
// Remove placeholder
|
|
1639
|
+
advisorHistory = advisorHistory.filter(m => !m._pending)
|
|
1640
|
+
|
|
1641
|
+
if (data.status === "no-llm") {
|
|
1642
|
+
advisorHistory.push({ role: "ai", text: "No LLM provider configured. " + data.message })
|
|
1643
|
+
} else if (data.error) {
|
|
1644
|
+
advisorHistory.push({ role: "ai", text: "Error: " + data.error })
|
|
1645
|
+
} else {
|
|
1646
|
+
const citations = (data.evidence ?? []).map(e => (e.id ?? "").slice(0, 8)).filter(Boolean)
|
|
1647
|
+
advisorHistory.push({
|
|
1648
|
+
role: "ai",
|
|
1649
|
+
what_we_know: data.what_we_know,
|
|
1650
|
+
recommendation: data.recommendation,
|
|
1651
|
+
next_step: data.next_step,
|
|
1652
|
+
risks: data.risks,
|
|
1653
|
+
confidence: data.confidence,
|
|
1654
|
+
citations,
|
|
1655
|
+
})
|
|
1656
|
+
}
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
advisorHistory = advisorHistory.filter(m => !m._pending)
|
|
1659
|
+
advisorHistory.push({ role: "ai", text: "Request failed: " + err.message })
|
|
1660
|
+
}
|
|
1661
|
+
renderAdvisorHistory()
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// ── Check ─────────────────────────────────────────────────────────────────
|
|
1665
|
+
|
|
1666
|
+
async function runCheck() {
|
|
1667
|
+
const outcome = document.getElementById("checkOutcome").value.trim()
|
|
1668
|
+
const design = document.getElementById("checkDesign").value.trim()
|
|
1669
|
+
if (!outcome && !design) { toast("Enter an outcome or design first", "err"); return }
|
|
1670
|
+
|
|
1671
|
+
const el = document.getElementById("checkResult")
|
|
1672
|
+
el.innerHTML = `<div class="loading">Analysing…</div>`
|
|
1673
|
+
|
|
1674
|
+
try {
|
|
1675
|
+
const res = await fetch("/api/check", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ outcome, design }) })
|
|
1676
|
+
const data = await res.json()
|
|
1677
|
+
if (data.error) { el.innerHTML = `<div class="empty">Error: ${esc(data.error)}</div>`; return }
|
|
1678
|
+
|
|
1679
|
+
const { risk, preflight } = data
|
|
1680
|
+
const level = risk?.level ?? "low"
|
|
1681
|
+
const riskLabel = { critical: "Critical", high: "High", medium: "Medium", low: "Low" }[level] ?? level
|
|
1682
|
+
const reasons = (risk?.reasons ?? []).filter(r => r !== "no sensitive patterns detected")
|
|
1683
|
+
|
|
1684
|
+
let html = `<div class="risk-banner risk-${level}">
|
|
1685
|
+
<span class="risk-level">${riskLabel}</span>
|
|
1686
|
+
<span style="font-size:13px">${reasons.length ? reasons.join(" · ") : "No sensitive patterns detected"}</span>
|
|
1687
|
+
</div>`
|
|
1688
|
+
|
|
1689
|
+
html += `<div style="display:flex;flex-wrap:wrap;gap:16px">`
|
|
1690
|
+
|
|
1691
|
+
// Preflight flags
|
|
1692
|
+
html += `<div style="min-width:200px">`
|
|
1693
|
+
html += `<div class="cp-section-label" style="margin-bottom:8px">Preflight flags</div>`
|
|
1694
|
+
const sensitive = preflight?.sensitive_areas ?? []
|
|
1695
|
+
if (sensitive.length) {
|
|
1696
|
+
html += sensitive.map(a => `<div class="preflight-row preflight-bad"><span>⚠</span> ${esc(a)}</div>`).join("")
|
|
1697
|
+
} else {
|
|
1698
|
+
html += `<div class="preflight-row preflight-ok"><span>✓</span> No sensitive areas</div>`
|
|
1699
|
+
}
|
|
1700
|
+
html += `<div class="preflight-row ${preflight?.rollback_mentioned ? "preflight-ok" : "preflight-bad"}">
|
|
1701
|
+
<span>${preflight?.rollback_mentioned ? "✓" : "·"}</span> Rollback ${preflight?.rollback_mentioned ? "mentioned" : "not mentioned"}
|
|
1702
|
+
</div>`
|
|
1703
|
+
html += `<div class="preflight-row ${preflight?.test_strategy_mentioned ? "preflight-ok" : "preflight-bad"}">
|
|
1704
|
+
<span>${preflight?.test_strategy_mentioned ? "✓" : "·"}</span> Tests ${preflight?.test_strategy_mentioned ? "mentioned" : "not mentioned"}
|
|
1705
|
+
</div>`
|
|
1706
|
+
html += `</div></div>`
|
|
1707
|
+
|
|
1708
|
+
el.innerHTML = html
|
|
1709
|
+
} catch (err) {
|
|
1710
|
+
el.innerHTML = `<div class="empty">Request failed: ${esc(err.message)}</div>`
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ── Ingest ────────────────────────────────────────────────────────────────
|
|
1715
|
+
|
|
1716
|
+
async function runIngest(type) {
|
|
1717
|
+
let body = { type }
|
|
1718
|
+
let resultEl
|
|
1719
|
+
|
|
1720
|
+
if (type === "git") {
|
|
1721
|
+
body.since = document.getElementById("ingestGitSince").value.trim() || "P90D"
|
|
1722
|
+
body.propose = document.getElementById("ingestGitPropose").checked
|
|
1723
|
+
resultEl = document.getElementById("ingestGitResult")
|
|
1724
|
+
} else if (type === "files") {
|
|
1725
|
+
const raw = document.getElementById("ingestFilePaths").value.trim()
|
|
1726
|
+
if (!raw) { toast("Enter at least one file path", "err"); return }
|
|
1727
|
+
body.paths = raw
|
|
1728
|
+
body.propose = document.getElementById("ingestFilePropose").checked
|
|
1729
|
+
resultEl = document.getElementById("ingestFileResult")
|
|
1730
|
+
} else if (type === "url") {
|
|
1731
|
+
const u = document.getElementById("ingestUrl").value.trim()
|
|
1732
|
+
if (!u) { toast("Enter a URL", "err"); return }
|
|
1733
|
+
body.urls = u
|
|
1734
|
+
body.propose = document.getElementById("ingestUrlPropose").checked
|
|
1735
|
+
resultEl = document.getElementById("ingestUrlResult")
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
resultEl.textContent = "Running…"
|
|
1739
|
+
try {
|
|
1740
|
+
const res = await fetch("/api/ingest", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
|
|
1741
|
+
const data = await res.json()
|
|
1742
|
+
if (data.error) { resultEl.textContent = "Error: " + data.error; return }
|
|
1743
|
+
|
|
1744
|
+
let out = `✓ ${data.added} added, ${data.skipped} skipped`
|
|
1745
|
+
if (body.propose && data.added > 0) out += ` — proposals staged, run quorum commit --list to review`
|
|
1746
|
+
if (data.items?.length) {
|
|
1747
|
+
out += "\n" + data.items.slice(0, 8).map(i => ` ${i.ref} ${i.summary}`).join("\n")
|
|
1748
|
+
if (data.items.length > 8) out += `\n … and ${data.items.length - 8} more`
|
|
1749
|
+
}
|
|
1750
|
+
resultEl.textContent = out
|
|
1751
|
+
if (data.added > 0) toast(`Ingested ${data.added} ${type} item${data.added !== 1 ? "s" : ""}`)
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
resultEl.textContent = "Request failed: " + err.message
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// ── Sentinel ──────────────────────────────────────────────────────────────
|
|
1758
|
+
|
|
1759
|
+
let sentinelLoaded = false
|
|
1760
|
+
|
|
1761
|
+
async function loadSentinel() {
|
|
1762
|
+
sentinelLoaded = true
|
|
1763
|
+
const el = document.getElementById("sentinelView")
|
|
1764
|
+
try {
|
|
1765
|
+
const res = await fetch("/api/sentinel/drift")
|
|
1766
|
+
const data = await res.json()
|
|
1767
|
+
renderSentinel(data)
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
el.innerHTML = `<div class="empty">Failed to load sentinel data<small>${esc(err.message)}</small></div>`
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function renderSentinel(data) {
|
|
1774
|
+
const el = document.getElementById("sentinelView")
|
|
1775
|
+
const { total = 0, flagged = 0, flags = [], note = "" } = data
|
|
1776
|
+
|
|
1777
|
+
let html = `<div style="display:flex;gap:20px;margin-bottom:18px;flex-wrap:wrap">`
|
|
1778
|
+
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>`
|
|
1779
|
+
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>`
|
|
1780
|
+
html += `</div>`
|
|
1781
|
+
|
|
1782
|
+
if (note) html += `<p style="font-size:12px;color:var(--muted);margin-bottom:16px">${esc(note)}</p>`
|
|
1783
|
+
|
|
1784
|
+
if (!flags.length) {
|
|
1785
|
+
html += `<div class="empty">No drift detected — all affected_areas paths resolve to existing files.</div>`
|
|
1786
|
+
} else {
|
|
1787
|
+
for (const f of flags) {
|
|
1788
|
+
html += `<div class="drift-flag">
|
|
1789
|
+
<div class="drift-flag-title">[${esc(f.entryId)}] ${esc(f.topic)}</div>
|
|
1790
|
+
<div class="drift-flag-body">${esc(f.decision)}</div>
|
|
1791
|
+
<div class="drift-missing">
|
|
1792
|
+
${f.missingFiles.map(p => `<span class="drift-path">${esc(p)}</span>`).join("")}
|
|
1793
|
+
</div>
|
|
1794
|
+
</div>`
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
el.innerHTML = html
|
|
1086
1799
|
}
|
|
1087
1800
|
</script>
|
|
1088
1801
|
</body>
|