mulch-cli 0.4.3 → 0.6.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 (193) hide show
  1. package/README.md +24 -4
  2. package/package.json +11 -16
  3. package/src/api.ts +310 -0
  4. package/src/cli.ts +54 -0
  5. package/src/commands/add.ts +61 -0
  6. package/src/commands/compact.ts +924 -0
  7. package/src/commands/delete.ts +103 -0
  8. package/src/commands/diff.ts +209 -0
  9. package/src/commands/doctor.ts +586 -0
  10. package/src/commands/edit.ts +253 -0
  11. package/src/commands/init.ts +33 -0
  12. package/src/commands/learn.ts +170 -0
  13. package/src/commands/onboard.ts +362 -0
  14. package/src/commands/prime.ts +327 -0
  15. package/src/commands/prune.ts +128 -0
  16. package/src/commands/query.ts +177 -0
  17. package/src/commands/ready.ts +194 -0
  18. package/src/commands/record.ts +959 -0
  19. package/src/commands/search.ts +234 -0
  20. package/src/commands/setup.ts +823 -0
  21. package/src/commands/status.ts +83 -0
  22. package/src/commands/sync.ts +224 -0
  23. package/src/commands/update.ts +112 -0
  24. package/src/commands/validate.ts +107 -0
  25. package/src/index.ts +50 -0
  26. package/src/schemas/config.ts +31 -0
  27. package/src/schemas/index.ts +18 -0
  28. package/src/schemas/record-schema.ts +177 -0
  29. package/src/schemas/record.ts +83 -0
  30. package/src/utils/bm25.ts +243 -0
  31. package/src/utils/budget.ts +157 -0
  32. package/src/utils/config.ts +117 -0
  33. package/src/utils/expertise.ts +379 -0
  34. package/src/utils/format.ts +767 -0
  35. package/src/utils/git.ts +89 -0
  36. package/src/utils/index.ts +54 -0
  37. package/src/utils/json-output.ts +13 -0
  38. package/src/utils/lock.ts +82 -0
  39. package/src/utils/markers.ts +51 -0
  40. package/src/utils/scoring.ts +101 -0
  41. package/src/utils/version.ts +46 -0
  42. package/dist/cli.d.ts +0 -3
  43. package/dist/cli.d.ts.map +0 -1
  44. package/dist/cli.js +0 -50
  45. package/dist/cli.js.map +0 -1
  46. package/dist/commands/add.d.ts +0 -3
  47. package/dist/commands/add.d.ts.map +0 -1
  48. package/dist/commands/add.js +0 -47
  49. package/dist/commands/add.js.map +0 -1
  50. package/dist/commands/compact.d.ts +0 -5
  51. package/dist/commands/compact.d.ts.map +0 -1
  52. package/dist/commands/compact.js +0 -709
  53. package/dist/commands/compact.js.map +0 -1
  54. package/dist/commands/delete.d.ts +0 -3
  55. package/dist/commands/delete.d.ts.map +0 -1
  56. package/dist/commands/delete.js +0 -82
  57. package/dist/commands/delete.js.map +0 -1
  58. package/dist/commands/diff.d.ts +0 -11
  59. package/dist/commands/diff.d.ts.map +0 -1
  60. package/dist/commands/diff.js +0 -170
  61. package/dist/commands/diff.js.map +0 -1
  62. package/dist/commands/doctor.d.ts +0 -3
  63. package/dist/commands/doctor.d.ts.map +0 -1
  64. package/dist/commands/doctor.js +0 -391
  65. package/dist/commands/doctor.js.map +0 -1
  66. package/dist/commands/edit.d.ts +0 -3
  67. package/dist/commands/edit.d.ts.map +0 -1
  68. package/dist/commands/edit.js +0 -210
  69. package/dist/commands/edit.js.map +0 -1
  70. package/dist/commands/init.d.ts +0 -3
  71. package/dist/commands/init.d.ts.map +0 -1
  72. package/dist/commands/init.js +0 -30
  73. package/dist/commands/init.js.map +0 -1
  74. package/dist/commands/learn.d.ts +0 -12
  75. package/dist/commands/learn.d.ts.map +0 -1
  76. package/dist/commands/learn.js +0 -130
  77. package/dist/commands/learn.js.map +0 -1
  78. package/dist/commands/onboard.d.ts +0 -10
  79. package/dist/commands/onboard.d.ts.map +0 -1
  80. package/dist/commands/onboard.js +0 -286
  81. package/dist/commands/onboard.js.map +0 -1
  82. package/dist/commands/prime.d.ts +0 -3
  83. package/dist/commands/prime.d.ts.map +0 -1
  84. package/dist/commands/prime.js +0 -242
  85. package/dist/commands/prime.js.map +0 -1
  86. package/dist/commands/prune.d.ts +0 -8
  87. package/dist/commands/prune.d.ts.map +0 -1
  88. package/dist/commands/prune.js +0 -90
  89. package/dist/commands/prune.js.map +0 -1
  90. package/dist/commands/query.d.ts +0 -3
  91. package/dist/commands/query.d.ts.map +0 -1
  92. package/dist/commands/query.js +0 -118
  93. package/dist/commands/query.js.map +0 -1
  94. package/dist/commands/ready.d.ts +0 -3
  95. package/dist/commands/ready.d.ts.map +0 -1
  96. package/dist/commands/ready.js +0 -160
  97. package/dist/commands/ready.js.map +0 -1
  98. package/dist/commands/record.d.ts +0 -13
  99. package/dist/commands/record.d.ts.map +0 -1
  100. package/dist/commands/record.js +0 -688
  101. package/dist/commands/record.js.map +0 -1
  102. package/dist/commands/search.d.ts +0 -3
  103. package/dist/commands/search.d.ts.map +0 -1
  104. package/dist/commands/search.js +0 -163
  105. package/dist/commands/search.js.map +0 -1
  106. package/dist/commands/setup.d.ts +0 -29
  107. package/dist/commands/setup.d.ts.map +0 -1
  108. package/dist/commands/setup.js +0 -548
  109. package/dist/commands/setup.js.map +0 -1
  110. package/dist/commands/status.d.ts +0 -3
  111. package/dist/commands/status.d.ts.map +0 -1
  112. package/dist/commands/status.js +0 -61
  113. package/dist/commands/status.js.map +0 -1
  114. package/dist/commands/sync.d.ts +0 -3
  115. package/dist/commands/sync.d.ts.map +0 -1
  116. package/dist/commands/sync.js +0 -176
  117. package/dist/commands/sync.js.map +0 -1
  118. package/dist/commands/update.d.ts +0 -3
  119. package/dist/commands/update.d.ts.map +0 -1
  120. package/dist/commands/update.js +0 -72
  121. package/dist/commands/update.js.map +0 -1
  122. package/dist/commands/validate.d.ts +0 -3
  123. package/dist/commands/validate.d.ts.map +0 -1
  124. package/dist/commands/validate.js +0 -86
  125. package/dist/commands/validate.js.map +0 -1
  126. package/dist/index.d.ts +0 -7
  127. package/dist/index.d.ts.map +0 -1
  128. package/dist/index.js +0 -8
  129. package/dist/index.js.map +0 -1
  130. package/dist/schemas/config.d.ts +0 -17
  131. package/dist/schemas/config.d.ts.map +0 -1
  132. package/dist/schemas/config.js +0 -16
  133. package/dist/schemas/config.js.map +0 -1
  134. package/dist/schemas/index.d.ts +0 -5
  135. package/dist/schemas/index.d.ts.map +0 -1
  136. package/dist/schemas/index.js +0 -3
  137. package/dist/schemas/index.js.map +0 -1
  138. package/dist/schemas/record-schema.d.ts +0 -379
  139. package/dist/schemas/record-schema.d.ts.map +0 -1
  140. package/dist/schemas/record-schema.js +0 -148
  141. package/dist/schemas/record-schema.js.map +0 -1
  142. package/dist/schemas/record.d.ts +0 -60
  143. package/dist/schemas/record.d.ts.map +0 -1
  144. package/dist/schemas/record.js +0 -2
  145. package/dist/schemas/record.js.map +0 -1
  146. package/dist/utils/bm25.d.ts +0 -39
  147. package/dist/utils/bm25.d.ts.map +0 -1
  148. package/dist/utils/bm25.js +0 -171
  149. package/dist/utils/bm25.js.map +0 -1
  150. package/dist/utils/budget.d.ts +0 -35
  151. package/dist/utils/budget.d.ts.map +0 -1
  152. package/dist/utils/budget.js +0 -114
  153. package/dist/utils/budget.js.map +0 -1
  154. package/dist/utils/config.d.ts +0 -12
  155. package/dist/utils/config.d.ts.map +0 -1
  156. package/dist/utils/config.js +0 -89
  157. package/dist/utils/config.js.map +0 -1
  158. package/dist/utils/expertise.d.ts +0 -57
  159. package/dist/utils/expertise.d.ts.map +0 -1
  160. package/dist/utils/expertise.js +0 -264
  161. package/dist/utils/expertise.js.map +0 -1
  162. package/dist/utils/format.d.ts +0 -31
  163. package/dist/utils/format.d.ts.map +0 -1
  164. package/dist/utils/format.js +0 -556
  165. package/dist/utils/format.js.map +0 -1
  166. package/dist/utils/git.d.ts +0 -6
  167. package/dist/utils/git.d.ts.map +0 -1
  168. package/dist/utils/git.js +0 -81
  169. package/dist/utils/git.js.map +0 -1
  170. package/dist/utils/index.d.ts +0 -8
  171. package/dist/utils/index.d.ts.map +0 -1
  172. package/dist/utils/index.js +0 -8
  173. package/dist/utils/index.js.map +0 -1
  174. package/dist/utils/json-output.d.ts +0 -8
  175. package/dist/utils/json-output.d.ts.map +0 -1
  176. package/dist/utils/json-output.js +0 -7
  177. package/dist/utils/json-output.js.map +0 -1
  178. package/dist/utils/lock.d.ts +0 -6
  179. package/dist/utils/lock.d.ts.map +0 -1
  180. package/dist/utils/lock.js +0 -70
  181. package/dist/utils/lock.js.map +0 -1
  182. package/dist/utils/markers.d.ts +0 -22
  183. package/dist/utils/markers.d.ts.map +0 -1
  184. package/dist/utils/markers.js +0 -42
  185. package/dist/utils/markers.js.map +0 -1
  186. package/dist/utils/scoring.d.ts +0 -73
  187. package/dist/utils/scoring.d.ts.map +0 -1
  188. package/dist/utils/scoring.js +0 -80
  189. package/dist/utils/scoring.js.map +0 -1
  190. package/dist/utils/version.d.ts +0 -15
  191. package/dist/utils/version.d.ts.map +0 -1
  192. package/dist/utils/version.js +0 -48
  193. package/dist/utils/version.js.map +0 -1
