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,586 @@
1
+ import { existsSync } from "node:fs";
2
+ import { writeFile as fsWriteFile, readFile, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import Ajv from "ajv";
5
+ import chalk from "chalk";
6
+ import type { Command } from "commander";
7
+ import type { MulchConfig } from "../schemas/config.ts";
8
+ import { recordSchema } from "../schemas/record-schema.ts";
9
+ import type { ExpertiseRecord } from "../schemas/record.ts";
10
+ import {
11
+ getExpertiseDir,
12
+ getExpertisePath,
13
+ getMulchDir,
14
+ readConfig,
15
+ writeConfig,
16
+ } from "../utils/config.ts";
17
+ import {
18
+ createExpertiseFile,
19
+ findDuplicate,
20
+ readExpertiseFile,
21
+ writeExpertiseFile,
22
+ } from "../utils/expertise.ts";
23
+ import { outputJson, outputJsonError } from "../utils/json-output.ts";
24
+ import { withFileLock } from "../utils/lock.ts";
25
+ import {
26
+ compareSemver,
27
+ getCurrentVersion,
28
+ getLatestVersion,
29
+ } from "../utils/version.ts";
30
+ import { isStale } from "./prune.ts";
31
+
32
+ interface DoctorCheck {
33
+ name: string;
34
+ status: "pass" | "warn" | "fail";
35
+ message: string;
36
+ fixable: boolean;
37
+ details: string[];
38
+ }
39
+
40
+ async function checkConfig(cwd?: string): Promise<DoctorCheck> {
41
+ try {
42
+ const mulchDir = getMulchDir(cwd);
43
+ if (!existsSync(mulchDir)) {
44
+ return {
45
+ name: "config",
46
+ status: "fail",
47
+ message: "No .mulch/ directory found",
48
+ fixable: false,
49
+ details: [],
50
+ };
51
+ }
52
+ await readConfig(cwd);
53
+ return {
54
+ name: "config",
55
+ status: "pass",
56
+ message: "Config is valid",
57
+ fixable: false,
58
+ details: [],
59
+ };
60
+ } catch (err) {
61
+ return {
62
+ name: "config",
63
+ status: "fail",
64
+ message: `Config error: ${(err as Error).message}`,
65
+ fixable: false,
66
+ details: [],
67
+ };
68
+ }
69
+ }
70
+
71
+ async function checkJsonlIntegrity(
72
+ config: MulchConfig,
73
+ cwd?: string,
74
+ ): Promise<DoctorCheck> {
75
+ const details: string[] = [];
76
+ for (const domain of config.domains) {
77
+ const filePath = getExpertisePath(domain, cwd);
78
+ let content: string;
79
+ try {
80
+ content = await readFile(filePath, "utf-8");
81
+ } catch {
82
+ continue;
83
+ }
84
+ const lines = content.split("\n");
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i].trim();
87
+ if (line.length === 0) continue;
88
+ try {
89
+ JSON.parse(line);
90
+ } catch {
91
+ details.push(`${domain}:${i + 1} - Invalid JSON`);
92
+ }
93
+ }
94
+ }
95
+ if (details.length > 0) {
96
+ return {
97
+ name: "jsonl-integrity",
98
+ status: "fail",
99
+ message: `${details.length} invalid JSON line(s) found`,
100
+ fixable: true,
101
+ details,
102
+ };
103
+ }
104
+ return {
105
+ name: "jsonl-integrity",
106
+ status: "pass",
107
+ message: "All JSONL lines are valid JSON",
108
+ fixable: true,
109
+ details: [],
110
+ };
111
+ }
112
+
113
+ async function checkSchemaValidation(
114
+ config: MulchConfig,
115
+ cwd?: string,
116
+ ): Promise<DoctorCheck> {
117
+ const ajv = new Ajv();
118
+ const validate = ajv.compile(recordSchema);
119
+ const details: string[] = [];
120
+
121
+ for (const domain of config.domains) {
122
+ const filePath = getExpertisePath(domain, cwd);
123
+ let content: string;
124
+ try {
125
+ content = await readFile(filePath, "utf-8");
126
+ } catch {
127
+ continue;
128
+ }
129
+ const lines = content.split("\n");
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const line = lines[i].trim();
132
+ if (line.length === 0) continue;
133
+ let parsed: unknown;
134
+ try {
135
+ parsed = JSON.parse(line);
136
+ } catch {
137
+ continue; // Already caught by integrity check
138
+ }
139
+ if (!validate(parsed)) {
140
+ const errors = (validate.errors ?? [])
141
+ .map((e) => `${e.instancePath} ${e.message}`)
142
+ .join("; ");
143
+ details.push(`${domain}:${i + 1} - ${errors}`);
144
+ }
145
+ }
146
+ }
147
+ if (details.length > 0) {
148
+ return {
149
+ name: "schema-validation",
150
+ status: "fail",
151
+ message: `${details.length} record(s) failed schema validation`,
152
+ fixable: true,
153
+ details,
154
+ };
155
+ }
156
+ return {
157
+ name: "schema-validation",
158
+ status: "pass",
159
+ message: "All records pass schema validation",
160
+ fixable: true,
161
+ details: [],
162
+ };
163
+ }
164
+
165
+ async function checkStaleRecords(
166
+ config: MulchConfig,
167
+ cwd?: string,
168
+ ): Promise<DoctorCheck> {
169
+ const now = new Date();
170
+ const shelfLife = config.classification_defaults.shelf_life;
171
+ const details: string[] = [];
172
+ let staleCount = 0;
173
+
174
+ for (const domain of config.domains) {
175
+ const filePath = getExpertisePath(domain, cwd);
176
+ const records = await readExpertiseFile(filePath);
177
+ for (const record of records) {
178
+ if (isStale(record, now, shelfLife)) {
179
+ staleCount++;
180
+ details.push(
181
+ `${domain}: stale ${record.type} (${record.classification})`,
182
+ );
183
+ }
184
+ }
185
+ }
186
+ if (staleCount > 0) {
187
+ return {
188
+ name: "stale-records",
189
+ status: "warn",
190
+ message: `${staleCount} stale record(s) found`,
191
+ fixable: true,
192
+ details,
193
+ };
194
+ }
195
+ return {
196
+ name: "stale-records",
197
+ status: "pass",
198
+ message: "No stale records",
199
+ fixable: true,
200
+ details: [],
201
+ };
202
+ }
203
+
204
+ async function checkOrphanedDomains(
205
+ config: MulchConfig,
206
+ cwd?: string,
207
+ ): Promise<DoctorCheck> {
208
+ const expertiseDir = getExpertiseDir(cwd);
209
+ const details: string[] = [];
210
+
211
+ // Check for JSONL files not in config
212
+ try {
213
+ const files = await readdir(expertiseDir);
214
+ for (const file of files) {
215
+ if (file.endsWith(".jsonl")) {
216
+ const domain = file.replace(".jsonl", "");
217
+ if (!config.domains.includes(domain)) {
218
+ details.push(
219
+ `File "${file}" exists but domain "${domain}" is not in config`,
220
+ );
221
+ }
222
+ }
223
+ }
224
+ } catch {
225
+ // expertise dir doesn't exist
226
+ }
227
+
228
+ // Check for config domains without JSONL files
229
+ for (const domain of config.domains) {
230
+ const filePath = getExpertisePath(domain, cwd);
231
+ if (!existsSync(filePath)) {
232
+ details.push(`Domain "${domain}" in config but no JSONL file exists`);
233
+ }
234
+ }
235
+
236
+ if (details.length > 0) {
237
+ return {
238
+ name: "orphaned-domains",
239
+ status: "warn",
240
+ message: `${details.length} orphaned domain issue(s)`,
241
+ fixable: true,
242
+ details,
243
+ };
244
+ }
245
+ return {
246
+ name: "orphaned-domains",
247
+ status: "pass",
248
+ message: "No orphaned domains",
249
+ fixable: true,
250
+ details: [],
251
+ };
252
+ }
253
+
254
+ async function checkDuplicates(
255
+ config: MulchConfig,
256
+ cwd?: string,
257
+ ): Promise<DoctorCheck> {
258
+ const details: string[] = [];
259
+ let dupCount = 0;
260
+
261
+ for (const domain of config.domains) {
262
+ const filePath = getExpertisePath(domain, cwd);
263
+ const records = await readExpertiseFile(filePath);
264
+ for (let i = 1; i < records.length; i++) {
265
+ const dup = findDuplicate(records.slice(0, i), records[i]);
266
+ if (dup) {
267
+ dupCount++;
268
+ details.push(
269
+ `${domain}: duplicate ${records[i].type} at index ${i + 1} (matches #${dup.index + 1})`,
270
+ );
271
+ }
272
+ }
273
+ }
274
+ if (dupCount > 0) {
275
+ return {
276
+ name: "duplicates",
277
+ status: "warn",
278
+ message: `${dupCount} duplicate record(s) found`,
279
+ fixable: false,
280
+ details,
281
+ };
282
+ }
283
+ return {
284
+ name: "duplicates",
285
+ status: "pass",
286
+ message: "No duplicates",
287
+ fixable: false,
288
+ details: [],
289
+ };
290
+ }
291
+
292
+ async function checkGovernance(
293
+ config: MulchConfig,
294
+ cwd?: string,
295
+ ): Promise<DoctorCheck> {
296
+ const details: string[] = [];
297
+ let worstStatus: "pass" | "warn" | "fail" = "pass";
298
+
299
+ for (const domain of config.domains) {
300
+ const filePath = getExpertisePath(domain, cwd);
301
+ const records = await readExpertiseFile(filePath);
302
+ const count = records.length;
303
+
304
+ if (count >= config.governance.hard_limit) {
305
+ details.push(
306
+ `${domain}: ${count} records (over hard limit of ${config.governance.hard_limit})`,
307
+ );
308
+ worstStatus = "fail";
309
+ } else if (count >= config.governance.warn_entries) {
310
+ details.push(
311
+ `${domain}: ${count} records (over warn threshold of ${config.governance.warn_entries})`,
312
+ );
313
+ if (worstStatus !== "fail") worstStatus = "warn";
314
+ } else if (count >= config.governance.max_entries) {
315
+ details.push(
316
+ `${domain}: ${count} records (approaching limit of ${config.governance.max_entries})`,
317
+ );
318
+ if (worstStatus !== "fail") worstStatus = "warn";
319
+ }
320
+ }
321
+
322
+ if (details.length > 0) {
323
+ return {
324
+ name: "governance",
325
+ status: worstStatus,
326
+ message: `${details.length} domain(s) over governance thresholds`,
327
+ fixable: false,
328
+ details,
329
+ };
330
+ }
331
+ return {
332
+ name: "governance",
333
+ status: "pass",
334
+ message: "All domains within governance limits",
335
+ fixable: false,
336
+ details: [],
337
+ };
338
+ }
339
+
340
+ async function checkUpdateAvailable(): Promise<DoctorCheck> {
341
+ const current = getCurrentVersion();
342
+ const latest = getLatestVersion();
343
+
344
+ if (latest === null) {
345
+ return {
346
+ name: "update",
347
+ status: "pass",
348
+ message: `Version ${current} (unable to check registry)`,
349
+ fixable: false,
350
+ details: [],
351
+ };
352
+ }
353
+
354
+ const cmp = compareSemver(current, latest);
355
+ if (cmp >= 0) {
356
+ return {
357
+ name: "update",
358
+ status: "pass",
359
+ message: `Version ${current} is up to date`,
360
+ fixable: false,
361
+ details: [],
362
+ };
363
+ }
364
+
365
+ return {
366
+ name: "update",
367
+ status: "warn",
368
+ message: `Update available: ${current} → ${latest}`,
369
+ fixable: false,
370
+ details: ["Run `mulch update` to update"],
371
+ };
372
+ }
373
+
374
+ async function applyFixes(
375
+ checks: DoctorCheck[],
376
+ config: MulchConfig,
377
+ cwd?: string,
378
+ ): Promise<string[]> {
379
+ const fixed: string[] = [];
380
+
381
+ for (const check of checks) {
382
+ if (check.status === "pass" || !check.fixable) continue;
383
+
384
+ switch (check.name) {
385
+ case "jsonl-integrity": {
386
+ // Remove invalid JSON lines
387
+ for (const domain of config.domains) {
388
+ const filePath = getExpertisePath(domain, cwd);
389
+ await withFileLock(filePath, async () => {
390
+ let content: string;
391
+ try {
392
+ content = await readFile(filePath, "utf-8");
393
+ } catch {
394
+ return;
395
+ }
396
+ const lines = content.split("\n");
397
+ const valid: string[] = [];
398
+ let removed = 0;
399
+ for (const line of lines) {
400
+ const trimmed = line.trim();
401
+ if (trimmed.length === 0) continue;
402
+ try {
403
+ JSON.parse(trimmed);
404
+ valid.push(trimmed);
405
+ } catch {
406
+ removed++;
407
+ }
408
+ }
409
+ if (removed > 0) {
410
+ await fsWriteFile(
411
+ filePath,
412
+ valid.map((l) => l).join("\n") + (valid.length > 0 ? "\n" : ""),
413
+ "utf-8",
414
+ );
415
+ fixed.push(
416
+ `Removed ${removed} invalid JSON line(s) from ${domain}`,
417
+ );
418
+ }
419
+ });
420
+ }
421
+ break;
422
+ }
423
+
424
+ case "schema-validation": {
425
+ const ajv = new Ajv();
426
+ const validate = ajv.compile(recordSchema);
427
+ for (const domain of config.domains) {
428
+ const filePath = getExpertisePath(domain, cwd);
429
+ await withFileLock(filePath, async () => {
430
+ const records = await readExpertiseFile(filePath);
431
+ const valid = records.filter((r) => validate(r));
432
+ const removed = records.length - valid.length;
433
+ if (removed > 0) {
434
+ await writeExpertiseFile(filePath, valid);
435
+ fixed.push(`Removed ${removed} invalid record(s) from ${domain}`);
436
+ }
437
+ });
438
+ }
439
+ break;
440
+ }
441
+
442
+ case "stale-records": {
443
+ const now = new Date();
444
+ const shelfLife = config.classification_defaults.shelf_life;
445
+ for (const domain of config.domains) {
446
+ const filePath = getExpertisePath(domain, cwd);
447
+ await withFileLock(filePath, async () => {
448
+ const records = await readExpertiseFile(filePath);
449
+ const kept = records.filter((r) => !isStale(r, now, shelfLife));
450
+ const pruned = records.length - kept.length;
451
+ if (pruned > 0) {
452
+ await writeExpertiseFile(filePath, kept);
453
+ fixed.push(`Pruned ${pruned} stale record(s) from ${domain}`);
454
+ }
455
+ });
456
+ }
457
+ break;
458
+ }
459
+
460
+ case "orphaned-domains": {
461
+ const expertiseDir = getExpertiseDir(cwd);
462
+ // Add missing domains to config
463
+ try {
464
+ const files = await readdir(expertiseDir);
465
+ for (const file of files) {
466
+ if (file.endsWith(".jsonl")) {
467
+ const domain = file.replace(".jsonl", "");
468
+ if (!config.domains.includes(domain)) {
469
+ config.domains.push(domain);
470
+ fixed.push(`Added orphaned domain "${domain}" to config`);
471
+ }
472
+ }
473
+ }
474
+ } catch {
475
+ // expertise dir doesn't exist
476
+ }
477
+ // Create missing JSONL files
478
+ for (const domain of config.domains) {
479
+ const filePath = getExpertisePath(domain, cwd);
480
+ if (!existsSync(filePath)) {
481
+ await createExpertiseFile(filePath);
482
+ fixed.push(`Created missing JSONL file for domain "${domain}"`);
483
+ }
484
+ }
485
+ if (fixed.length > 0) {
486
+ await writeConfig(config, cwd);
487
+ }
488
+ break;
489
+ }
490
+ }
491
+ }
492
+
493
+ return fixed;
494
+ }
495
+
496
+ export function registerDoctorCommand(program: Command): void {
497
+ program
498
+ .command("doctor")
499
+ .description("Run health checks on expertise records")
500
+ .option("--fix", "auto-fix fixable issues")
501
+ .action(async (options: { fix?: boolean }) => {
502
+ const jsonMode = program.opts().json === true;
503
+
504
+ // Check config first — if it fails, we can't run other checks
505
+ const configCheck = await checkConfig();
506
+ if (configCheck.status === "fail") {
507
+ const checks = [configCheck];
508
+ const summary = { pass: 0, warn: 0, fail: 1 };
509
+ if (jsonMode) {
510
+ outputJson({ success: false, command: "doctor", checks, summary });
511
+ } else {
512
+ console.log("Mulch Doctor");
513
+ console.log(chalk.red(` ✘ ${configCheck.message}`));
514
+ console.log("\n0 passed, 0 warnings, 1 failed");
515
+ }
516
+ process.exitCode = 1;
517
+ return;
518
+ }
519
+
520
+ const config = await readConfig();
521
+
522
+ const checks: DoctorCheck[] = [configCheck];
523
+ checks.push(await checkJsonlIntegrity(config));
524
+ checks.push(await checkSchemaValidation(config));
525
+ checks.push(await checkStaleRecords(config));
526
+ checks.push(await checkOrphanedDomains(config));
527
+ checks.push(await checkDuplicates(config));
528
+ checks.push(await checkGovernance(config));
529
+ checks.push(await checkUpdateAvailable());
530
+
531
+ const summary = {
532
+ pass: checks.filter((c) => c.status === "pass").length,
533
+ warn: checks.filter((c) => c.status === "warn").length,
534
+ fail: checks.filter((c) => c.status === "fail").length,
535
+ };
536
+
537
+ let fixed: string[] = [];
538
+ if (options.fix) {
539
+ fixed = await applyFixes(checks, config);
540
+ }
541
+
542
+ if (jsonMode) {
543
+ outputJson({
544
+ success: summary.fail === 0,
545
+ command: "doctor",
546
+ checks,
547
+ summary,
548
+ ...(options.fix && { fixed }),
549
+ });
550
+ } else {
551
+ console.log("Mulch Doctor");
552
+ for (const check of checks) {
553
+ const icon =
554
+ check.status === "pass"
555
+ ? chalk.green("✔")
556
+ : check.status === "warn"
557
+ ? chalk.yellow("⚠")
558
+ : chalk.red("✘");
559
+ const msg =
560
+ check.status === "pass" ? check.message : `${check.message}`;
561
+ console.log(` ${icon} ${msg}`);
562
+
563
+ // Print details for non-pass checks
564
+ if (check.status !== "pass" && check.details.length > 0) {
565
+ for (const detail of check.details) {
566
+ console.log(` ${detail}`);
567
+ }
568
+ }
569
+ }
570
+ console.log(
571
+ `\n${summary.pass} passed, ${summary.warn} warning(s), ${summary.fail} failed`,
572
+ );
573
+
574
+ if (fixed.length > 0) {
575
+ console.log(`\n${chalk.green("Fixed:")}`);
576
+ for (const f of fixed) {
577
+ console.log(` ${chalk.green("✔")} ${f}`);
578
+ }
579
+ }
580
+ }
581
+
582
+ if (summary.fail > 0) {
583
+ process.exitCode = 1;
584
+ }
585
+ });
586
+ }