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,924 @@
1
+ import { createInterface } from "node:readline";
2
+ import Ajv from "ajv";
3
+ import chalk from "chalk";
4
+ import type { Command } from "commander";
5
+ import { recordSchema } from "../schemas/record-schema.ts";
6
+ import type { ExpertiseRecord, RecordType } from "../schemas/record.ts";
7
+ import { getExpertisePath, readConfig } from "../utils/config.ts";
8
+ import {
9
+ generateRecordId,
10
+ readExpertiseFile,
11
+ resolveRecordId,
12
+ writeExpertiseFile,
13
+ } from "../utils/expertise.ts";
14
+ import { getRecordSummary } from "../utils/format.ts";
15
+ import { outputJson, outputJsonError } from "../utils/json-output.ts";
16
+ import { withFileLock } from "../utils/lock.ts";
17
+
18
+ interface CompactCandidate {
19
+ domain: string;
20
+ type: RecordType;
21
+ records: Array<{
22
+ id: string | undefined;
23
+ summary: string;
24
+ recorded_at: string;
25
+ }>;
26
+ }
27
+
28
+ function findCandidates(
29
+ domain: string,
30
+ records: ExpertiseRecord[],
31
+ now: Date,
32
+ shelfLife: { tactical: number; observational: number },
33
+ minGroupSize = 3,
34
+ ): CompactCandidate[] {
35
+ // Group records by type
36
+ const byType = new Map<RecordType, ExpertiseRecord[]>();
37
+ for (const r of records) {
38
+ if (!byType.has(r.type)) {
39
+ byType.set(r.type, []);
40
+ }
41
+ byType.get(r.type)!.push(r);
42
+ }
43
+
44
+ const candidates: CompactCandidate[] = [];
45
+
46
+ for (const [type, group] of byType) {
47
+ if (group.length < 2) continue;
48
+
49
+ // Include groups where at least one record is stale or the group is large enough
50
+ const hasStale = group.some((r) => {
51
+ if (r.classification === "foundational") return false;
52
+ const ageMs = now.getTime() - new Date(r.recorded_at).getTime();
53
+ const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
54
+ if (r.classification === "tactical") return ageDays > shelfLife.tactical;
55
+ if (r.classification === "observational")
56
+ return ageDays > shelfLife.observational;
57
+ return false;
58
+ });
59
+
60
+ if (hasStale || group.length >= minGroupSize) {
61
+ candidates.push({
62
+ domain,
63
+ type,
64
+ records: group.map((r) => ({
65
+ id: r.id,
66
+ summary: getRecordSummary(r),
67
+ recorded_at: r.recorded_at,
68
+ })),
69
+ });
70
+ }
71
+ }
72
+
73
+ return candidates;
74
+ }
75
+
76
+ function resolveRecordIds(
77
+ records: ExpertiseRecord[],
78
+ identifiers: string[],
79
+ ): number[] {
80
+ const indices: number[] = [];
81
+ for (const id of identifiers) {
82
+ const result = resolveRecordId(records, id);
83
+ if (!result.ok) {
84
+ throw new Error(result.error);
85
+ }
86
+ indices.push(result.index);
87
+ }
88
+ return indices;
89
+ }
90
+
91
+ async function confirmAction(prompt: string): Promise<boolean> {
92
+ const rl = createInterface({
93
+ input: process.stdin,
94
+ output: process.stdout,
95
+ });
96
+
97
+ return new Promise((resolve) => {
98
+ rl.question(`${prompt} (y/N): `, (answer) => {
99
+ rl.close();
100
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
101
+ });
102
+ });
103
+ }
104
+
105
+ export function registerCompactCommand(program: Command): void {
106
+ program
107
+ .command("compact")
108
+ .argument("[domain]", "expertise domain (required for --apply)")
109
+ .description("Compact records: analyze candidates or apply a compaction")
110
+ .option("--analyze", "show compaction candidates")
111
+ .option("--apply", "apply a compaction (replace records with summary)")
112
+ .option("--auto", "automatically compact all candidates")
113
+ .option(
114
+ "--dry-run",
115
+ "preview what --auto would do without writing (use with --auto)",
116
+ )
117
+ .option(
118
+ "--min-group <size>",
119
+ "minimum group size for auto-compaction (default: 5)",
120
+ "5",
121
+ )
122
+ .option(
123
+ "--max-records <count>",
124
+ "maximum records to compact in one run (default: 50)",
125
+ "50",
126
+ )
127
+ .option("--yes", "skip confirmation prompts (use with --auto)")
128
+ .option("--records <ids>", "comma-separated record IDs to compact")
129
+ .option("--type <type>", "record type for the replacement")
130
+ .option("--name <name>", "name for replacement (pattern/reference/guide)")
131
+ .option("--title <title>", "title for replacement (decision)")
132
+ .option("--description <description>", "description for replacement")
133
+ .option("--content <content>", "content for replacement (convention)")
134
+ .option("--resolution <resolution>", "resolution for replacement (failure)")
135
+ .option("--rationale <rationale>", "rationale for replacement (decision)")
136
+ .action(
137
+ async (domain: string | undefined, options: Record<string, unknown>) => {
138
+ const jsonMode = program.opts().json === true;
139
+
140
+ if (options.analyze) {
141
+ await handleAnalyze(jsonMode, domain);
142
+ } else if (options.auto) {
143
+ await handleAuto(options, jsonMode, domain);
144
+ } else if (options.apply) {
145
+ if (!domain) {
146
+ const msg = "Domain is required for --apply.";
147
+ if (jsonMode) {
148
+ outputJsonError("compact", msg);
149
+ } else {
150
+ console.error(chalk.red(`Error: ${msg}`));
151
+ }
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ await handleApply(domain, options, jsonMode);
156
+ } else {
157
+ const msg = "Specify --analyze, --auto, or --apply.";
158
+ if (jsonMode) {
159
+ outputJsonError("compact", msg);
160
+ } else {
161
+ console.error(chalk.red(`Error: ${msg}`));
162
+ }
163
+ process.exitCode = 1;
164
+ }
165
+ },
166
+ );
167
+ }
168
+
169
+ async function handleAnalyze(
170
+ jsonMode: boolean,
171
+ domain?: string,
172
+ ): Promise<void> {
173
+ const config = await readConfig();
174
+ const now = new Date();
175
+ const shelfLife = config.classification_defaults.shelf_life;
176
+ const allCandidates: CompactCandidate[] = [];
177
+
178
+ // Filter to specific domain if provided, otherwise check all domains
179
+ const domainsToCheck = domain ? [domain] : config.domains;
180
+
181
+ // Validate domain if specified
182
+ if (domain && !config.domains.includes(domain)) {
183
+ const msg = `Domain "${domain}" not found in config.`;
184
+ if (jsonMode) {
185
+ outputJsonError("compact", msg);
186
+ } else {
187
+ console.error(chalk.red(`Error: ${msg}`));
188
+ }
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+
193
+ for (const d of domainsToCheck) {
194
+ const filePath = getExpertisePath(d);
195
+ const records = await readExpertiseFile(filePath);
196
+ if (records.length < 2) continue;
197
+ const candidates = findCandidates(d, records, now, shelfLife);
198
+ allCandidates.push(...candidates);
199
+ }
200
+
201
+ if (jsonMode) {
202
+ outputJson({
203
+ success: true,
204
+ command: "compact",
205
+ action: "analyze",
206
+ candidates: allCandidates,
207
+ });
208
+ return;
209
+ }
210
+
211
+ if (allCandidates.length === 0) {
212
+ console.log(chalk.green("No compaction candidates found."));
213
+ return;
214
+ }
215
+
216
+ // Group candidates by domain for better organization
217
+ const byDomain = new Map<string, CompactCandidate[]>();
218
+ for (const c of allCandidates) {
219
+ if (!byDomain.has(c.domain)) {
220
+ byDomain.set(c.domain, []);
221
+ }
222
+ byDomain.get(c.domain)!.push(c);
223
+ }
224
+
225
+ const totalGroups = allCandidates.length;
226
+ const totalRecords = allCandidates.reduce(
227
+ (sum, c) => sum + c.records.length,
228
+ 0,
229
+ );
230
+
231
+ console.log(chalk.bold("\nCompaction candidates:\n"));
232
+ console.log(
233
+ chalk.dim(
234
+ `Found ${totalGroups} groups (${totalRecords} records that could be compacted)\n`,
235
+ ),
236
+ );
237
+
238
+ for (const [domain, candidates] of byDomain) {
239
+ console.log(chalk.bold(`${domain}:`));
240
+ for (const c of candidates) {
241
+ console.log(` ${chalk.cyan(c.type)} (${c.records.length} records)`);
242
+ for (const r of c.records.slice(0, 3)) {
243
+ console.log(` ${r.id ?? "(no id)"}: ${r.summary}`);
244
+ }
245
+ if (c.records.length > 3) {
246
+ console.log(chalk.dim(` ... and ${c.records.length - 3} more`));
247
+ }
248
+ }
249
+ console.log();
250
+ }
251
+
252
+ console.log(chalk.dim("To compact manually:"));
253
+ console.log(
254
+ chalk.dim(
255
+ " mulch compact <domain> --apply --records <ids> --type <type> [fields...]",
256
+ ),
257
+ );
258
+ console.log(chalk.dim("\nTo compact automatically:"));
259
+ console.log(chalk.dim(" mulch compact --auto [--dry-run]"));
260
+ }
261
+
262
+ async function handleAuto(
263
+ options: Record<string, unknown>,
264
+ jsonMode: boolean,
265
+ domain?: string,
266
+ ): Promise<void> {
267
+ const config = await readConfig();
268
+ const now = new Date();
269
+ const shelfLife = config.classification_defaults.shelf_life;
270
+
271
+ const dryRun = options.dryRun === true;
272
+ const skipConfirmation = options.yes === true;
273
+ const minGroupSize = Number.parseInt(options.minGroup as string, 10) || 5;
274
+ const maxRecords = Number.parseInt(options.maxRecords as string, 10) || 50;
275
+
276
+ // Filter to specific domain if provided, otherwise check all domains
277
+ const domainsToCheck = domain ? [domain] : config.domains;
278
+
279
+ // Validate domain if specified
280
+ if (domain && !config.domains.includes(domain)) {
281
+ const msg = `Domain "${domain}" not found in config.`;
282
+ if (jsonMode) {
283
+ outputJsonError("compact", msg);
284
+ } else {
285
+ console.error(chalk.red(`Error: ${msg}`));
286
+ }
287
+ process.exitCode = 1;
288
+ return;
289
+ }
290
+
291
+ // Collect all candidates across specified domains
292
+ const allCandidates: Array<{ domain: string; candidate: CompactCandidate }> =
293
+ [];
294
+
295
+ for (const d of domainsToCheck) {
296
+ const filePath = getExpertisePath(d);
297
+ const records = await readExpertiseFile(filePath);
298
+ if (records.length < 2) continue;
299
+
300
+ const candidates = findCandidates(d, records, now, shelfLife, minGroupSize);
301
+ for (const candidate of candidates) {
302
+ allCandidates.push({ domain: d, candidate });
303
+ }
304
+ }
305
+
306
+ if (allCandidates.length === 0) {
307
+ if (jsonMode) {
308
+ outputJson({
309
+ success: true,
310
+ command: "compact",
311
+ action: dryRun ? "dry-run" : "auto",
312
+ compacted: 0,
313
+ results: [],
314
+ });
315
+ } else {
316
+ console.log(chalk.green("No compaction candidates found."));
317
+ }
318
+ return;
319
+ }
320
+
321
+ // Calculate total records to compact and apply max limit
322
+ let totalRecordsToCompact = 0;
323
+ const candidatesToProcess: Array<{
324
+ domain: string;
325
+ candidate: CompactCandidate;
326
+ }> = [];
327
+
328
+ for (const item of allCandidates) {
329
+ if (totalRecordsToCompact + item.candidate.records.length > maxRecords) {
330
+ break;
331
+ }
332
+ candidatesToProcess.push(item);
333
+ totalRecordsToCompact += item.candidate.records.length;
334
+ }
335
+
336
+ // Show summary
337
+ if (!jsonMode && !dryRun) {
338
+ console.log(chalk.bold("\nCompaction summary:\n"));
339
+ console.log(` ${candidatesToProcess.length} groups will be compacted`);
340
+ console.log(
341
+ ` ${totalRecordsToCompact} records → ${candidatesToProcess.length} records\n`,
342
+ );
343
+
344
+ for (const { domain, candidate } of candidatesToProcess) {
345
+ console.log(
346
+ `${chalk.cyan(`${domain}/${candidate.type}`)} (${candidate.records.length} records)`,
347
+ );
348
+ for (const r of candidate.records.slice(0, 3)) {
349
+ console.log(` ${r.id ?? "(no id)"}: ${r.summary}`);
350
+ }
351
+ if (candidate.records.length > 3) {
352
+ console.log(
353
+ chalk.dim(` ... and ${candidate.records.length - 3} more`),
354
+ );
355
+ }
356
+ console.log();
357
+ }
358
+
359
+ if (allCandidates.length > candidatesToProcess.length) {
360
+ const skipped = allCandidates.length - candidatesToProcess.length;
361
+ console.log(
362
+ chalk.yellow(
363
+ `Note: ${skipped} additional groups skipped due to --max-records limit\n`,
364
+ ),
365
+ );
366
+ }
367
+ }
368
+
369
+ // Dry-run mode: show detailed preview of what would be done
370
+ if (dryRun) {
371
+ if (jsonMode) {
372
+ outputJson({
373
+ success: true,
374
+ command: "compact",
375
+ action: "dry-run",
376
+ wouldCompact: totalRecordsToCompact,
377
+ groups: candidatesToProcess.map(({ domain, candidate }) => ({
378
+ domain,
379
+ type: candidate.type,
380
+ count: candidate.records.length,
381
+ records: candidate.records,
382
+ })),
383
+ });
384
+ } else {
385
+ console.log(chalk.bold("\nDry-run preview:\n"));
386
+ console.log(` ${candidatesToProcess.length} groups would be compacted`);
387
+ console.log(
388
+ ` ${totalRecordsToCompact} records → ${candidatesToProcess.length} records\n`,
389
+ );
390
+
391
+ for (const { domain, candidate } of candidatesToProcess) {
392
+ console.log(
393
+ `${chalk.cyan(`${domain}/${candidate.type}`)} (${candidate.records.length} records)`,
394
+ );
395
+ for (const r of candidate.records.slice(0, 3)) {
396
+ console.log(` ${r.id ?? "(no id)"}: ${r.summary}`);
397
+ }
398
+ if (candidate.records.length > 3) {
399
+ console.log(
400
+ chalk.dim(` ... and ${candidate.records.length - 3} more`),
401
+ );
402
+ }
403
+ console.log();
404
+ }
405
+
406
+ if (allCandidates.length > candidatesToProcess.length) {
407
+ const skipped = allCandidates.length - candidatesToProcess.length;
408
+ console.log(
409
+ chalk.yellow(
410
+ `Note: ${skipped} additional groups skipped due to --max-records limit\n`,
411
+ ),
412
+ );
413
+ }
414
+
415
+ console.log(
416
+ chalk.green(
417
+ `✓ Dry-run complete. Would compact ${totalRecordsToCompact} records across ${candidatesToProcess.length} groups.`,
418
+ ),
419
+ );
420
+ console.log(chalk.dim(" Run without --dry-run to apply changes."));
421
+ }
422
+ return;
423
+ }
424
+
425
+ // Ask for confirmation unless --yes was passed
426
+ if (!jsonMode && !skipConfirmation) {
427
+ const confirmed = await confirmAction("Proceed with compaction?");
428
+ if (!confirmed) {
429
+ console.log(chalk.yellow("Compaction cancelled."));
430
+ return;
431
+ }
432
+ }
433
+
434
+ // Apply compaction
435
+ let totalCompacted = 0;
436
+ const results: Array<{ domain: string; type: RecordType; count: number }> =
437
+ [];
438
+
439
+ // Group candidates by domain for efficient processing
440
+ const byDomain = new Map<string, CompactCandidate[]>();
441
+ for (const { domain, candidate } of candidatesToProcess) {
442
+ if (!byDomain.has(domain)) {
443
+ byDomain.set(domain, []);
444
+ }
445
+ byDomain.get(domain)!.push(candidate);
446
+ }
447
+
448
+ for (const [domain, candidates] of byDomain) {
449
+ const filePath = getExpertisePath(domain);
450
+
451
+ await withFileLock(filePath, async () => {
452
+ const records = await readExpertiseFile(filePath);
453
+ let updatedRecords = [...records];
454
+
455
+ for (const candidate of candidates) {
456
+ // Find the actual record objects for this candidate
457
+ const recordsToCompact = updatedRecords.filter(
458
+ (r) =>
459
+ r.type === candidate.type &&
460
+ candidate.records.some((cr) => cr.id === r.id),
461
+ );
462
+
463
+ if (recordsToCompact.length < 2) continue;
464
+
465
+ // Create merged replacement record
466
+ const replacement = mergeRecords(recordsToCompact);
467
+
468
+ // Remove old records
469
+ const idsToRemove = new Set(recordsToCompact.map((r) => r.id));
470
+ updatedRecords = updatedRecords.filter((r) => !idsToRemove.has(r.id));
471
+
472
+ // Add replacement
473
+ updatedRecords.push(replacement);
474
+
475
+ totalCompacted += recordsToCompact.length;
476
+ results.push({
477
+ domain,
478
+ type: candidate.type,
479
+ count: recordsToCompact.length,
480
+ });
481
+ }
482
+
483
+ // Write back if changes were made
484
+ if (updatedRecords.length !== records.length) {
485
+ await writeExpertiseFile(filePath, updatedRecords);
486
+ }
487
+ });
488
+ }
489
+
490
+ if (jsonMode) {
491
+ outputJson({
492
+ success: true,
493
+ command: "compact",
494
+ action: "auto",
495
+ compacted: totalCompacted,
496
+ results,
497
+ });
498
+ return;
499
+ }
500
+
501
+ console.log(
502
+ chalk.green(
503
+ `\n✓ Auto-compacted ${totalCompacted} records across ${results.length} groups`,
504
+ ),
505
+ );
506
+ for (const r of results) {
507
+ console.log(chalk.dim(` ${r.domain}/${r.type}: ${r.count} records → 1`));
508
+ }
509
+ }
510
+
511
+ export function mergeRecords(records: ExpertiseRecord[]): ExpertiseRecord {
512
+ if (records.length === 0) {
513
+ throw new Error("Cannot merge empty record list");
514
+ }
515
+
516
+ const type = records[0].type;
517
+ const recordedAt = new Date().toISOString();
518
+ const supersedes = records.map((r) => r.id).filter(Boolean) as string[];
519
+
520
+ // Merge tags (unique union)
521
+ const allTags = records.flatMap((r) => r.tags ?? []);
522
+ const tags = allTags.length > 0 ? Array.from(new Set(allTags)) : undefined;
523
+
524
+ // Merge files (for pattern/reference types)
525
+ const allFiles = records.flatMap((r) =>
526
+ "files" in r ? (r.files ?? []) : [],
527
+ );
528
+ const files = allFiles.length > 0 ? Array.from(new Set(allFiles)) : undefined;
529
+
530
+ let result: ExpertiseRecord;
531
+
532
+ switch (type) {
533
+ case "convention": {
534
+ const contents = records.map((r) => (r as { content: string }).content);
535
+ const content = contents.join("\n\n");
536
+ result = {
537
+ type: "convention",
538
+ content,
539
+ classification: "foundational",
540
+ recorded_at: recordedAt,
541
+ supersedes,
542
+ };
543
+ if (tags) result.tags = tags;
544
+ break;
545
+ }
546
+
547
+ case "pattern": {
548
+ const patterns = records as Array<{ name: string; description: string }>;
549
+ const name = patterns.reduce(
550
+ (longest, p) => (p.name.length > longest.length ? p.name : longest),
551
+ patterns[0].name,
552
+ );
553
+ const description = patterns.map((p) => p.description).join("\n\n");
554
+ result = {
555
+ type: "pattern",
556
+ name,
557
+ description,
558
+ classification: "foundational",
559
+ recorded_at: recordedAt,
560
+ supersedes,
561
+ };
562
+ if (tags) result.tags = tags;
563
+ if (files) result.files = files;
564
+ break;
565
+ }
566
+
567
+ case "failure": {
568
+ const failures = records as Array<{
569
+ description: string;
570
+ resolution: string;
571
+ }>;
572
+ const description = failures.map((f) => f.description).join("\n\n");
573
+ const resolution = failures.map((f) => f.resolution).join("\n\n");
574
+ result = {
575
+ type: "failure",
576
+ description,
577
+ resolution,
578
+ classification: "foundational",
579
+ recorded_at: recordedAt,
580
+ supersedes,
581
+ };
582
+ if (tags) result.tags = tags;
583
+ break;
584
+ }
585
+
586
+ case "decision": {
587
+ const decisions = records as Array<{ title: string; rationale: string }>;
588
+ const title = decisions.reduce(
589
+ (longest, d) => (d.title.length > longest.length ? d.title : longest),
590
+ decisions[0].title,
591
+ );
592
+ const rationale = decisions.map((d) => d.rationale).join("\n\n");
593
+ result = {
594
+ type: "decision",
595
+ title,
596
+ rationale,
597
+ classification: "foundational",
598
+ recorded_at: recordedAt,
599
+ supersedes,
600
+ };
601
+ if (tags) result.tags = tags;
602
+ break;
603
+ }
604
+
605
+ case "reference": {
606
+ const references = records as Array<{
607
+ name: string;
608
+ description: string;
609
+ }>;
610
+ const name = references.reduce(
611
+ (longest, r) => (r.name.length > longest.length ? r.name : longest),
612
+ references[0].name,
613
+ );
614
+ const description = references.map((r) => r.description).join("\n\n");
615
+ result = {
616
+ type: "reference",
617
+ name,
618
+ description,
619
+ classification: "foundational",
620
+ recorded_at: recordedAt,
621
+ supersedes,
622
+ };
623
+ if (tags) result.tags = tags;
624
+ if (files) result.files = files;
625
+ break;
626
+ }
627
+
628
+ case "guide": {
629
+ const guides = records as Array<{ name: string; description: string }>;
630
+ const name = guides.reduce(
631
+ (longest, g) => (g.name.length > longest.length ? g.name : longest),
632
+ guides[0].name,
633
+ );
634
+ const description = guides.map((g) => g.description).join("\n\n");
635
+ result = {
636
+ type: "guide",
637
+ name,
638
+ description,
639
+ classification: "foundational",
640
+ recorded_at: recordedAt,
641
+ supersedes,
642
+ };
643
+ if (tags) result.tags = tags;
644
+ break;
645
+ }
646
+
647
+ default: {
648
+ throw new Error(`Unknown record type: ${type}`);
649
+ }
650
+ }
651
+
652
+ // Generate ID for the merged record
653
+ result.id = generateRecordId(result);
654
+ return result;
655
+ }
656
+
657
+ async function handleApply(
658
+ domain: string,
659
+ options: Record<string, unknown>,
660
+ jsonMode: boolean,
661
+ ): Promise<void> {
662
+ const config = await readConfig();
663
+
664
+ if (!config.domains.includes(domain)) {
665
+ const msg = `Domain "${domain}" not found in config.`;
666
+ if (jsonMode) {
667
+ outputJsonError("compact", msg);
668
+ } else {
669
+ console.error(chalk.red(`Error: ${msg}`));
670
+ }
671
+ process.exitCode = 1;
672
+ return;
673
+ }
674
+
675
+ if (typeof options.records !== "string") {
676
+ const msg = "--records is required for --apply.";
677
+ if (jsonMode) {
678
+ outputJsonError("compact", msg);
679
+ } else {
680
+ console.error(chalk.red(`Error: ${msg}`));
681
+ }
682
+ process.exitCode = 1;
683
+ return;
684
+ }
685
+
686
+ const filePath = getExpertisePath(domain);
687
+ await withFileLock(filePath, async () => {
688
+ const records = await readExpertiseFile(filePath);
689
+ const identifiers = (options.records as string)
690
+ .split(",")
691
+ .map((s) => s.trim())
692
+ .filter(Boolean);
693
+
694
+ let indicesToRemove: number[];
695
+ try {
696
+ indicesToRemove = resolveRecordIds(records, identifiers);
697
+ } catch (err) {
698
+ const msg = (err as Error).message;
699
+ if (jsonMode) {
700
+ outputJsonError("compact", msg);
701
+ } else {
702
+ console.error(chalk.red(`Error: ${msg}`));
703
+ }
704
+ process.exitCode = 1;
705
+ return;
706
+ }
707
+
708
+ if (indicesToRemove.length < 2) {
709
+ const msg = "Compaction requires at least 2 records.";
710
+ if (jsonMode) {
711
+ outputJsonError("compact", msg);
712
+ } else {
713
+ console.error(chalk.red(`Error: ${msg}`));
714
+ }
715
+ process.exitCode = 1;
716
+ return;
717
+ }
718
+
719
+ // Build replacement record
720
+ const recordType =
721
+ (options.type as RecordType | undefined) ??
722
+ records[indicesToRemove[0]].type;
723
+ const recordedAt = new Date().toISOString();
724
+ const compactedFrom = indicesToRemove
725
+ .map((i) => records[i].id)
726
+ .filter(Boolean) as string[];
727
+
728
+ let replacement: ExpertiseRecord;
729
+
730
+ switch (recordType) {
731
+ case "convention": {
732
+ const content =
733
+ (options.content as string | undefined) ??
734
+ (options.description as string | undefined);
735
+ if (!content) {
736
+ const msg =
737
+ "Replacement convention requires --content or --description.";
738
+ if (jsonMode) {
739
+ outputJsonError("compact", msg);
740
+ } else {
741
+ console.error(chalk.red(`Error: ${msg}`));
742
+ }
743
+ process.exitCode = 1;
744
+ return;
745
+ }
746
+ replacement = {
747
+ type: "convention",
748
+ content,
749
+ classification: "foundational",
750
+ recorded_at: recordedAt,
751
+ };
752
+ break;
753
+ }
754
+ case "pattern": {
755
+ const name = options.name as string | undefined;
756
+ const description = options.description as string | undefined;
757
+ if (!name || !description) {
758
+ const msg = "Replacement pattern requires --name and --description.";
759
+ if (jsonMode) {
760
+ outputJsonError("compact", msg);
761
+ } else {
762
+ console.error(chalk.red(`Error: ${msg}`));
763
+ }
764
+ process.exitCode = 1;
765
+ return;
766
+ }
767
+ replacement = {
768
+ type: "pattern",
769
+ name,
770
+ description,
771
+ classification: "foundational",
772
+ recorded_at: recordedAt,
773
+ };
774
+ break;
775
+ }
776
+ case "failure": {
777
+ const description = options.description as string | undefined;
778
+ const resolution = options.resolution as string | undefined;
779
+ if (!description || !resolution) {
780
+ const msg =
781
+ "Replacement failure requires --description and --resolution.";
782
+ if (jsonMode) {
783
+ outputJsonError("compact", msg);
784
+ } else {
785
+ console.error(chalk.red(`Error: ${msg}`));
786
+ }
787
+ process.exitCode = 1;
788
+ return;
789
+ }
790
+ replacement = {
791
+ type: "failure",
792
+ description,
793
+ resolution,
794
+ classification: "foundational",
795
+ recorded_at: recordedAt,
796
+ };
797
+ break;
798
+ }
799
+ case "decision": {
800
+ const title = options.title as string | undefined;
801
+ const rationale = options.rationale as string | undefined;
802
+ if (!title || !rationale) {
803
+ const msg = "Replacement decision requires --title and --rationale.";
804
+ if (jsonMode) {
805
+ outputJsonError("compact", msg);
806
+ } else {
807
+ console.error(chalk.red(`Error: ${msg}`));
808
+ }
809
+ process.exitCode = 1;
810
+ return;
811
+ }
812
+ replacement = {
813
+ type: "decision",
814
+ title,
815
+ rationale,
816
+ classification: "foundational",
817
+ recorded_at: recordedAt,
818
+ };
819
+ break;
820
+ }
821
+ case "reference": {
822
+ const name = options.name as string | undefined;
823
+ const description = options.description as string | undefined;
824
+ if (!name || !description) {
825
+ const msg =
826
+ "Replacement reference requires --name and --description.";
827
+ if (jsonMode) {
828
+ outputJsonError("compact", msg);
829
+ } else {
830
+ console.error(chalk.red(`Error: ${msg}`));
831
+ }
832
+ process.exitCode = 1;
833
+ return;
834
+ }
835
+ replacement = {
836
+ type: "reference",
837
+ name,
838
+ description,
839
+ classification: "foundational",
840
+ recorded_at: recordedAt,
841
+ };
842
+ break;
843
+ }
844
+ case "guide": {
845
+ const name = options.name as string | undefined;
846
+ const description = options.description as string | undefined;
847
+ if (!name || !description) {
848
+ const msg = "Replacement guide requires --name and --description.";
849
+ if (jsonMode) {
850
+ outputJsonError("compact", msg);
851
+ } else {
852
+ console.error(chalk.red(`Error: ${msg}`));
853
+ }
854
+ process.exitCode = 1;
855
+ return;
856
+ }
857
+ replacement = {
858
+ type: "guide",
859
+ name,
860
+ description,
861
+ classification: "foundational",
862
+ recorded_at: recordedAt,
863
+ };
864
+ break;
865
+ }
866
+ default: {
867
+ const msg = `Unknown record type "${recordType}".`;
868
+ if (jsonMode) {
869
+ outputJsonError("compact", msg);
870
+ } else {
871
+ console.error(chalk.red(`Error: ${msg}`));
872
+ }
873
+ process.exitCode = 1;
874
+ return;
875
+ }
876
+ }
877
+
878
+ // Add supersedes links to the compacted-from records
879
+ if (compactedFrom.length > 0) {
880
+ replacement.supersedes = compactedFrom;
881
+ }
882
+
883
+ // Validate replacement
884
+ const ajv = new Ajv();
885
+ const validate = ajv.compile(recordSchema);
886
+ replacement.id = generateRecordId(replacement);
887
+ if (!validate(replacement)) {
888
+ const errors = (validate.errors ?? []).map(
889
+ (err) => `${err.instancePath} ${err.message}`,
890
+ );
891
+ const msg = `Replacement record failed validation: ${errors.join("; ")}`;
892
+ if (jsonMode) {
893
+ outputJsonError("compact", msg);
894
+ } else {
895
+ console.error(chalk.red(`Error: ${msg}`));
896
+ }
897
+ process.exitCode = 1;
898
+ return;
899
+ }
900
+
901
+ // Remove old records and append replacement
902
+ const removeSet = new Set(indicesToRemove);
903
+ const remaining = records.filter((_, i) => !removeSet.has(i));
904
+ remaining.push(replacement);
905
+ await writeExpertiseFile(filePath, remaining);
906
+
907
+ if (jsonMode) {
908
+ outputJson({
909
+ success: true,
910
+ command: "compact",
911
+ action: "applied",
912
+ domain,
913
+ removed: indicesToRemove.length,
914
+ replacement,
915
+ });
916
+ } else {
917
+ console.log(
918
+ chalk.green(
919
+ `\u2714 Compacted ${indicesToRemove.length} ${recordType} records into 1 in ${domain}`,
920
+ ),
921
+ );
922
+ }
923
+ });
924
+ }