@@ -0,0 +1,117 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import yaml from "js-yaml";
5
+ import type { MulchConfig } from "../schemas/config.ts";
6
+ import { DEFAULT_CONFIG } from "../schemas/config.ts";
7
+
8
+ const MULCH_DIR = ".mulch";
9
+ const CONFIG_FILE = "mulch.config.yaml";
10
+ const EXPERTISE_DIR = "expertise";
11
+
12
+ export const GITATTRIBUTES_LINE = ".mulch/expertise/*.jsonl merge=union";
13
+
14
+ export const MULCH_README = `# .mulch/
15
+
16
+ This directory is managed by [mulch](https://github.com/jayminwest/mulch) — a structured expertise layer for coding agents.
17
+
18
+ ## Key Commands
19
+
20
+ - \`mulch init\` — Initialize a .mulch directory
21
+ - \`mulch add\` — Add a new domain
22
+ - \`mulch record\` — Record an expertise record
23
+ - \`mulch edit\` — Edit an existing record
24
+ - \`mulch query\` — Query expertise records
25
+ - \`mulch prime [domain]\` — Output a priming prompt (optionally scoped to one domain)
26
+ - \`mulch search\` — Search records across domains
27
+ - \`mulch status\` — Show domain statistics
28
+ - \`mulch validate\` — Validate all records against the schema
29
+ - \`mulch prune\` — Remove expired records
30
+
31
+ ## Structure
32
+
33
+ - \`mulch.config.yaml\` — Configuration file
34
+ - \`expertise/\` — JSONL files, one per domain
35
+ `;
36
+
37
+ export function getMulchDir(cwd: string = process.cwd()): string {
38
+ return join(cwd, MULCH_DIR);
39
+ }
40
+
41
+ export function getConfigPath(cwd: string = process.cwd()): string {
42
+ return join(getMulchDir(cwd), CONFIG_FILE);
43
+ }
44
+
45
+ export function getExpertiseDir(cwd: string = process.cwd()): string {
46
+ return join(getMulchDir(cwd), EXPERTISE_DIR);
47
+ }
48
+
49
+ export function validateDomainName(domain: string): void {
50
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(domain)) {
51
+ throw new Error(
52
+ `Invalid domain name: "${domain}". Only alphanumeric characters, hyphens, and underscores are allowed.`,
53
+ );
54
+ }
55
+ }
56
+
57
+ export function getExpertisePath(
58
+ domain: string,
59
+ cwd: string = process.cwd(),
60
+ ): string {
61
+ validateDomainName(domain);
62
+ return join(getExpertiseDir(cwd), `${domain}.jsonl`);
63
+ }
64
+
65
+ export async function readConfig(
66
+ cwd: string = process.cwd(),
67
+ ): Promise<MulchConfig> {
68
+ const configPath = getConfigPath(cwd);
69
+ const content = await readFile(configPath, "utf-8");
70
+ return yaml.load(content) as MulchConfig;
71
+ }
72
+
73
+ export async function writeConfig(
74
+ config: MulchConfig,
75
+ cwd: string = process.cwd(),
76
+ ): Promise<void> {
77
+ const configPath = getConfigPath(cwd);
78
+ const content = yaml.dump(config, { lineWidth: -1 });
79
+ await writeFile(configPath, content, "utf-8");
80
+ }
81
+
82
+ export async function initMulchDir(cwd: string = process.cwd()): Promise<void> {
83
+ const mulchDir = getMulchDir(cwd);
84
+ const expertiseDir = getExpertiseDir(cwd);
85
+ await mkdir(mulchDir, { recursive: true });
86
+ await mkdir(expertiseDir, { recursive: true });
87
+
88
+ // Only write default config if none exists — preserve user customizations
89
+ const configPath = getConfigPath(cwd);
90
+ if (!existsSync(configPath)) {
91
+ await writeConfig({ ...DEFAULT_CONFIG }, cwd);
92
+ }
93
+
94
+ // Create or append .gitattributes with merge=union for JSONL files
95
+ const gitattributesPath = join(cwd, ".gitattributes");
96
+ let existing = "";
97
+ try {
98
+ existing = await readFile(gitattributesPath, "utf-8");
99
+ } catch {
100
+ // File doesn't exist yet — will create it
101
+ }
102
+ if (!existing.includes(GITATTRIBUTES_LINE)) {
103
+ const separator =
104
+ existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
105
+ await writeFile(
106
+ gitattributesPath,
107
+ `${existing + separator + GITATTRIBUTES_LINE}\n`,
108
+ "utf-8",
109
+ );
110
+ }
111
+
112
+ // Create .mulch/README.md if missing
113
+ const readmePath = join(mulchDir, "README.md");
114
+ if (!existsSync(readmePath)) {
115
+ await writeFile(readmePath, MULCH_README, "utf-8");
116
+ }
117
+ }
@@ -0,0 +1,379 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ appendFile,
4
+ readFile,
5
+ rename,
6
+ stat,
7
+ unlink,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import type {
11
+ Classification,
12
+ ExpertiseRecord,
13
+ RecordType,
14
+ } from "../schemas/record.ts";
15
+ import { DEFAULT_BM25_PARAMS, searchBM25 } from "./bm25.ts";
16
+
17
+ export async function readExpertiseFile(
18
+ filePath: string,
19
+ ): Promise<ExpertiseRecord[]> {
20
+ let content: string;
21
+ try {
22
+ content = await readFile(filePath, "utf-8");
23
+ } catch {
24
+ return [];
25
+ }
26
+
27
+ const records: ExpertiseRecord[] = [];
28
+ const lines = content.split("\n").filter((line) => line.trim().length > 0);
29
+ for (const line of lines) {
30
+ const raw = JSON.parse(line) as Record<string, unknown>;
31
+ // Normalize legacy outcome (singular) to outcomes (array) for backward compat
32
+ if (
33
+ "outcome" in raw &&
34
+ raw.outcome !== null &&
35
+ raw.outcome !== undefined &&
36
+ !("outcomes" in raw)
37
+ ) {
38
+ const legacy = raw.outcome as Record<string, unknown>;
39
+ raw.outcomes = [
40
+ {
41
+ status: legacy.status,
42
+ ...(legacy.duration !== undefined
43
+ ? { duration: legacy.duration }
44
+ : {}),
45
+ ...(legacy.test_results !== undefined
46
+ ? { test_results: legacy.test_results }
47
+ : {}),
48
+ ...(legacy.agent !== undefined ? { agent: legacy.agent } : {}),
49
+ },
50
+ ];
51
+ raw.outcome = undefined;
52
+ }
53
+ records.push(raw as unknown as ExpertiseRecord);
54
+ }
55
+ return records;
56
+ }
57
+
58
+ export function generateRecordId(record: ExpertiseRecord): string {
59
+ let key: string;
60
+ switch (record.type) {
61
+ case "convention":
62
+ key = `convention:${record.content}`;
63
+ break;
64
+ case "pattern":
65
+ key = `pattern:${record.name}`;
66
+ break;
67
+ case "failure":
68
+ key = `failure:${record.description}`;
69
+ break;
70
+ case "decision":
71
+ key = `decision:${record.title}`;
72
+ break;
73
+ case "reference":
74
+ key = `reference:${record.name}`;
75
+ break;
76
+ case "guide":
77
+ key = `guide:${record.name}`;
78
+ break;
79
+ }
80
+ return `mx-${createHash("sha256").update(key).digest("hex").slice(0, 6)}`;
81
+ }
82
+
83
+ export async function appendRecord(
84
+ filePath: string,
85
+ record: ExpertiseRecord,
86
+ ): Promise<void> {
87
+ if (!record.id) {
88
+ record.id = generateRecordId(record);
89
+ }
90
+ const line = `${JSON.stringify(record)}\n`;
91
+ await appendFile(filePath, line, "utf-8");
92
+ }
93
+
94
+ export async function createExpertiseFile(filePath: string): Promise<void> {
95
+ await writeFile(filePath, "", "utf-8");
96
+ }
97
+
98
+ export async function getFileModTime(filePath: string): Promise<Date | null> {
99
+ try {
100
+ const stats = await stat(filePath);
101
+ return stats.mtime;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export async function writeExpertiseFile(
108
+ filePath: string,
109
+ records: ExpertiseRecord[],
110
+ ): Promise<void> {
111
+ for (const r of records) {
112
+ if (!r.id) {
113
+ r.id = generateRecordId(r);
114
+ }
115
+ }
116
+ const content =
117
+ records.map((r) => JSON.stringify(r)).join("\n") +
118
+ (records.length > 0 ? "\n" : "");
119
+ const tmpPath = `${filePath}.tmp.${randomBytes(8).toString("hex")}`;
120
+ await writeFile(tmpPath, content, "utf-8");
121
+ try {
122
+ await rename(tmpPath, filePath);
123
+ } catch (err) {
124
+ try {
125
+ await unlink(tmpPath);
126
+ } catch {
127
+ /* best-effort cleanup */
128
+ }
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ export function countRecords(records: ExpertiseRecord[]): number {
134
+ return records.length;
135
+ }
136
+
137
+ export function filterByType(
138
+ records: ExpertiseRecord[],
139
+ type: string,
140
+ ): ExpertiseRecord[] {
141
+ return records.filter((r) => r.type === type);
142
+ }
143
+
144
+ export function filterByClassification(
145
+ records: ExpertiseRecord[],
146
+ classification: string,
147
+ ): ExpertiseRecord[] {
148
+ return records.filter((r) => r.classification === classification);
149
+ }
150
+
151
+ export function filterByFile(
152
+ records: ExpertiseRecord[],
153
+ file: string,
154
+ ): ExpertiseRecord[] {
155
+ const fileLower = file.toLowerCase();
156
+ return records.filter((r) => {
157
+ if ("files" in r && r.files) {
158
+ return r.files.some((f) => f.toLowerCase().includes(fileLower));
159
+ }
160
+ return false;
161
+ });
162
+ }
163
+
164
+ export function findDuplicate(
165
+ existing: ExpertiseRecord[],
166
+ newRecord: ExpertiseRecord,
167
+ ): { index: number; record: ExpertiseRecord } | null {
168
+ for (let i = 0; i < existing.length; i++) {
169
+ const record = existing[i];
170
+ if (record.type !== newRecord.type) continue;
171
+
172
+ switch (record.type) {
173
+ case "pattern":
174
+ if (newRecord.type === "pattern" && record.name === newRecord.name) {
175
+ return { index: i, record };
176
+ }
177
+ break;
178
+ case "decision":
179
+ if (newRecord.type === "decision" && record.title === newRecord.title) {
180
+ return { index: i, record };
181
+ }
182
+ break;
183
+ case "convention":
184
+ if (
185
+ newRecord.type === "convention" &&
186
+ record.content === newRecord.content
187
+ ) {
188
+ return { index: i, record };
189
+ }
190
+ break;
191
+ case "failure":
192
+ if (
193
+ newRecord.type === "failure" &&
194
+ record.description === newRecord.description
195
+ ) {
196
+ return { index: i, record };
197
+ }
198
+ break;
199
+ case "reference":
200
+ if (newRecord.type === "reference" && record.name === newRecord.name) {
201
+ return { index: i, record };
202
+ }
203
+ break;
204
+ case "guide":
205
+ if (newRecord.type === "guide" && record.name === newRecord.name) {
206
+ return { index: i, record };
207
+ }
208
+ break;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+
214
+ export type ResolveResult =
215
+ | { ok: true; index: number; record: ExpertiseRecord }
216
+ | { ok: false; error: string };
217
+
218
+ /**
219
+ * Resolve an identifier to a record within a domain.
220
+ * Accepts: full ID (mx-abc123), bare hash (abc123), or prefix (abc / mx-abc).
221
+ * Returns the unique matching record or an error if not found / ambiguous.
222
+ */
223
+ export function resolveRecordId(
224
+ records: ExpertiseRecord[],
225
+ identifier: string,
226
+ ): ResolveResult {
227
+ // Normalize: strip mx- prefix if present to get the hash part
228
+ const hash = identifier.startsWith("mx-") ? identifier.slice(3) : identifier;
229
+
230
+ // Try exact match first
231
+ const exactIndex = records.findIndex((r) => r.id === `mx-${hash}`);
232
+ if (exactIndex !== -1) {
233
+ return { ok: true, index: exactIndex, record: records[exactIndex] };
234
+ }
235
+
236
+ // Try prefix match
237
+ const matches: Array<{ index: number; record: ExpertiseRecord }> = [];
238
+ for (let i = 0; i < records.length; i++) {
239
+ const rid = records[i].id;
240
+ if (rid?.startsWith(`mx-${hash}`)) {
241
+ matches.push({ index: i, record: records[i] });
242
+ }
243
+ }
244
+
245
+ if (matches.length === 1) {
246
+ return { ok: true, index: matches[0].index, record: matches[0].record };
247
+ }
248
+
249
+ if (matches.length > 1) {
250
+ const ids = matches.map((m) => m.record.id).join(", ");
251
+ return {
252
+ ok: false,
253
+ error: `Ambiguous identifier "${identifier}" matches ${matches.length} records: ${ids}. Use more characters to disambiguate.`,
254
+ };
255
+ }
256
+
257
+ return {
258
+ ok: false,
259
+ error: `Record "${identifier}" not found. Run \`mulch query\` to see record IDs.`,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Search records using BM25 ranking algorithm.
265
+ * Returns records sorted by relevance (highest score first).
266
+ */
267
+ export function searchRecords(
268
+ records: ExpertiseRecord[],
269
+ query: string,
270
+ ): ExpertiseRecord[] {
271
+ const results = searchBM25(records, query, DEFAULT_BM25_PARAMS);
272
+ return results.map((r) => r.record);
273
+ }
274
+
275
+ export interface DomainHealth {
276
+ governance_utilization: number;
277
+ stale_count: number;
278
+ type_distribution: Record<RecordType, number>;
279
+ classification_distribution: Record<Classification, number>;
280
+ oldest_timestamp: string | null;
281
+ newest_timestamp: string | null;
282
+ }
283
+
284
+ /**
285
+ * Check if a record is stale based on classification and shelf life.
286
+ */
287
+ export function isRecordStale(
288
+ record: ExpertiseRecord,
289
+ now: Date,
290
+ shelfLife: { tactical: number; observational: number },
291
+ ): boolean {
292
+ const classification: Classification = record.classification;
293
+
294
+ if (classification === "foundational") {
295
+ return false;
296
+ }
297
+
298
+ const recordedAt = new Date(record.recorded_at);
299
+ const ageInDays = Math.floor(
300
+ (now.getTime() - recordedAt.getTime()) / (1000 * 60 * 60 * 24),
301
+ );
302
+
303
+ if (classification === "tactical") {
304
+ return ageInDays > shelfLife.tactical;
305
+ }
306
+
307
+ if (classification === "observational") {
308
+ return ageInDays > shelfLife.observational;
309
+ }
310
+
311
+ return false;
312
+ }
313
+
314
+ /**
315
+ * Calculate comprehensive health metrics for a domain.
316
+ */
317
+ export function calculateDomainHealth(
318
+ records: ExpertiseRecord[],
319
+ maxEntries: number,
320
+ shelfLife: { tactical: number; observational: number },
321
+ ): DomainHealth {
322
+ const now = new Date();
323
+
324
+ // Initialize distributions
325
+ const typeDistribution: Record<RecordType, number> = {
326
+ convention: 0,
327
+ pattern: 0,
328
+ failure: 0,
329
+ decision: 0,
330
+ reference: 0,
331
+ guide: 0,
332
+ };
333
+
334
+ const classificationDistribution: Record<Classification, number> = {
335
+ foundational: 0,
336
+ tactical: 0,
337
+ observational: 0,
338
+ };
339
+
340
+ let staleCount = 0;
341
+ let oldestTimestamp: string | null = null;
342
+ let newestTimestamp: string | null = null;
343
+
344
+ // Calculate metrics
345
+ for (const record of records) {
346
+ // Type distribution
347
+ typeDistribution[record.type]++;
348
+
349
+ // Classification distribution
350
+ classificationDistribution[record.classification]++;
351
+
352
+ // Stale count
353
+ if (isRecordStale(record, now, shelfLife)) {
354
+ staleCount++;
355
+ }
356
+
357
+ // Oldest/newest timestamps
358
+ const recordedAt = record.recorded_at;
359
+ if (!oldestTimestamp || recordedAt < oldestTimestamp) {
360
+ oldestTimestamp = recordedAt;
361
+ }
362
+ if (!newestTimestamp || recordedAt > newestTimestamp) {
363
+ newestTimestamp = recordedAt;
364
+ }
365
+ }
366
+
367
+ // Governance utilization (as percentage, 0-100)
368
+ const governanceUtilization =
369
+ maxEntries > 0 ? Math.round((records.length / maxEntries) * 100) : 0;
370
+
371
+ return {
372
+ governance_utilization: governanceUtilization,
373
+ stale_count: staleCount,
374
+ type_distribution: typeDistribution,
375
+ classification_distribution: classificationDistribution,
376
+ oldest_timestamp: oldestTimestamp,
377
+ newest_timestamp: newestTimestamp,
378
+ };
379
+ }