@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.
Files changed (39) hide show
  1. package/.github/copilot-instructions.md +94 -0
  2. package/CLAUDE.md +86 -0
  3. package/GEMINI.md +73 -0
  4. package/LICENSE +21 -0
  5. package/README.md +202 -0
  6. package/SETUP.md +256 -0
  7. package/bin/init.js +366 -0
  8. package/modules/AGENTS.md +66 -0
  9. package/modules/CLAUDE.md +64 -0
  10. package/modules/README.md +251 -0
  11. package/modules/council/advisors.ts +68 -0
  12. package/modules/council/chairman.ts +112 -0
  13. package/modules/council/deliberate.ts +106 -0
  14. package/modules/council/frame.ts +54 -0
  15. package/modules/council/index.ts +4 -0
  16. package/modules/council/personas.ts +57 -0
  17. package/modules/council/reviewers.ts +81 -0
  18. package/modules/council/types.ts +45 -0
  19. package/modules/jury/evaluate.ts +112 -0
  20. package/modules/jury/index.ts +3 -0
  21. package/modules/jury/schema.ts +15 -0
  22. package/modules/jury/types.ts +31 -0
  23. package/modules/oracle/adapters/lance-db.ts +81 -0
  24. package/modules/oracle/adapters/xenova-embedder.ts +43 -0
  25. package/modules/oracle/bm25.ts +92 -0
  26. package/modules/oracle/index.ts +36 -0
  27. package/modules/oracle/log.ts +15 -0
  28. package/modules/oracle/propose.ts +148 -0
  29. package/modules/oracle/query.ts +145 -0
  30. package/modules/oracle/summary.ts +115 -0
  31. package/modules/oracle/types.ts +32 -0
  32. package/modules/sentinel/assert.ts +95 -0
  33. package/modules/sentinel/coverage.ts +106 -0
  34. package/modules/sentinel/drift.ts +159 -0
  35. package/modules/sentinel/index.ts +6 -0
  36. package/modules/sentinel/review.ts +207 -0
  37. package/modules/setup.ts +153 -0
  38. package/modules/shared/types.ts +148 -0
  39. 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
+ }
@@ -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
+ }