@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,148 @@
1
+ import { promises as fs } from "fs"
2
+ import path from "path"
3
+ import { randomUUID } from "crypto"
4
+ import { exec } from "child_process"
5
+ import { promisify } from "util"
6
+ import type { ChronicleEntry, SimilarityWarning } from "../shared/types"
7
+ import type { OracleDeps } from "./types"
8
+ import { updateSummary } from "./summary"
9
+
10
+ const execAsync = promisify(exec)
11
+
12
+ const INSIGHT_MIN_LENGTH = 20
13
+ const INSIGHT_MAX_LENGTH = 200
14
+ const SIMILARITY_WARNING_THRESHOLD = 0.85
15
+
16
+ function validateEntry(entry: Omit<ChronicleEntry, "id" | "timestamp">): void {
17
+ const insight = entry.key_insight?.trim() ?? ""
18
+ if (insight.length < INSIGHT_MIN_LENGTH) {
19
+ throw new Error(
20
+ `key_insight too short (${insight.length} chars, min ${INSIGHT_MIN_LENGTH}). ` +
21
+ `Write a specific, complete sentence naming the module or area affected.`,
22
+ )
23
+ }
24
+ if (insight.length > INSIGHT_MAX_LENGTH) {
25
+ throw new Error(
26
+ `key_insight too long (${insight.length} chars, max ${INSIGHT_MAX_LENGTH}). ` +
27
+ `Distil to a single clear sentence.`,
28
+ )
29
+ }
30
+ if (!entry.affected_areas || entry.affected_areas.filter(a => a.trim()).length === 0) {
31
+ throw new Error(`affected_areas must contain at least one non-empty entry.`)
32
+ }
33
+ if (entry.confidence < 0 || entry.confidence > 1) {
34
+ throw new Error(`confidence must be between 0 and 1, got ${entry.confidence}.`)
35
+ }
36
+ }
37
+
38
+ async function checkSimilarity(
39
+ entry: Omit<ChronicleEntry, "id" | "timestamp">,
40
+ deps: OracleDeps,
41
+ ): Promise<SimilarityWarning | undefined> {
42
+ try {
43
+ const text = [entry.key_insight, ...entry.affected_areas].join(" ")
44
+ const vector = await deps.embedder(text)
45
+ const results = await deps.vectorStore.search(vector, 3)
46
+ if (results.length === 0) return undefined
47
+ const top = results[0]
48
+ if (top.score < SIMILARITY_WARNING_THRESHOLD) return undefined
49
+ return {
50
+ entry: top.entry,
51
+ score: top.score,
52
+ // If the existing entry is validated, a near-duplicate is likely a correction
53
+ warning: top.entry.status === "validated" ? "potential-supersession" : "potential-duplicate",
54
+ }
55
+ } catch {
56
+ // Similarity check is best-effort — never block a proposal because of it
57
+ return undefined
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Propose a new Chronicle entry for human review.
63
+ * Validates entry quality and checks for similar existing entries before writing.
64
+ * Writes the entry to .chronicle/proposals/<id>.json — NOT yet indexed.
65
+ * The proposal sits pending until a human calls commit() to approve it.
66
+ *
67
+ * Throws if key_insight is too short/long or affected_areas is empty.
68
+ * Returns a SimilarityWarning if a near-identical entry already exists —
69
+ * the human gate should surface this before approving the commit.
70
+ */
71
+ export async function propose(
72
+ entry: Omit<ChronicleEntry, "id" | "timestamp">,
73
+ deps: OracleDeps,
74
+ ): Promise<{ proposalId: string; similarity?: SimilarityWarning }> {
75
+ validateEntry(entry)
76
+
77
+ const similarity = await checkSimilarity(entry, deps)
78
+
79
+ const chronicleDir = deps.chronicleDir ?? ".chronicle"
80
+ const proposalsDir = path.join(chronicleDir, "proposals")
81
+ await fs.mkdir(proposalsDir, { recursive: true })
82
+
83
+ const proposalId = randomUUID()
84
+ const proposalPath = path.join(proposalsDir, `${proposalId}.json`)
85
+ await fs.writeFile(proposalPath, JSON.stringify(entry, null, 2), "utf8")
86
+
87
+ return { proposalId, ...(similarity ? { similarity } : {}) }
88
+ }
89
+
90
+ /**
91
+ * Commit a pending proposal after human approval.
92
+ * Reads the proposal file, assigns an ID and timestamp, embeds the entry,
93
+ * upserts it into the vector store, and deletes the proposal file.
94
+ *
95
+ * Throws if the proposal does not exist.
96
+ */
97
+ export async function commit(
98
+ proposalId: string,
99
+ deps: OracleDeps,
100
+ ): Promise<ChronicleEntry> {
101
+ const chronicleDir = deps.chronicleDir ?? ".chronicle"
102
+ const proposalPath = path.join(chronicleDir, "proposals", `${proposalId}.json`)
103
+
104
+ let raw: string
105
+ try {
106
+ raw = await fs.readFile(proposalPath, "utf8")
107
+ } catch {
108
+ throw new Error(`Proposal not found: ${proposalId}`)
109
+ }
110
+
111
+ const partial = JSON.parse(raw) as Omit<ChronicleEntry, "id" | "timestamp">
112
+
113
+ const entry: ChronicleEntry = {
114
+ ...partial,
115
+ id: randomUUID(),
116
+ timestamp: new Date().toISOString(),
117
+ }
118
+
119
+ // Embed the key insight (plus affected areas for richer retrieval)
120
+ const embeddingText = [entry.key_insight, ...entry.affected_areas].join(" ")
121
+ const vector = await deps.embedder(embeddingText)
122
+ await deps.vectorStore.upsert(entry.id, vector, entry)
123
+
124
+ // Write to committed/ — the git-tracked source of truth shared across the team
125
+ const committedDir = path.join(chronicleDir, "committed")
126
+ await fs.mkdir(committedDir, { recursive: true })
127
+ const committedPath = path.join(committedDir, `${entry.id}.json`)
128
+ await fs.writeFile(committedPath, JSON.stringify(entry, null, 2), "utf8")
129
+
130
+ // Stage the committed entry for the next git commit — best-effort
131
+ try {
132
+ await execAsync(`git add "${committedPath}"`)
133
+ } catch {
134
+ // Not in a git repo, or git is unavailable — silently continue
135
+ }
136
+
137
+ // Rebuild SUMMARY.md — best-effort, never fail a commit
138
+ try {
139
+ await updateSummary(chronicleDir)
140
+ } catch {
141
+ // Summary generation failure must not fail a commit
142
+ }
143
+
144
+ // Remove the proposal — it has been committed
145
+ await fs.unlink(proposalPath)
146
+
147
+ return entry
148
+ }
@@ -0,0 +1,145 @@
1
+ import type { ChronicleEntry, OracleResult, QueryOptions } from "../shared/types"
2
+ import type { OracleDeps } from "./types"
3
+ import { bm25Score, extractDomainTerms } from "./bm25"
4
+ import { appendQueryLog } from "./log"
5
+
6
+ const DEFAULT_LIMIT = 10
7
+ const DEFAULT_SCORE_THRESHOLD = 0.031
8
+ const RRF_K = 60
9
+ /** Retrieve this many vector candidates before BM25 re-ranking. */
10
+ const CANDIDATE_MULTIPLIER = 3
11
+
12
+ /**
13
+ * Reciprocal Rank Fusion score.
14
+ * score = Σ 1 / (k + rank_i) summed across all rank lists.
15
+ * k = 60 (standard constant).
16
+ */
17
+ function rrfScore(ranks: number[]): number {
18
+ return ranks.reduce((sum, rank) => sum + 1 / (RRF_K + rank), 0)
19
+ }
20
+
21
+ /**
22
+ * Two-pass retrieval with Reciprocal Rank Fusion.
23
+ *
24
+ * Pass 1 — vector similarity:
25
+ * Embed the query, retrieve top (limit × CANDIDATE_MULTIPLIER) candidates.
26
+ *
27
+ * Pass 2 — BM25 re-ranking with query enrichment:
28
+ * Extract domain terms from Pass 1 key insights, enrich the query,
29
+ * score candidates with BM25, fuse ranks via RRF.
30
+ *
31
+ * Results below scoreThreshold are dropped entirely.
32
+ * All queries are appended to .chronicle/query-log.jsonl.
33
+ */
34
+ export async function query(
35
+ text: string,
36
+ options: QueryOptions = {},
37
+ deps: OracleDeps,
38
+ ): Promise<OracleResult[]> {
39
+ const {
40
+ statusFilter,
41
+ limit = DEFAULT_LIMIT,
42
+ scoreThreshold = DEFAULT_SCORE_THRESHOLD,
43
+ } = options
44
+
45
+ const startTime = Date.now()
46
+
47
+ // ── Pass 1: vector similarity ──────────────────────────────────────────────
48
+ const queryVector = await deps.embedder(text)
49
+ const candidateLimit = limit * CANDIDATE_MULTIPLIER
50
+ let candidates = await deps.vectorStore.search(queryVector, candidateLimit)
51
+
52
+ // Status filter applied before BM25 to avoid scoring irrelevant entries
53
+ if (statusFilter && statusFilter.length > 0) {
54
+ candidates = candidates.filter(c => statusFilter.includes(c.entry.status))
55
+ }
56
+
57
+ if (candidates.length === 0) {
58
+ await tryLogQuery(text, [], startTime, deps)
59
+ return []
60
+ }
61
+
62
+ // ── Pass 2: BM25 re-ranking with query enrichment ─────────────────────────
63
+ const topInsights = candidates
64
+ .slice(0, Math.min(5, candidates.length))
65
+ .map(c => c.entry.key_insight)
66
+ const domainTerms = extractDomainTerms(topInsights)
67
+ const enrichedQuery =
68
+ domainTerms.length > 0 ? `${text} ${domainTerms.join(" ")}` : text
69
+
70
+ const documents = candidates.map(c =>
71
+ [c.entry.key_insight, ...c.entry.affected_areas].join(" "),
72
+ )
73
+ const bm25Scores = bm25Score(enrichedQuery, documents)
74
+
75
+ // Build BM25 rank lookup (index → rank)
76
+ const bm25RankOf: number[] = new Array(candidates.length)
77
+ bm25Scores
78
+ .map((score, i) => ({ i, score }))
79
+ .sort((a, b) => b.score - a.score)
80
+ .forEach(({ i }, rank) => {
81
+ bm25RankOf[i] = rank
82
+ })
83
+
84
+ // ── RRF fusion ─────────────────────────────────────────────────────────────
85
+ const fused: Array<ChronicleEntry & { score: number }> = candidates.map(
86
+ (candidate, vectorRank) => ({
87
+ ...candidate.entry,
88
+ score: rrfScore([vectorRank, bm25RankOf[vectorRank]]),
89
+ }),
90
+ )
91
+
92
+ fused.sort((a, b) => b.score - a.score)
93
+
94
+ const filtered = fused
95
+ .filter(r => r.score >= scoreThreshold)
96
+ .slice(0, limit)
97
+
98
+ const results = assignTiers(filtered)
99
+
100
+ await tryLogQuery(text, results, startTime, deps)
101
+ return results
102
+ }
103
+
104
+ /**
105
+ * Assign relevance tiers within the result set using relative rank.
106
+ * Top ~30% → primary, next ~40% → supporting, remainder → background.
107
+ * Thresholds are relative so they self-calibrate as Chronicle grows.
108
+ */
109
+ function assignTiers(
110
+ results: Array<ChronicleEntry & { score: number }>,
111
+ ): OracleResult[] {
112
+ const n = results.length
113
+ if (n === 0) return []
114
+ const primaryCount = Math.max(1, Math.ceil(n * 0.3))
115
+ const supportingCount = Math.max(1, Math.ceil(n * 0.4))
116
+ return results.map((r, i) => ({
117
+ ...r,
118
+ tier:
119
+ i < primaryCount ? "primary"
120
+ : i < primaryCount + supportingCount ? "supporting"
121
+ : "background",
122
+ }))
123
+ }
124
+
125
+ async function tryLogQuery(
126
+ text: string,
127
+ results: OracleResult[],
128
+ startTime: number,
129
+ deps: OracleDeps,
130
+ ): Promise<void> {
131
+ try {
132
+ await appendQueryLog(
133
+ {
134
+ query: text,
135
+ results: results.map(r => ({ id: r.id, score: r.score, status: r.status })),
136
+ resultCount: results.length,
137
+ durationMs: Date.now() - startTime,
138
+ timestamp: new Date().toISOString(),
139
+ },
140
+ deps.chronicleDir ?? ".chronicle",
141
+ )
142
+ } catch {
143
+ // Query logging is best-effort — never fail a query because of a log write failure
144
+ }
145
+ }
@@ -0,0 +1,115 @@
1
+ import { promises as fs } from "fs"
2
+ import path from "path"
3
+ import type { ChronicleEntry } from "../shared/types"
4
+
5
+ const SUMMARY_WEEKS = 12
6
+ const DIRECTIVE =
7
+ "<!-- Chronicle Summary v1 — temporal orientation for agents. " +
8
+ "Use for sequence context; query Oracle by entry ID for full reasoning. -->"
9
+
10
+ /**
11
+ * Returns the ISO week string (YYYY-Www) for a given date.
12
+ * Uses the ISO 8601 definition: week 1 is the week containing the first Thursday.
13
+ */
14
+ function isoWeekKey(date: Date): string {
15
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
16
+ const day = d.getUTCDay() || 7
17
+ d.setUTCDate(d.getUTCDate() + 4 - day)
18
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
19
+ const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7)
20
+ return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`
21
+ }
22
+
23
+ function workRefLabel(entry: ChronicleEntry): string {
24
+ if (!entry.work_ref) return "__none__"
25
+ const { type, ref } = entry.work_ref
26
+ return ref ? `[${type} ${ref}]` : `[${type}]`
27
+ }
28
+
29
+ function renderEntry(entry: ChronicleEntry): string {
30
+ const areas = entry.affected_areas.join(", ")
31
+ const id = entry.id.slice(0, 8)
32
+ return `- **[${id}]** ${areas} — \`${entry.status}\` (${entry.confidence.toFixed(2)}) — ${entry.key_insight}`
33
+ }
34
+
35
+ /**
36
+ * Rebuild .chronicle/SUMMARY.md from all committed entries.
37
+ *
38
+ * Groups entries by ISO week (most-recent first), then by work_ref within
39
+ * each week. Shows the last SUMMARY_WEEKS weeks; older entries are omitted
40
+ * (still fully queryable via Oracle).
41
+ *
42
+ * Called by commit() as a best-effort side-effect — never throws.
43
+ */
44
+ export async function updateSummary(chronicleDir: string): Promise<void> {
45
+ const committedDir = path.join(chronicleDir, "committed")
46
+
47
+ let files: string[]
48
+ try {
49
+ files = await fs.readdir(committedDir)
50
+ } catch {
51
+ return
52
+ }
53
+
54
+ const entries: ChronicleEntry[] = []
55
+ for (const file of files) {
56
+ if (!file.endsWith(".json")) continue
57
+ try {
58
+ const raw = await fs.readFile(path.join(committedDir, file), "utf8")
59
+ entries.push(JSON.parse(raw) as ChronicleEntry)
60
+ } catch {
61
+ // Skip malformed entries
62
+ }
63
+ }
64
+
65
+ if (entries.length === 0) return
66
+
67
+ entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
68
+
69
+ // Group by ISO week
70
+ const byWeek = new Map<string, ChronicleEntry[]>()
71
+ for (const entry of entries) {
72
+ const week = isoWeekKey(new Date(entry.timestamp))
73
+ const bucket = byWeek.get(week) ?? []
74
+ bucket.push(entry)
75
+ byWeek.set(week, bucket)
76
+ }
77
+
78
+ const weeks = [...byWeek.keys()].sort().reverse().slice(0, SUMMARY_WEEKS)
79
+
80
+ const lines: string[] = [DIRECTIVE, ""]
81
+
82
+ for (const week of weeks) {
83
+ lines.push(`## Week ${week}`, "")
84
+
85
+ // Group entries within week by work_ref label
86
+ const weekEntries = byWeek.get(week)!
87
+ const byWork = new Map<string, ChronicleEntry[]>()
88
+ for (const entry of weekEntries) {
89
+ const key = workRefLabel(entry)
90
+ const bucket = byWork.get(key) ?? []
91
+ bucket.push(entry)
92
+ byWork.set(key, bucket)
93
+ }
94
+
95
+ // Labelled work groups first, then ungrouped
96
+ const workKeys = [...byWork.keys()].sort((a, b) =>
97
+ a === "__none__" ? 1 : b === "__none__" ? -1 : a.localeCompare(b),
98
+ )
99
+
100
+ for (const key of workKeys) {
101
+ if (key === "__none__") {
102
+ lines.push(`### (no work context — query Oracle by entry ID for details)`)
103
+ } else {
104
+ lines.push(`### ${key}`)
105
+ }
106
+ for (const entry of byWork.get(key)!) {
107
+ lines.push(renderEntry(entry))
108
+ }
109
+ lines.push("")
110
+ }
111
+ }
112
+
113
+ const summaryPath = path.join(chronicleDir, "SUMMARY.md")
114
+ await fs.writeFile(summaryPath, lines.join("\n"), "utf8")
115
+ }
@@ -0,0 +1,32 @@
1
+ import type { ChronicleEntry } from "../shared/types"
2
+
3
+ /**
4
+ * Abstract vector store interface.
5
+ * Swap implementations without changing Oracle logic.
6
+ * Default implementation: LanceDB (see adapters/lance-db.ts).
7
+ */
8
+ export interface VectorStore {
9
+ /**
10
+ * Upsert a Chronicle entry with its embedding vector.
11
+ * If an entry with this ID already exists, it is replaced.
12
+ */
13
+ upsert: (id: string, vector: number[], metadata: ChronicleEntry) => Promise<void>
14
+ /**
15
+ * Return the top-K most similar entries to the given query vector.
16
+ * Scores should be in [0, 1] (higher = more similar).
17
+ */
18
+ search: (
19
+ vector: number[],
20
+ limit: number,
21
+ ) => Promise<Array<{ entry: ChronicleEntry; score: number }>>
22
+ /** Return all stored entries (used for full-corpus BM25 if needed). */
23
+ getAll: () => Promise<ChronicleEntry[]>
24
+ }
25
+
26
+ export interface OracleDeps {
27
+ /** Converts text to a numeric embedding vector. */
28
+ embedder: (text: string) => Promise<number[]>
29
+ vectorStore: VectorStore
30
+ /** Root directory for Chronicle data. Default: ".chronicle" */
31
+ chronicleDir?: string
32
+ }
@@ -0,0 +1,95 @@
1
+ import { coverage } from "./coverage"
2
+ import { detectDrift } from "./drift"
3
+ import type { LLMProvider } from "../shared/types"
4
+
5
+ export interface SentinelAssertOptions {
6
+ chronicleDir?: string
7
+ codebasePath?: string
8
+ /** When provided, drift detection runs. When absent, drift tests are skipped. */
9
+ llm?: LLMProvider
10
+ extensions?: string[]
11
+ /**
12
+ * Chronicle coverage must reach this percentage for the CI test to pass.
13
+ * Default 0 = report gaps as advisory output without failing the build.
14
+ * Raise this as the project matures (e.g. 50 for an established codebase).
15
+ */
16
+ minCoveragePercent?: number
17
+ }
18
+
19
+ /**
20
+ * Returns a set of named assertions designed to be called inside a Vitest
21
+ * describe block. Coverage assertions are deterministic and always run.
22
+ * Drift assertions skip gracefully when no LLM is provided.
23
+ *
24
+ * @example
25
+ * import { describe } from "vitest"
26
+ * import { sentinelAssertions } from "../modules/sentinel/assert"
27
+ *
28
+ * const assertions = sentinelAssertions({ chronicleDir: ".chronicle", codebasePath: "modules" })
29
+ * describe("sentinel", () => { assertions.forEach(a => a()) })
30
+ */
31
+ export function sentinelAssertions(options: SentinelAssertOptions = {}): Array<() => void> {
32
+ const {
33
+ chronicleDir = ".chronicle",
34
+ codebasePath = ".",
35
+ llm,
36
+ extensions,
37
+ minCoveragePercent = 0,
38
+ } = options
39
+
40
+ // Import vitest lazily so this file is usable outside of a test context too
41
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
42
+ const { it, expect, describe: _describe } = require("vitest") as typeof import("vitest")
43
+
44
+ const assertions: Array<() => void> = []
45
+
46
+ // ── Coverage (deterministic, always run) ──────────────────────────────────
47
+ assertions.push(() => {
48
+ const label = minCoveragePercent > 0
49
+ ? `coverage: Chronicle coverage ≥ ${minCoveragePercent}%`
50
+ : "coverage: Chronicle coverage report [advisory]"
51
+ it(label, async () => {
52
+ const report = await coverage(chronicleDir, codebasePath, { extensions })
53
+ if (report.uncoveredFiles.length > 0) {
54
+ const list = report.uncoveredFiles.slice(0, 10).join("\n ")
55
+ const msg = `${report.uncoveredFiles.length} source file(s) have no Chronicle coverage (${report.percentage}% covered):\n ${list}`
56
+ if (minCoveragePercent > 0) {
57
+ expect(report.percentage, msg).toBeGreaterThanOrEqual(minCoveragePercent)
58
+ } else {
59
+ // New project or no threshold set — surface gaps without failing the build
60
+ console.info(`[sentinel] ${msg}`)
61
+ }
62
+ }
63
+ })
64
+ })
65
+
66
+ assertions.push(() => {
67
+ it("coverage: report is readable and well-formed", async () => {
68
+ const report = await coverage(chronicleDir, codebasePath, { extensions })
69
+ expect(report.totalFiles).toBeGreaterThanOrEqual(0)
70
+ expect(report.percentage).toBeGreaterThanOrEqual(0)
71
+ expect(report.percentage).toBeLessThanOrEqual(100)
72
+ })
73
+ })
74
+
75
+ // ── Drift (advisory, skips when no LLM configured) ────────────────────────
76
+ assertions.push(() => {
77
+ it.skipIf(!llm)(
78
+ "drift: no Chronicle entries flagged as potentially stale [advisory]",
79
+ async () => {
80
+ const report = await detectDrift(chronicleDir, codebasePath, llm!)
81
+ if (report.flags.length > 0) {
82
+ const detail = report.flags
83
+ .map(f => ` [${f.entryId.slice(0, 8)}] ${f.keyInsight}\n → ${f.reasoning}`)
84
+ .join("\n")
85
+ expect(
86
+ report.flags,
87
+ `${report.flags.length} Chronicle entry/entries may have drifted (advisory — review before marking refuted):\n${detail}`,
88
+ ).toHaveLength(0)
89
+ }
90
+ },
91
+ )
92
+ })
93
+
94
+ return assertions
95
+ }
@@ -0,0 +1,106 @@
1
+ import { promises as fs, Dirent } from "fs"
2
+ import path from "path"
3
+ import type { ChronicleEntry, CoverageReport, FileCoverage } from "../shared/types"
4
+
5
+ const IGNORED_DIRS = new Set(["node_modules", "dist", ".git", ".chronicle", "coverage", "__tests__"])
6
+ const TEST_SUFFIXES = [".test.ts", ".spec.ts", ".test.js", ".spec.js"]
7
+
8
+ async function walkFiles(
9
+ dir: string,
10
+ extensions: string[],
11
+ excludeTestFiles: boolean,
12
+ ): Promise<string[]> {
13
+ const results: string[] = []
14
+
15
+ async function recurse(current: string): Promise<void> {
16
+ let entries: Dirent<string>[]
17
+ try {
18
+ entries = await fs.readdir(current, { withFileTypes: true, encoding: "utf8" })
19
+ } catch {
20
+ return
21
+ }
22
+ for (const entry of entries) {
23
+ if (entry.isDirectory()) {
24
+ if (!IGNORED_DIRS.has(entry.name)) await recurse(path.join(current, entry.name))
25
+ } else if (extensions.some(ext => entry.name.endsWith(ext))) {
26
+ if (excludeTestFiles && TEST_SUFFIXES.some(s => entry.name.endsWith(s))) continue
27
+ results.push(path.join(current, entry.name))
28
+ }
29
+ }
30
+ }
31
+
32
+ await recurse(dir)
33
+ return results
34
+ }
35
+
36
+ async function readCommittedEntries(chronicleDir: string): Promise<ChronicleEntry[]> {
37
+ const committedDir = path.join(chronicleDir, "committed")
38
+ let files: string[]
39
+ try {
40
+ files = await fs.readdir(committedDir)
41
+ } catch {
42
+ return []
43
+ }
44
+ const entries: ChronicleEntry[] = []
45
+ for (const file of files) {
46
+ if (!file.endsWith(".json")) continue
47
+ try {
48
+ const raw = await fs.readFile(path.join(committedDir, file), "utf8")
49
+ entries.push(JSON.parse(raw) as ChronicleEntry)
50
+ } catch {
51
+ // skip malformed
52
+ }
53
+ }
54
+ return entries
55
+ }
56
+
57
+ function isCovered(relativePath: string, entries: ChronicleEntry[]): { covered: boolean; entryIds: string[] } {
58
+ const matched: string[] = []
59
+ const normalised = relativePath.replace(/\\/g, "/")
60
+ for (const entry of entries) {
61
+ const hits = entry.affected_areas.some(area => {
62
+ const normArea = area.replace(/\\/g, "/")
63
+ return normalised.includes(normArea) || normArea.includes(normalised)
64
+ })
65
+ if (hits) matched.push(entry.id)
66
+ }
67
+ return { covered: matched.length > 0, entryIds: matched }
68
+ }
69
+
70
+ /**
71
+ * Scan the codebase and report which files have Chronicle entries referencing
72
+ * them in affected_areas and which do not.
73
+ *
74
+ * Matching is substring-based — "oracle/propose.ts" in affected_areas covers
75
+ * "modules/oracle/propose.ts" in the codebase. Treat percentage as directional
76
+ * signal, not a precision metric.
77
+ */
78
+ export async function coverage(
79
+ chronicleDir: string,
80
+ codebasePath: string,
81
+ options: { extensions?: string[]; excludeTestFiles?: boolean } = {},
82
+ ): Promise<CoverageReport> {
83
+ const extensions = options.extensions ?? [".ts"]
84
+ const excludeTestFiles = options.excludeTestFiles ?? true
85
+ const [entries, files] = await Promise.all([
86
+ readCommittedEntries(chronicleDir),
87
+ walkFiles(codebasePath, extensions, excludeTestFiles),
88
+ ])
89
+
90
+ const coverageByFile: FileCoverage[] = files.map(absolute => {
91
+ const relative = path.relative(codebasePath, absolute).replace(/\\/g, "/")
92
+ const { covered, entryIds } = isCovered(relative, entries)
93
+ return { file: relative, covered, entryIds }
94
+ })
95
+
96
+ const covered = coverageByFile.filter(f => f.covered)
97
+ const uncovered = coverageByFile.filter(f => !f.covered)
98
+
99
+ return {
100
+ totalFiles: files.length,
101
+ coveredFiles: covered.length,
102
+ uncoveredFiles: uncovered.map(f => f.file),
103
+ coverageByFile,
104
+ percentage: files.length === 0 ? 0 : Math.round((covered.length / files.length) * 100),
105
+ }
106
+ }