@balpal4495/quorum 0.1.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/.github/copilot-instructions.md +94 -0
- package/CLAUDE.md +86 -0
- package/GEMINI.md +73 -0
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/SETUP.md +256 -0
- package/bin/init.js +366 -0
- package/modules/AGENTS.md +66 -0
- package/modules/CLAUDE.md +64 -0
- package/modules/README.md +251 -0
- package/modules/council/advisors.ts +68 -0
- package/modules/council/chairman.ts +112 -0
- package/modules/council/deliberate.ts +106 -0
- package/modules/council/frame.ts +54 -0
- package/modules/council/index.ts +4 -0
- package/modules/council/personas.ts +57 -0
- package/modules/council/reviewers.ts +81 -0
- package/modules/council/types.ts +45 -0
- package/modules/jury/evaluate.ts +112 -0
- package/modules/jury/index.ts +3 -0
- package/modules/jury/schema.ts +15 -0
- package/modules/jury/types.ts +31 -0
- package/modules/oracle/adapters/lance-db.ts +81 -0
- package/modules/oracle/adapters/xenova-embedder.ts +43 -0
- package/modules/oracle/bm25.ts +92 -0
- package/modules/oracle/index.ts +36 -0
- package/modules/oracle/log.ts +15 -0
- package/modules/oracle/propose.ts +148 -0
- package/modules/oracle/query.ts +145 -0
- package/modules/oracle/summary.ts +115 -0
- package/modules/oracle/types.ts +32 -0
- package/modules/sentinel/assert.ts +95 -0
- package/modules/sentinel/coverage.ts +106 -0
- package/modules/sentinel/drift.ts +159 -0
- package/modules/sentinel/index.ts +6 -0
- package/modules/sentinel/review.ts +207 -0
- package/modules/setup.ts +153 -0
- package/modules/shared/types.ts +148 -0
- package/package.json +47 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { promises as fs } from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import type { ChronicleEntry, DriftFlag, DriftReport, LLMProvider } from "../shared/types"
|
|
4
|
+
|
|
5
|
+
const FILE_CONTENT_LIMIT = 3000
|
|
6
|
+
|
|
7
|
+
async function readCommittedEntries(chronicleDir: string): Promise<ChronicleEntry[]> {
|
|
8
|
+
const committedDir = path.join(chronicleDir, "committed")
|
|
9
|
+
let files: string[]
|
|
10
|
+
try {
|
|
11
|
+
files = await fs.readdir(committedDir)
|
|
12
|
+
} catch {
|
|
13
|
+
return []
|
|
14
|
+
}
|
|
15
|
+
const entries: ChronicleEntry[] = []
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
if (!file.endsWith(".json")) continue
|
|
18
|
+
try {
|
|
19
|
+
const raw = await fs.readFile(path.join(committedDir, file), "utf8")
|
|
20
|
+
entries.push(JSON.parse(raw) as ChronicleEntry)
|
|
21
|
+
} catch {
|
|
22
|
+
// skip malformed
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return entries
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function resolveLocalFiles(areas: string[], codebasePath: string): Promise<string[]> {
|
|
29
|
+
const resolved: string[] = []
|
|
30
|
+
for (const area of areas) {
|
|
31
|
+
// Try as a direct relative path first
|
|
32
|
+
const candidate = path.join(codebasePath, area)
|
|
33
|
+
try {
|
|
34
|
+
await fs.access(candidate)
|
|
35
|
+
resolved.push(candidate)
|
|
36
|
+
continue
|
|
37
|
+
} catch {
|
|
38
|
+
// not a direct path — try substring search
|
|
39
|
+
}
|
|
40
|
+
// Walk up to two levels to find files whose relative path contains the area string
|
|
41
|
+
try {
|
|
42
|
+
const all = await fs.readdir(codebasePath, { recursive: true, encoding: "utf8" })
|
|
43
|
+
for (const f of all) {
|
|
44
|
+
const normalised = f.replace(/\\/g, "/")
|
|
45
|
+
if (normalised.includes(area.replace(/\\/g, "/")) && normalised.endsWith(".ts")) {
|
|
46
|
+
resolved.push(path.join(codebasePath, f))
|
|
47
|
+
break
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...new Set(resolved)]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function evaluateDrift(
|
|
58
|
+
entry: ChronicleEntry,
|
|
59
|
+
files: Array<{ filePath: string; content: string }>,
|
|
60
|
+
llm: LLMProvider,
|
|
61
|
+
): Promise<DriftFlag> {
|
|
62
|
+
const fileSection = files
|
|
63
|
+
.map(f => `### ${path.basename(f.filePath)}\n\`\`\`\n${f.content.slice(0, FILE_CONTENT_LIMIT)}\n\`\`\``)
|
|
64
|
+
.join("\n\n")
|
|
65
|
+
|
|
66
|
+
const response = await llm([
|
|
67
|
+
{
|
|
68
|
+
role: "system",
|
|
69
|
+
content:
|
|
70
|
+
"You are a code reviewer checking whether a documented insight still accurately describes the current source code. " +
|
|
71
|
+
"Reply with a JSON object only — no markdown, no explanation outside the object.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
role: "user",
|
|
75
|
+
content:
|
|
76
|
+
`Documented insight:\n"${entry.key_insight}"\n\n` +
|
|
77
|
+
`Current source:\n${fileSection}\n\n` +
|
|
78
|
+
`Does this insight still accurately describe the code above?\n` +
|
|
79
|
+
`{"stillValid": boolean, "confidence": number, "reasoning": "one sentence"}`,
|
|
80
|
+
},
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const match = response.match(/\{[\s\S]*?\}/)
|
|
85
|
+
if (!match) throw new Error("no JSON")
|
|
86
|
+
const parsed = JSON.parse(match[0]) as { stillValid?: unknown; confidence?: unknown; reasoning?: unknown }
|
|
87
|
+
return {
|
|
88
|
+
entryId: entry.id,
|
|
89
|
+
keyInsight: entry.key_insight,
|
|
90
|
+
affectedFiles: files.map(f => f.filePath),
|
|
91
|
+
stillValid: Boolean(parsed.stillValid),
|
|
92
|
+
confidence: typeof parsed.confidence === "number" ? Math.max(0, Math.min(1, parsed.confidence)) : 0.5,
|
|
93
|
+
reasoning: typeof parsed.reasoning === "string" ? parsed.reasoning : "no reasoning provided",
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Parse failure → conservative: flag for human review
|
|
97
|
+
return {
|
|
98
|
+
entryId: entry.id,
|
|
99
|
+
keyInsight: entry.key_insight,
|
|
100
|
+
affectedFiles: files.map(f => f.filePath),
|
|
101
|
+
stillValid: false,
|
|
102
|
+
confidence: 0,
|
|
103
|
+
reasoning: "LLM response could not be parsed — manual review recommended",
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* For each Chronicle entry whose affected_areas resolves to at least one local
|
|
110
|
+
* source file, ask the LLM whether the key_insight still accurately describes
|
|
111
|
+
* the current code.
|
|
112
|
+
*
|
|
113
|
+
* Output is strictly advisory — entries are never updated autonomously.
|
|
114
|
+
* Entries where no affected_areas value resolves to a local file are skipped
|
|
115
|
+
* (e.g. entries about external tools, workflows, or conceptual areas).
|
|
116
|
+
*/
|
|
117
|
+
export async function detectDrift(
|
|
118
|
+
chronicleDir: string,
|
|
119
|
+
codebasePath: string,
|
|
120
|
+
llm: LLMProvider,
|
|
121
|
+
): Promise<DriftReport> {
|
|
122
|
+
const entries = await readCommittedEntries(chronicleDir)
|
|
123
|
+
|
|
124
|
+
const flags: DriftFlag[] = []
|
|
125
|
+
const confirmed: DriftFlag[] = []
|
|
126
|
+
const skipped: string[] = []
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const localPaths = await resolveLocalFiles(entry.affected_areas, codebasePath)
|
|
130
|
+
if (localPaths.length === 0) {
|
|
131
|
+
skipped.push(entry.id)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const files: Array<{ filePath: string; content: string }> = []
|
|
136
|
+
for (const p of localPaths) {
|
|
137
|
+
try {
|
|
138
|
+
const content = await fs.readFile(p, "utf8")
|
|
139
|
+
files.push({ filePath: p, content })
|
|
140
|
+
} catch {
|
|
141
|
+
// file unreadable — skip this path
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (files.length === 0) {
|
|
146
|
+
skipped.push(entry.id)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = await evaluateDrift(entry, files, llm)
|
|
151
|
+
if (result.stillValid) {
|
|
152
|
+
confirmed.push(result)
|
|
153
|
+
} else {
|
|
154
|
+
flags.push(result)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { checkedAt: new Date().toISOString(), flags, confirmed, skipped }
|
|
159
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { coverage } from "./coverage"
|
|
2
|
+
export { detectDrift } from "./drift"
|
|
3
|
+
export { reviewContext } from "./review"
|
|
4
|
+
export { sentinelAssertions } from "./assert"
|
|
5
|
+
export type { CoverageReport, FileCoverage, DriftReport, DriftFlag } from "../shared/types"
|
|
6
|
+
export type { SentinelAssertOptions } from "./assert"
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { promises as fs } from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import type { ChronicleEntry } from "../shared/types"
|
|
4
|
+
import { coverage as runCoverage } from "./coverage"
|
|
5
|
+
|
|
6
|
+
function extractModule(filePath: string): string {
|
|
7
|
+
const normalised = filePath.replace(/\\/g, "/").replace(/^\/+/, "")
|
|
8
|
+
const stripped = normalised.replace(/^modules\//, "")
|
|
9
|
+
const parts = stripped.split("/")
|
|
10
|
+
return parts.length === 1 ? "(root)" : parts[0]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mermaidSafe(str: string): string {
|
|
14
|
+
return str.replace(/[^a-zA-Z0-9_]/g, "_")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function riskClass(pct: number): "high" | "medium" | "good" {
|
|
18
|
+
if (pct === 0) return "high"
|
|
19
|
+
if (pct < 50) return "medium"
|
|
20
|
+
return "good"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function riskLabel(pct: number): string {
|
|
24
|
+
if (pct === 0) return "high"
|
|
25
|
+
if (pct < 50) return "medium"
|
|
26
|
+
return "low"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isoWeekKey(date: Date): string {
|
|
30
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
|
31
|
+
const day = d.getUTCDay() || 7
|
|
32
|
+
d.setUTCDate(d.getUTCDate() + 4 - day)
|
|
33
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
|
34
|
+
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7)
|
|
35
|
+
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readCommittedEntries(chronicleDir: string): Promise<ChronicleEntry[]> {
|
|
39
|
+
const committedDir = path.join(chronicleDir, "committed")
|
|
40
|
+
let files: string[]
|
|
41
|
+
try {
|
|
42
|
+
files = await fs.readdir(committedDir)
|
|
43
|
+
} catch {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
const entries: ChronicleEntry[] = []
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
if (!file.endsWith(".json")) continue
|
|
49
|
+
try {
|
|
50
|
+
const raw = await fs.readFile(path.join(committedDir, file), "utf8")
|
|
51
|
+
entries.push(JSON.parse(raw) as ChronicleEntry)
|
|
52
|
+
} catch {
|
|
53
|
+
// skip malformed
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return entries
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type ModuleStat = {
|
|
60
|
+
name: string
|
|
61
|
+
totalFiles: number
|
|
62
|
+
coveredFiles: number
|
|
63
|
+
entryIds: string[]
|
|
64
|
+
changedFiles: number
|
|
65
|
+
percentage: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate a PR-level Chronicle coverage map as a markdown string ready to
|
|
70
|
+
* post as a PR comment.
|
|
71
|
+
*
|
|
72
|
+
* Produces three zones:
|
|
73
|
+
* 1. Coverage table — all modules with coverage %, entry count, file count,
|
|
74
|
+
* PR delta, and risk. Changed modules are bolded.
|
|
75
|
+
* 2. Heatmap diagram — Chronicle → modules, nodes coloured by risk level,
|
|
76
|
+
* labels show coverage % and change count in one visual.
|
|
77
|
+
* 3. Chronicle context — entries for touched modules only.
|
|
78
|
+
*
|
|
79
|
+
* Deterministic — no LLM required. Pass changedFiles from `git diff --name-only`.
|
|
80
|
+
*/
|
|
81
|
+
export async function reviewContext(
|
|
82
|
+
changedFiles: string[],
|
|
83
|
+
chronicleDir: string,
|
|
84
|
+
codebasePath: string,
|
|
85
|
+
): Promise<string> {
|
|
86
|
+
const filtered = changedFiles.filter(f => f.trim().length > 0)
|
|
87
|
+
if (filtered.length === 0) return "<!-- sentinel: no changed files -->"
|
|
88
|
+
|
|
89
|
+
const [report, allEntries] = await Promise.all([
|
|
90
|
+
runCoverage(chronicleDir, codebasePath),
|
|
91
|
+
readCommittedEntries(chronicleDir),
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
// Count changed files per module
|
|
95
|
+
const changedByModule = new Map<string, number>()
|
|
96
|
+
for (const file of filtered) {
|
|
97
|
+
const mod = extractModule(file)
|
|
98
|
+
changedByModule.set(mod, (changedByModule.get(mod) ?? 0) + 1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Build per-module stats from coverage report
|
|
102
|
+
const moduleStats = new Map<string, ModuleStat>()
|
|
103
|
+
for (const f of report.coverageByFile) {
|
|
104
|
+
const mod = extractModule(f.file)
|
|
105
|
+
const stat = moduleStats.get(mod) ?? {
|
|
106
|
+
name: mod, totalFiles: 0, coveredFiles: 0,
|
|
107
|
+
entryIds: [], changedFiles: changedByModule.get(mod) ?? 0, percentage: 0,
|
|
108
|
+
}
|
|
109
|
+
stat.totalFiles++
|
|
110
|
+
if (f.covered) {
|
|
111
|
+
stat.coveredFiles++
|
|
112
|
+
for (const id of f.entryIds) {
|
|
113
|
+
if (!stat.entryIds.includes(id)) stat.entryIds.push(id)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
moduleStats.set(mod, stat)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Include modules only referenced by changedFiles but not in codebase scan
|
|
120
|
+
for (const [mod, count] of changedByModule) {
|
|
121
|
+
if (!moduleStats.has(mod)) {
|
|
122
|
+
moduleStats.set(mod, {
|
|
123
|
+
name: mod, totalFiles: count, coveredFiles: 0,
|
|
124
|
+
entryIds: [], changedFiles: count, percentage: 0,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const stat of moduleStats.values()) {
|
|
130
|
+
stat.percentage = stat.totalFiles === 0
|
|
131
|
+
? 0
|
|
132
|
+
: Math.round((stat.coveredFiles / stat.totalFiles) * 100)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const allModules = [...moduleStats.values()].sort((a, b) =>
|
|
136
|
+
a.name === "(root)" ? 1 : b.name === "(root)" ? -1 : a.name.localeCompare(b.name),
|
|
137
|
+
)
|
|
138
|
+
const touchedModules = allModules.filter(m => m.changedFiles > 0)
|
|
139
|
+
|
|
140
|
+
const lines: string[] = []
|
|
141
|
+
const week = isoWeekKey(new Date())
|
|
142
|
+
const chronicleIsEmpty = allEntries.length === 0
|
|
143
|
+
|
|
144
|
+
// ── Header ────────────────────────────────────────────────────────────────
|
|
145
|
+
lines.push(`## Sentinel — Chronicle Coverage Map — ${week}`)
|
|
146
|
+
lines.push("")
|
|
147
|
+
|
|
148
|
+
if (chronicleIsEmpty) {
|
|
149
|
+
lines.push(
|
|
150
|
+
"> **Chronicle has no entries yet.** Every module shows as uncovered because this project has no documented knowledge. " +
|
|
151
|
+
"The modules touched by this PR are a good starting point — run `oracle.propose()` after this lands to begin building Chronicle.",
|
|
152
|
+
)
|
|
153
|
+
lines.push("")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Coverage table ────────────────────────────────────────────────────────
|
|
157
|
+
lines.push("| Module | Coverage | Entries | Files | PR Changes | Risk |")
|
|
158
|
+
lines.push("|--------|----------|---------|-------|------------|------|")
|
|
159
|
+
for (const stat of allModules) {
|
|
160
|
+
const name = stat.changedFiles > 0 ? `**${stat.name}/**` : `${stat.name}/`
|
|
161
|
+
const pct = `${stat.percentage}%`
|
|
162
|
+
const changed = stat.changedFiles > 0 ? `**${stat.changedFiles} files**` : "—"
|
|
163
|
+
lines.push(
|
|
164
|
+
`| ${name} | ${pct} | ${stat.entryIds.length} | ${stat.totalFiles} | ${changed} | ${riskLabel(stat.percentage)} |`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
lines.push("")
|
|
168
|
+
|
|
169
|
+
// ── Heatmap diagram ───────────────────────────────────────────────────────
|
|
170
|
+
lines.push("```mermaid")
|
|
171
|
+
lines.push("flowchart TD")
|
|
172
|
+
lines.push(" classDef high fill:#fca5a5,stroke:#dc2626")
|
|
173
|
+
lines.push(" classDef medium fill:#fde68a,stroke:#d97706")
|
|
174
|
+
lines.push(" classDef good fill:#bbf7d0,stroke:#16a34a")
|
|
175
|
+
lines.push(" Chronicle[(Chronicle)]")
|
|
176
|
+
for (const stat of allModules) {
|
|
177
|
+
const nodeId = mermaidSafe(stat.name)
|
|
178
|
+
const changed = stat.changedFiles > 0 ? ` — ${stat.changedFiles} changed` : ""
|
|
179
|
+
const label = `${stat.name} — ${stat.percentage}%${changed}`
|
|
180
|
+
const cls = riskClass(stat.percentage)
|
|
181
|
+
lines.push(` Chronicle --> ${nodeId}["${label}"]:::${cls}`)
|
|
182
|
+
}
|
|
183
|
+
lines.push("```")
|
|
184
|
+
lines.push("")
|
|
185
|
+
|
|
186
|
+
// ── Chronicle context for touched modules ─────────────────────────────────
|
|
187
|
+
const touchedWithEntries = touchedModules.filter(m => m.entryIds.length > 0)
|
|
188
|
+
if (touchedWithEntries.length > 0) {
|
|
189
|
+
lines.push("### Chronicle context for changed modules")
|
|
190
|
+
lines.push("")
|
|
191
|
+
for (const stat of touchedWithEntries) {
|
|
192
|
+
lines.push(`**${stat.name}/**`)
|
|
193
|
+
const relevant = allEntries.filter(e => stat.entryIds.includes(e.id))
|
|
194
|
+
for (const entry of relevant) {
|
|
195
|
+
lines.push(`- \`[${entry.id.slice(0, 8)}]\` ${entry.key_insight}`)
|
|
196
|
+
lines.push(` *${entry.status} — confidence ${entry.confidence.toFixed(2)}*`)
|
|
197
|
+
}
|
|
198
|
+
lines.push("")
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Footer ────────────────────────────────────────────────────────────────
|
|
203
|
+
lines.push("---")
|
|
204
|
+
lines.push("*Risk: high = 0% coverage, medium = 1-49%, low = 50%+*")
|
|
205
|
+
|
|
206
|
+
return lines.join("\n")
|
|
207
|
+
}
|
package/modules/setup.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import { promises as fs } from "fs"
|
|
3
|
+
import { createOracleClient } from "./oracle/index"
|
|
4
|
+
import { xenovaEmbed, warmEmbedder } from "./oracle/adapters/xenova-embedder"
|
|
5
|
+
import { createLanceDBStore } from "./oracle/adapters/lance-db"
|
|
6
|
+
import { evaluate } from "./jury/evaluate"
|
|
7
|
+
import { deliberate } from "./council/deliberate"
|
|
8
|
+
import type { LLMProvider, OracleClient } from "./shared/types"
|
|
9
|
+
import type { JuryInput, JuryOutput, JuryDeps } from "./jury/types"
|
|
10
|
+
import type { CouncilInput, CouncilOutput, CouncilDeps, CouncilModels } from "./council/types"
|
|
11
|
+
|
|
12
|
+
export interface SetupOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Injectable LLM provider.
|
|
15
|
+
* All modules that need an LLM receive this function.
|
|
16
|
+
* Ignored by Oracle (which has no LLM dependency).
|
|
17
|
+
*/
|
|
18
|
+
llm: LLMProvider
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Root directory for Chronicle data.
|
|
22
|
+
* Default: ".chronicle" (relative to process.cwd())
|
|
23
|
+
*/
|
|
24
|
+
chronicleDir?: string
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Model overrides for each reasoning step.
|
|
28
|
+
* If omitted, the LLM provider's default model is used for all steps.
|
|
29
|
+
*/
|
|
30
|
+
models?: {
|
|
31
|
+
jury?: string
|
|
32
|
+
council?: CouncilModels
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pre-warm the local ONNX embedder during setup so the first query
|
|
37
|
+
* is not slow. Set to false to skip (e.g. in test environments).
|
|
38
|
+
* Default: true
|
|
39
|
+
*/
|
|
40
|
+
warmEmbedder?: boolean
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Swap the default embedder (Xenova all-MiniLM-L6-v2) for your own.
|
|
44
|
+
* Must return a vector of consistent dimension.
|
|
45
|
+
*/
|
|
46
|
+
embedder?: (text: string) => Promise<number[]>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Modules {
|
|
50
|
+
/**
|
|
51
|
+
* Fully wired OracleClient.
|
|
52
|
+
* Use oracle.query() to retrieve evidence.
|
|
53
|
+
* Use oracle.propose() + oracle.commit() for the human-gated write path.
|
|
54
|
+
*/
|
|
55
|
+
oracle: OracleClient
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Evaluate a proposed design against Oracle evidence.
|
|
59
|
+
* Returns a confidence score and the Council brief for the next step.
|
|
60
|
+
*/
|
|
61
|
+
evaluate: (input: Omit<JuryInput, never>) => Promise<JuryOutput>
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run the full Council deliberation pipeline.
|
|
65
|
+
* Proposes the verdict to Oracle automatically — a human must call
|
|
66
|
+
* oracle.commit(proposalId) to index it into Chronicle.
|
|
67
|
+
*/
|
|
68
|
+
deliberate: (
|
|
69
|
+
input: Omit<CouncilInput, "jury_output"> & { jury_output: JuryOutput },
|
|
70
|
+
) => Promise<CouncilOutput>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wire up all three modules from a single call.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* import { setup } from "./modules/setup"
|
|
78
|
+
*
|
|
79
|
+
* const { oracle, evaluate, deliberate } = await setup({
|
|
80
|
+
* llm: myLLMProvider,
|
|
81
|
+
* })
|
|
82
|
+
*
|
|
83
|
+
* const evidence = await oracle.query("authentication patterns")
|
|
84
|
+
* const jury = await evaluate({ outcome, design, evidence })
|
|
85
|
+
* const verdict = await deliberate({ outcome, design, evidence, jury_output: jury })
|
|
86
|
+
*
|
|
87
|
+
* if (verdict.satisfied) {
|
|
88
|
+
* // → human gate → Executor
|
|
89
|
+
* }
|
|
90
|
+
*/
|
|
91
|
+
export async function setup(options: SetupOptions): Promise<Modules> {
|
|
92
|
+
const {
|
|
93
|
+
llm,
|
|
94
|
+
chronicleDir = ".chronicle",
|
|
95
|
+
models = {},
|
|
96
|
+
warmEmbedder: shouldWarm = true,
|
|
97
|
+
embedder = xenovaEmbed,
|
|
98
|
+
} = options
|
|
99
|
+
|
|
100
|
+
// Ensure Chronicle directories exist before anything tries to write to them
|
|
101
|
+
await fs.mkdir(path.join(chronicleDir, "proposals"), { recursive: true })
|
|
102
|
+
await fs.mkdir(path.join(chronicleDir, "committed"), { recursive: true })
|
|
103
|
+
|
|
104
|
+
// Pre-warm the embedder if using the default (downloads model on first use)
|
|
105
|
+
if (shouldWarm && embedder === xenovaEmbed) {
|
|
106
|
+
await warmEmbedder()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const vectorStore = await createLanceDBStore(chronicleDir)
|
|
110
|
+
|
|
111
|
+
// Rebuild local index from committed entries if any are missing.
|
|
112
|
+
// This brings a fresh machine (or a post-git-pull state) up to date
|
|
113
|
+
// without requiring any manual step.
|
|
114
|
+
const committedDir = path.join(chronicleDir, "committed")
|
|
115
|
+
const committedFiles = (await fs.readdir(committedDir)).filter(f => f.endsWith(".json"))
|
|
116
|
+
|
|
117
|
+
if (committedFiles.length > 0) {
|
|
118
|
+
const existing = await vectorStore.getAll()
|
|
119
|
+
const existingIds = new Set(existing.map(e => e.id))
|
|
120
|
+
const missing = committedFiles.filter(f => !existingIds.has(f.replace(".json", "")))
|
|
121
|
+
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
console.log(`[Chronicle] Rebuilding index from ${missing.length} committed ${missing.length === 1 ? "entry" : "entries"}…`)
|
|
124
|
+
for (const file of missing) {
|
|
125
|
+
const raw = await fs.readFile(path.join(committedDir, file), "utf8")
|
|
126
|
+
const entry = JSON.parse(raw) as import("./shared/types").ChronicleEntry
|
|
127
|
+
const embeddingText = [entry.key_insight, ...entry.affected_areas].join(" ")
|
|
128
|
+
const vector = await embedder(embeddingText)
|
|
129
|
+
await vectorStore.upsert(entry.id, vector, entry)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const oracle = createOracleClient({
|
|
135
|
+
embedder,
|
|
136
|
+
vectorStore,
|
|
137
|
+
chronicleDir,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
oracle,
|
|
142
|
+
|
|
143
|
+
evaluate: (input: JuryInput) =>
|
|
144
|
+
evaluate(input, { llm, model: models.jury }),
|
|
145
|
+
|
|
146
|
+
deliberate: (input: CouncilInput) =>
|
|
147
|
+
deliberate(input, {
|
|
148
|
+
llm,
|
|
149
|
+
oracle,
|
|
150
|
+
models: models.council,
|
|
151
|
+
}),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types used across Oracle, Jury, and Council modules.
|
|
3
|
+
* These are the only types that cross module boundaries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Message = {
|
|
7
|
+
role: "system" | "user" | "assistant"
|
|
8
|
+
content: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Injectable LLM provider. Accepts a message array and optional model override.
|
|
13
|
+
* Returns the assistant response as a string.
|
|
14
|
+
*
|
|
15
|
+
* The modules never hardcode a provider — wire this at the application level.
|
|
16
|
+
*/
|
|
17
|
+
export type LLMProvider = (messages: Message[], model?: string) => Promise<string>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Links a Chronicle entry to the unit of work that triggered it.
|
|
21
|
+
* Gives agents the "why now" context that key_insight alone cannot convey.
|
|
22
|
+
*/
|
|
23
|
+
export type WorkRef = {
|
|
24
|
+
type: "bug" | "story" | "epic" | "pr" | "spike"
|
|
25
|
+
/** Ticket number, PR reference, or branch name. e.g. "PROJ-123", "PR #4" */
|
|
26
|
+
ref?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A durable knowledge record stored in Chronicle.
|
|
31
|
+
* This is the canonical unit of institutional memory.
|
|
32
|
+
*/
|
|
33
|
+
export type ChronicleEntry = {
|
|
34
|
+
id: string
|
|
35
|
+
/** The core finding or decision, in one clear sentence. */
|
|
36
|
+
key_insight: string
|
|
37
|
+
/** Parts of the codebase or system this entry applies to. */
|
|
38
|
+
affected_areas: string[]
|
|
39
|
+
status: "validated" | "refuted" | "open"
|
|
40
|
+
/** 0–1. How strongly this was confirmed at write time. */
|
|
41
|
+
confidence: number
|
|
42
|
+
/** Which module produced this entry (detective, council, executor, etc.). */
|
|
43
|
+
source_module: string
|
|
44
|
+
/** IDs of Chronicle entries this decision was based on. */
|
|
45
|
+
evidence_cited: string[]
|
|
46
|
+
/** What actually happened when this was acted on. Added post-execution by Scribe. */
|
|
47
|
+
outcome?: string
|
|
48
|
+
/** The unit of work that triggered this entry. Used to build SUMMARY.md temporal context. */
|
|
49
|
+
work_ref?: WorkRef
|
|
50
|
+
timestamp: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A Chronicle entry enriched with its retrieval score and relevance tier.
|
|
55
|
+
* Returned by Oracle.query().
|
|
56
|
+
*
|
|
57
|
+
* Tiers indicate relevance within the result set:
|
|
58
|
+
* primary — top ~30%: directly answers the query, should be foregrounded
|
|
59
|
+
* supporting — middle ~40%: contextually relevant, useful but not central
|
|
60
|
+
* background — bottom ~30%: loosely related, de-emphasise but do not hide
|
|
61
|
+
*/
|
|
62
|
+
export type OracleResult = ChronicleEntry & {
|
|
63
|
+
score: number
|
|
64
|
+
tier: "primary" | "supporting" | "background"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returned by oracle.propose() when a high-similarity entry already exists.
|
|
69
|
+
* The human gate should surface this before approving the commit.
|
|
70
|
+
*/
|
|
71
|
+
export type SimilarityWarning = {
|
|
72
|
+
entry: ChronicleEntry
|
|
73
|
+
score: number
|
|
74
|
+
/** potential-duplicate: near-identical insight. potential-supersession: likely a correction. */
|
|
75
|
+
warning: "potential-duplicate" | "potential-supersession"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type QueryOptions = {
|
|
79
|
+
statusFilter?: Array<"validated" | "refuted" | "open">
|
|
80
|
+
/** Maximum results to return. Default: 10. */
|
|
81
|
+
limit?: number
|
|
82
|
+
/**
|
|
83
|
+
* Minimum RRF score to include a result.
|
|
84
|
+
* Results below this threshold are dropped entirely — better to return nothing than noise.
|
|
85
|
+
* Default: 0.031.
|
|
86
|
+
*/
|
|
87
|
+
scoreThreshold?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Sentinel types ────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** Per-file result from sentinel.coverage(). */
|
|
93
|
+
export type FileCoverage = {
|
|
94
|
+
file: string
|
|
95
|
+
covered: boolean
|
|
96
|
+
/** IDs of Chronicle entries that reference this file in affected_areas. */
|
|
97
|
+
entryIds: string[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Returned by sentinel.coverage(). */
|
|
101
|
+
export type CoverageReport = {
|
|
102
|
+
totalFiles: number
|
|
103
|
+
coveredFiles: number
|
|
104
|
+
uncoveredFiles: string[]
|
|
105
|
+
coverageByFile: FileCoverage[]
|
|
106
|
+
/** Integer 0–100. Treat as directional signal, not a precision metric. */
|
|
107
|
+
percentage: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Advisory result for a single Chronicle entry from sentinel.detectDrift().
|
|
112
|
+
* Never auto-updates an entry — human reviews the flag and decides.
|
|
113
|
+
*/
|
|
114
|
+
export type DriftFlag = {
|
|
115
|
+
entryId: string
|
|
116
|
+
keyInsight: string
|
|
117
|
+
affectedFiles: string[]
|
|
118
|
+
stillValid: boolean
|
|
119
|
+
/** 0–1 confidence in the LLM's verdict. Low confidence = needs closer human review. */
|
|
120
|
+
confidence: number
|
|
121
|
+
reasoning: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Returned by sentinel.detectDrift(). */
|
|
125
|
+
export type DriftReport = {
|
|
126
|
+
checkedAt: string
|
|
127
|
+
/** Entries the LLM judged as no longer accurate — review and consider updating status. */
|
|
128
|
+
flags: DriftFlag[]
|
|
129
|
+
/** Entries the LLM judged as still current. */
|
|
130
|
+
confirmed: DriftFlag[]
|
|
131
|
+
/** Entry IDs skipped because no affected_areas value resolved to a local file. */
|
|
132
|
+
skipped: string[]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Oracle client ─────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The public interface any module uses to interact with Chronicle.
|
|
139
|
+
* Inject this into Jury and Council — do not couple them to Oracle internals.
|
|
140
|
+
*/
|
|
141
|
+
export interface OracleClient {
|
|
142
|
+
query: (text: string, options?: QueryOptions) => Promise<OracleResult[]>
|
|
143
|
+
propose: (
|
|
144
|
+
entry: Omit<ChronicleEntry, "id" | "timestamp">,
|
|
145
|
+
) => Promise<{ proposalId: string; similarity?: SimilarityWarning }>
|
|
146
|
+
/** Called after human approval. Indexes the proposal into Chronicle. */
|
|
147
|
+
commit: (proposalId: string) => Promise<ChronicleEntry>
|
|
148
|
+
}
|