mulch-cli 0.5.0 → 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 (196) hide show
  1. package/README.md +12 -1
  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/{dist/utils/scoring.d.ts → src/utils/scoring.ts} +53 -9
  41. package/src/utils/version.ts +46 -0
  42. package/dist/api.d.ts +0 -65
  43. package/dist/api.d.ts.map +0 -1
  44. package/dist/api.js +0 -196
  45. package/dist/api.js.map +0 -1
  46. package/dist/cli.d.ts +0 -3
  47. package/dist/cli.d.ts.map +0 -1
  48. package/dist/cli.js +0 -50
  49. package/dist/cli.js.map +0 -1
  50. package/dist/commands/add.d.ts +0 -3
  51. package/dist/commands/add.d.ts.map +0 -1
  52. package/dist/commands/add.js +0 -47
  53. package/dist/commands/add.js.map +0 -1
  54. package/dist/commands/compact.d.ts +0 -5
  55. package/dist/commands/compact.d.ts.map +0 -1
  56. package/dist/commands/compact.js +0 -709
  57. package/dist/commands/compact.js.map +0 -1
  58. package/dist/commands/delete.d.ts +0 -3
  59. package/dist/commands/delete.d.ts.map +0 -1
  60. package/dist/commands/delete.js +0 -82
  61. package/dist/commands/delete.js.map +0 -1
  62. package/dist/commands/diff.d.ts +0 -11
  63. package/dist/commands/diff.d.ts.map +0 -1
  64. package/dist/commands/diff.js +0 -170
  65. package/dist/commands/diff.js.map +0 -1
  66. package/dist/commands/doctor.d.ts +0 -3
  67. package/dist/commands/doctor.d.ts.map +0 -1
  68. package/dist/commands/doctor.js +0 -391
  69. package/dist/commands/doctor.js.map +0 -1
  70. package/dist/commands/edit.d.ts +0 -3
  71. package/dist/commands/edit.d.ts.map +0 -1
  72. package/dist/commands/edit.js +0 -198
  73. package/dist/commands/edit.js.map +0 -1
  74. package/dist/commands/init.d.ts +0 -3
  75. package/dist/commands/init.d.ts.map +0 -1
  76. package/dist/commands/init.js +0 -30
  77. package/dist/commands/init.js.map +0 -1
  78. package/dist/commands/learn.d.ts +0 -12
  79. package/dist/commands/learn.d.ts.map +0 -1
  80. package/dist/commands/learn.js +0 -130
  81. package/dist/commands/learn.js.map +0 -1
  82. package/dist/commands/onboard.d.ts +0 -10
  83. package/dist/commands/onboard.d.ts.map +0 -1
  84. package/dist/commands/onboard.js +0 -286
  85. package/dist/commands/onboard.js.map +0 -1
  86. package/dist/commands/prime.d.ts +0 -3
  87. package/dist/commands/prime.d.ts.map +0 -1
  88. package/dist/commands/prime.js +0 -242
  89. package/dist/commands/prime.js.map +0 -1
  90. package/dist/commands/prune.d.ts +0 -8
  91. package/dist/commands/prune.d.ts.map +0 -1
  92. package/dist/commands/prune.js +0 -90
  93. package/dist/commands/prune.js.map +0 -1
  94. package/dist/commands/query.d.ts +0 -3
  95. package/dist/commands/query.d.ts.map +0 -1
  96. package/dist/commands/query.js +0 -133
  97. package/dist/commands/query.js.map +0 -1
  98. package/dist/commands/ready.d.ts +0 -3
  99. package/dist/commands/ready.d.ts.map +0 -1
  100. package/dist/commands/ready.js +0 -160
  101. package/dist/commands/ready.js.map +0 -1
  102. package/dist/commands/record.d.ts +0 -13
  103. package/dist/commands/record.d.ts.map +0 -1
  104. package/dist/commands/record.js +0 -689
  105. package/dist/commands/record.js.map +0 -1
  106. package/dist/commands/search.d.ts +0 -3
  107. package/dist/commands/search.d.ts.map +0 -1
  108. package/dist/commands/search.js +0 -163
  109. package/dist/commands/search.js.map +0 -1
  110. package/dist/commands/setup.d.ts +0 -29
  111. package/dist/commands/setup.d.ts.map +0 -1
  112. package/dist/commands/setup.js +0 -548
  113. package/dist/commands/setup.js.map +0 -1
  114. package/dist/commands/status.d.ts +0 -3
  115. package/dist/commands/status.d.ts.map +0 -1
  116. package/dist/commands/status.js +0 -61
  117. package/dist/commands/status.js.map +0 -1
  118. package/dist/commands/sync.d.ts +0 -3
  119. package/dist/commands/sync.d.ts.map +0 -1
  120. package/dist/commands/sync.js +0 -176
  121. package/dist/commands/sync.js.map +0 -1
  122. package/dist/commands/update.d.ts +0 -3
  123. package/dist/commands/update.d.ts.map +0 -1
  124. package/dist/commands/update.js +0 -72
  125. package/dist/commands/update.js.map +0 -1
  126. package/dist/commands/validate.d.ts +0 -3
  127. package/dist/commands/validate.d.ts.map +0 -1
  128. package/dist/commands/validate.js +0 -86
  129. package/dist/commands/validate.js.map +0 -1
  130. package/dist/index.d.ts +0 -9
  131. package/dist/index.d.ts.map +0 -1
  132. package/dist/index.js +0 -10
  133. package/dist/index.js.map +0 -1
  134. package/dist/schemas/config.d.ts +0 -17
  135. package/dist/schemas/config.d.ts.map +0 -1
  136. package/dist/schemas/config.js +0 -16
  137. package/dist/schemas/config.js.map +0 -1
  138. package/dist/schemas/index.d.ts +0 -5
  139. package/dist/schemas/index.d.ts.map +0 -1
  140. package/dist/schemas/index.js +0 -3
  141. package/dist/schemas/index.js.map +0 -1
  142. package/dist/schemas/record-schema.d.ts +0 -403
  143. package/dist/schemas/record-schema.d.ts.map +0 -1
  144. package/dist/schemas/record-schema.js +0 -150
  145. package/dist/schemas/record-schema.js.map +0 -1
  146. package/dist/schemas/record.d.ts +0 -62
  147. package/dist/schemas/record.d.ts.map +0 -1
  148. package/dist/schemas/record.js +0 -2
  149. package/dist/schemas/record.js.map +0 -1
  150. package/dist/utils/bm25.d.ts +0 -39
  151. package/dist/utils/bm25.d.ts.map +0 -1
  152. package/dist/utils/bm25.js +0 -171
  153. package/dist/utils/bm25.js.map +0 -1
  154. package/dist/utils/budget.d.ts +0 -35
  155. package/dist/utils/budget.d.ts.map +0 -1
  156. package/dist/utils/budget.js +0 -114
  157. package/dist/utils/budget.js.map +0 -1
  158. package/dist/utils/config.d.ts +0 -12
  159. package/dist/utils/config.d.ts.map +0 -1
  160. package/dist/utils/config.js +0 -89
  161. package/dist/utils/config.js.map +0 -1
  162. package/dist/utils/expertise.d.ts +0 -57
  163. package/dist/utils/expertise.d.ts.map +0 -1
  164. package/dist/utils/expertise.js +0 -276
  165. package/dist/utils/expertise.js.map +0 -1
  166. package/dist/utils/format.d.ts +0 -31
  167. package/dist/utils/format.d.ts.map +0 -1
  168. package/dist/utils/format.js +0 -562
  169. package/dist/utils/format.js.map +0 -1
  170. package/dist/utils/git.d.ts +0 -6
  171. package/dist/utils/git.d.ts.map +0 -1
  172. package/dist/utils/git.js +0 -81
  173. package/dist/utils/git.js.map +0 -1
  174. package/dist/utils/index.d.ts +0 -8
  175. package/dist/utils/index.d.ts.map +0 -1
  176. package/dist/utils/index.js +0 -8
  177. package/dist/utils/index.js.map +0 -1
  178. package/dist/utils/json-output.d.ts +0 -8
  179. package/dist/utils/json-output.d.ts.map +0 -1
  180. package/dist/utils/json-output.js +0 -7
  181. package/dist/utils/json-output.js.map +0 -1
  182. package/dist/utils/lock.d.ts +0 -6
  183. package/dist/utils/lock.d.ts.map +0 -1
  184. package/dist/utils/lock.js +0 -70
  185. package/dist/utils/lock.js.map +0 -1
  186. package/dist/utils/markers.d.ts +0 -22
  187. package/dist/utils/markers.d.ts.map +0 -1
  188. package/dist/utils/markers.js +0 -42
  189. package/dist/utils/markers.js.map +0 -1
  190. package/dist/utils/scoring.d.ts.map +0 -1
  191. package/dist/utils/scoring.js +0 -80
  192. package/dist/utils/scoring.js.map +0 -1
  193. package/dist/utils/version.d.ts +0 -15
  194. package/dist/utils/version.d.ts.map +0 -1
  195. package/dist/utils/version.js +0 -48
  196. package/dist/utils/version.js.map +0 -1
@@ -0,0 +1,362 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import chalk from "chalk";
4
+ import type { Command } from "commander";
5
+ import { outputJson, outputJsonError } from "../utils/json-output.ts";
6
+ import {
7
+ MARKER_END,
8
+ MARKER_START,
9
+ hasMarkerSection,
10
+ replaceMarkerSection,
11
+ wrapInMarkers,
12
+ } from "../utils/markers.ts";
13
+
14
+ export const ONBOARD_VERSION = 1;
15
+ export const VERSION_MARKER = `<!-- mulch-onboard-v:${String(ONBOARD_VERSION)} -->`;
16
+
17
+ const SNIPPET_DEFAULT = `## Project Expertise (Mulch)
18
+ ${VERSION_MARKER}
19
+
20
+ This project uses [Mulch](https://github.com/jayminwest/mulch) for structured expertise management.
21
+
22
+ **At the start of every session**, run:
23
+ \`\`\`bash
24
+ mulch prime
25
+ \`\`\`
26
+
27
+ This injects project-specific conventions, patterns, decisions, and other learnings into your context.
28
+ Use \`mulch prime --files src/foo.ts\` to load only records relevant to specific files.
29
+
30
+ **Before completing your task**, review your work for insights worth preserving — conventions discovered,
31
+ patterns applied, failures encountered, or decisions made — and record them:
32
+ \`\`\`bash
33
+ mulch record <domain> --type <convention|pattern|failure|decision|reference|guide> --description "..."
34
+ \`\`\`
35
+
36
+ Link evidence when available: \`--evidence-commit <sha>\`, \`--evidence-bead <id>\`
37
+
38
+ Run \`mulch status\` to check domain health and entry counts.
39
+ Run \`mulch --help\` for full usage.
40
+ Mulch write commands use file locking and atomic writes — multiple agents can safely record to the same domain concurrently.
41
+
42
+ ### Before You Finish
43
+
44
+ 1. Discover what to record:
45
+ \`\`\`bash
46
+ mulch learn
47
+ \`\`\`
48
+ 2. Store insights from this work session:
49
+ \`\`\`bash
50
+ mulch record <domain> --type <convention|pattern|failure|decision|reference|guide> --description "..."
51
+ \`\`\`
52
+ 3. Validate and commit:
53
+ \`\`\`bash
54
+ mulch sync
55
+ \`\`\`
56
+ `;
57
+
58
+ const LEGACY_HEADER = "## Project Expertise (Mulch)";
59
+ const LEGACY_TAIL =
60
+ 'mulch validate && git add .mulch/ && git commit -m "mulch: record learnings"';
61
+
62
+ function getSnippet(provider: string | undefined): string {
63
+ if (!provider || provider === "default") {
64
+ return SNIPPET_DEFAULT;
65
+ }
66
+ // All providers use the same standardized snippet
67
+ return SNIPPET_DEFAULT;
68
+ }
69
+
70
+ async function fileExists(filePath: string): Promise<boolean> {
71
+ try {
72
+ await access(filePath);
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ interface OnboardTarget {
80
+ path: string;
81
+ fileName: string;
82
+ exists: boolean;
83
+ }
84
+
85
+ function hasLegacySnippet(content: string): boolean {
86
+ return content.includes(LEGACY_HEADER);
87
+ }
88
+
89
+ function replaceLegacySnippet(content: string, newSection: string): string {
90
+ const headerIdx = content.indexOf(LEGACY_HEADER);
91
+ if (headerIdx === -1) return content;
92
+
93
+ const tailIdx = content.indexOf(LEGACY_TAIL, headerIdx);
94
+
95
+ let endIdx: number;
96
+ if (tailIdx !== -1) {
97
+ // Find the closing ``` after the tail line
98
+ const afterTail = content.indexOf("```", tailIdx + LEGACY_TAIL.length);
99
+ if (afterTail !== -1) {
100
+ endIdx = afterTail + 3;
101
+ // Consume trailing newlines
102
+ while (endIdx < content.length && content[endIdx] === "\n") {
103
+ endIdx++;
104
+ }
105
+ } else {
106
+ endIdx = content.length;
107
+ }
108
+ } else {
109
+ // Tail not found (user edited the snippet): take from header to EOF
110
+ endIdx = content.length;
111
+ }
112
+
113
+ const before = content.substring(0, headerIdx);
114
+ const after = content.substring(endIdx);
115
+
116
+ return before + newSection + after;
117
+ }
118
+
119
+ function isSnippetCurrent(content: string): boolean {
120
+ if (!hasMarkerSection(content)) return false;
121
+ return content.includes(VERSION_MARKER);
122
+ }
123
+
124
+ async function findSnippetLocations(cwd: string): Promise<OnboardTarget[]> {
125
+ const candidates = [
126
+ { fileName: "CLAUDE.md", path: join(cwd, "CLAUDE.md") },
127
+ { fileName: ".claude/CLAUDE.md", path: join(cwd, ".claude", "CLAUDE.md") },
128
+ { fileName: "AGENTS.md", path: join(cwd, "AGENTS.md") },
129
+ ];
130
+
131
+ const results: OnboardTarget[] = [];
132
+ for (const c of candidates) {
133
+ const exists = await fileExists(c.path);
134
+ if (exists) {
135
+ const content = await readFile(c.path, "utf-8");
136
+ if (hasMarkerSection(content) || hasLegacySnippet(content)) {
137
+ results.push({ ...c, exists: true });
138
+ }
139
+ }
140
+ }
141
+ return results;
142
+ }
143
+
144
+ async function resolveTargetFile(cwd: string): Promise<{
145
+ target: OnboardTarget;
146
+ duplicates: OnboardTarget[];
147
+ }> {
148
+ const withSnippet = await findSnippetLocations(cwd);
149
+
150
+ // If snippet found in one or more locations, use the first; others are duplicates
151
+ if (withSnippet.length > 0) {
152
+ return {
153
+ target: withSnippet[0],
154
+ duplicates: withSnippet.slice(1),
155
+ };
156
+ }
157
+
158
+ // No snippet found anywhere. Prefer existing CLAUDE.md, else AGENTS.md
159
+ if (await fileExists(join(cwd, "CLAUDE.md"))) {
160
+ return {
161
+ target: {
162
+ fileName: "CLAUDE.md",
163
+ path: join(cwd, "CLAUDE.md"),
164
+ exists: true,
165
+ },
166
+ duplicates: [],
167
+ };
168
+ }
169
+
170
+ const agentsExists = await fileExists(join(cwd, "AGENTS.md"));
171
+ return {
172
+ target: {
173
+ fileName: "AGENTS.md",
174
+ path: join(cwd, "AGENTS.md"),
175
+ exists: agentsExists,
176
+ },
177
+ duplicates: [],
178
+ };
179
+ }
180
+
181
+ type OnboardAction =
182
+ | "created"
183
+ | "appended"
184
+ | "updated"
185
+ | "migrated"
186
+ | "up_to_date"
187
+ | "not_installed"
188
+ | "outdated"
189
+ | "legacy";
190
+
191
+ export async function runOnboard(options: {
192
+ stdout?: boolean;
193
+ provider?: string;
194
+ check?: boolean;
195
+ cwd?: string;
196
+ jsonMode?: boolean;
197
+ }): Promise<void> {
198
+ const cwd = options.cwd ?? process.cwd();
199
+ const snippet = getSnippet(options.provider);
200
+ const wrappedSnippet = wrapInMarkers(snippet);
201
+
202
+ if (options.stdout) {
203
+ process.stdout.write(wrappedSnippet);
204
+ return;
205
+ }
206
+
207
+ const { target, duplicates } = await resolveTargetFile(cwd);
208
+
209
+ // --check: read-only inspection
210
+ if (options.check) {
211
+ let action: OnboardAction;
212
+
213
+ if (!target.exists) {
214
+ action = "not_installed";
215
+ } else {
216
+ const content = await readFile(target.path, "utf-8");
217
+ if (hasMarkerSection(content)) {
218
+ action = isSnippetCurrent(content) ? "up_to_date" : "outdated";
219
+ } else if (hasLegacySnippet(content)) {
220
+ action = "legacy";
221
+ } else {
222
+ action = "not_installed";
223
+ }
224
+ }
225
+
226
+ if (options.jsonMode) {
227
+ outputJson({
228
+ success: true,
229
+ command: "onboard",
230
+ file: target.fileName,
231
+ action,
232
+ });
233
+ } else {
234
+ const messages: Record<string, string> = {
235
+ not_installed: `Mulch snippet is not installed in ${target.fileName}.`,
236
+ up_to_date: `Mulch snippet in ${target.fileName} is up to date.`,
237
+ outdated: `Mulch snippet in ${target.fileName} is outdated. Run \`mulch onboard\` to update.`,
238
+ legacy: `Mulch snippet in ${target.fileName} uses legacy format (no markers). Run \`mulch onboard\` to migrate.`,
239
+ };
240
+ const colors: Record<string, (s: string) => string> = {
241
+ not_installed: chalk.yellow,
242
+ up_to_date: chalk.green,
243
+ outdated: chalk.yellow,
244
+ legacy: chalk.yellow,
245
+ };
246
+ console.log(colors[action](messages[action]));
247
+ }
248
+
249
+ if (duplicates.length > 0) {
250
+ const names = duplicates.map((d) => d.fileName).join(", ");
251
+ if (!options.jsonMode) {
252
+ console.log(
253
+ chalk.yellow(`Warning: mulch snippet also found in: ${names}`),
254
+ );
255
+ }
256
+ }
257
+ return;
258
+ }
259
+
260
+ // Write path
261
+ let action: OnboardAction;
262
+
263
+ if (!target.exists) {
264
+ // Create new file
265
+ await mkdir(dirname(target.path), { recursive: true });
266
+ await writeFile(target.path, `${wrappedSnippet}\n`, "utf-8");
267
+ action = "created";
268
+ } else {
269
+ const content = await readFile(target.path, "utf-8");
270
+
271
+ if (hasMarkerSection(content)) {
272
+ // Check if current
273
+ if (isSnippetCurrent(content)) {
274
+ action = "up_to_date";
275
+ } else {
276
+ // Replace marker section
277
+ const updated = replaceMarkerSection(content, wrappedSnippet);
278
+ if (updated !== null) {
279
+ await writeFile(target.path, updated, "utf-8");
280
+ }
281
+ action = "updated";
282
+ }
283
+ } else if (hasLegacySnippet(content)) {
284
+ // Migrate legacy snippet
285
+ const migrated = replaceLegacySnippet(content, `${wrappedSnippet}\n`);
286
+ await writeFile(target.path, migrated, "utf-8");
287
+ action = "migrated";
288
+ } else {
289
+ // Append to existing file
290
+ await writeFile(
291
+ target.path,
292
+ `${content.trimEnd()}\n\n${wrappedSnippet}\n`,
293
+ "utf-8",
294
+ );
295
+ action = "appended";
296
+ }
297
+ }
298
+
299
+ if (options.jsonMode) {
300
+ outputJson({
301
+ success: true,
302
+ command: "onboard",
303
+ file: target.fileName,
304
+ action,
305
+ });
306
+ } else {
307
+ const messages: Record<string, string> = {
308
+ created: `Mulch onboarding snippet written to ${target.fileName}.`,
309
+ appended: `Mulch onboarding snippet appended to ${target.fileName}.`,
310
+ updated: `Mulch onboarding snippet updated in ${target.fileName}.`,
311
+ migrated: `Mulch onboarding snippet migrated to marker format in ${target.fileName}.`,
312
+ up_to_date: `Mulch snippet in ${target.fileName} is already up to date. No changes made.`,
313
+ };
314
+ const color = action === "up_to_date" ? chalk.yellow : chalk.green;
315
+ console.log(color(messages[action]));
316
+ }
317
+
318
+ if (duplicates.length > 0) {
319
+ const names = duplicates.map((d) => d.fileName).join(", ");
320
+ if (!options.jsonMode) {
321
+ console.log(
322
+ chalk.yellow(`Warning: mulch snippet also found in: ${names}`),
323
+ );
324
+ }
325
+ }
326
+ }
327
+
328
+ export function registerOnboardCommand(program: Command): void {
329
+ program
330
+ .command("onboard")
331
+ .description(
332
+ "Generate or update an AGENTS.md/CLAUDE.md snippet pointing to mulch prime",
333
+ )
334
+ .option("--stdout", "print snippet to stdout instead of writing to file")
335
+ .option(
336
+ "--provider <provider>",
337
+ "customize snippet for a specific provider (e.g. claude)",
338
+ )
339
+ .option(
340
+ "--check",
341
+ "check if onboarding snippet is installed and up to date",
342
+ )
343
+ .action(
344
+ async (options: {
345
+ stdout?: boolean;
346
+ provider?: string;
347
+ check?: boolean;
348
+ }) => {
349
+ const jsonMode = program.opts().json === true;
350
+ try {
351
+ await runOnboard({ ...options, jsonMode });
352
+ } catch (err) {
353
+ if (jsonMode) {
354
+ outputJsonError("onboard", (err as Error).message);
355
+ } else {
356
+ console.error(`Error: ${(err as Error).message}`);
357
+ }
358
+ process.exitCode = 1;
359
+ }
360
+ },
361
+ );
362
+ }
@@ -0,0 +1,327 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import chalk from "chalk";
3
+ import { type Command, Option } from "commander";
4
+ import {
5
+ DEFAULT_BUDGET,
6
+ applyBudget,
7
+ formatBudgetSummary,
8
+ } from "../utils/budget.ts";
9
+ import type { DomainRecords } from "../utils/budget.ts";
10
+ import { getExpertisePath, readConfig } from "../utils/config.ts";
11
+ import { getFileModTime, readExpertiseFile } from "../utils/expertise.ts";
12
+ import {
13
+ formatDomainExpertise,
14
+ formatDomainExpertiseCompact,
15
+ formatDomainExpertisePlain,
16
+ formatDomainExpertiseXml,
17
+ formatMcpOutput,
18
+ formatPrimeOutput,
19
+ formatPrimeOutputCompact,
20
+ formatPrimeOutputPlain,
21
+ formatPrimeOutputXml,
22
+ getSessionEndReminder,
23
+ } from "../utils/format.ts";
24
+ import type { McpDomain, PrimeFormat } from "../utils/format.ts";
25
+ import { filterByContext, getChangedFiles, isGitRepo } from "../utils/git.ts";
26
+ import { outputJsonError } from "../utils/json-output.ts";
27
+
28
+ interface PrimeOptions {
29
+ full?: boolean;
30
+ verbose?: boolean;
31
+ mcp?: boolean;
32
+ format?: PrimeFormat;
33
+ export?: string;
34
+ domain?: string[];
35
+ excludeDomain?: string[];
36
+ context?: boolean;
37
+ files?: string[];
38
+ budget?: string;
39
+ noLimit?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Produce a rough text representation of a record for token estimation.
44
+ * Uses a simple format similar to compact lines.
45
+ */
46
+ function estimateRecordText(
47
+ record: import("../schemas/record.js").ExpertiseRecord,
48
+ ): string {
49
+ switch (record.type) {
50
+ case "convention":
51
+ return `[convention] ${record.content}`;
52
+ case "pattern": {
53
+ const files =
54
+ record.files && record.files.length > 0
55
+ ? ` (${record.files.join(", ")})`
56
+ : "";
57
+ return `[pattern] ${record.name}: ${record.description}${files}`;
58
+ }
59
+ case "failure":
60
+ return `[failure] ${record.description} -> ${record.resolution}`;
61
+ case "decision":
62
+ return `[decision] ${record.title}: ${record.rationale}`;
63
+ case "reference": {
64
+ const refFiles =
65
+ record.files && record.files.length > 0
66
+ ? `: ${record.files.join(", ")}`
67
+ : `: ${record.description}`;
68
+ return `[reference] ${record.name}${refFiles}`;
69
+ }
70
+ case "guide":
71
+ return `[guide] ${record.name}: ${record.description}`;
72
+ }
73
+ }
74
+
75
+ export function registerPrimeCommand(program: Command): void {
76
+ program
77
+ .command("prime")
78
+ .description("Generate a priming prompt from expertise records")
79
+ .argument("[domains...]", "optional domain(s) to scope output to")
80
+ .option("--full", "include full record details (classification, evidence)")
81
+ .option(
82
+ "-v, --verbose",
83
+ "full output with section headers and recording instructions",
84
+ )
85
+ .option("--mcp", "output in MCP-compatible JSON format")
86
+ .option("--domain <domains...>", "domain(s) to include")
87
+ .option("--exclude-domain <domains...>", "domain(s) to exclude")
88
+ .addOption(
89
+ new Option("--format <format>", "output format")
90
+ .choices(["markdown", "xml", "plain"])
91
+ .default("markdown"),
92
+ )
93
+ .option(
94
+ "--context",
95
+ "filter records to only those relevant to changed files",
96
+ )
97
+ .option(
98
+ "--files <paths...>",
99
+ "filter records to only those relevant to specified files",
100
+ )
101
+ .option("--export <path>", "export output to a file")
102
+ .option(
103
+ "--budget <tokens>",
104
+ `token budget for output (default: ${DEFAULT_BUDGET})`,
105
+ )
106
+ .option("--no-limit", "disable token budget limit")
107
+ .action(async (domainsArg: string[], options: PrimeOptions) => {
108
+ const jsonMode = program.opts().json === true;
109
+ try {
110
+ const config = await readConfig();
111
+ const format = options.format ?? "markdown";
112
+
113
+ const requested = [...domainsArg, ...(options.domain ?? [])];
114
+ const unique = [...new Set(requested)];
115
+
116
+ for (const d of unique) {
117
+ if (!config.domains.includes(d)) {
118
+ if (jsonMode) {
119
+ outputJsonError(
120
+ "prime",
121
+ `Domain "${d}" not found in config. Available domains: ${config.domains.join(", ")}`,
122
+ );
123
+ } else {
124
+ console.error(
125
+ `Error: Domain "${d}" not found in config. Available domains: ${config.domains.join(", ")}`,
126
+ );
127
+ }
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ }
132
+
133
+ const excluded = options.excludeDomain ?? [];
134
+ for (const d of excluded) {
135
+ if (!config.domains.includes(d)) {
136
+ if (jsonMode) {
137
+ outputJsonError(
138
+ "prime",
139
+ `Excluded domain "${d}" not found in config. Available domains: ${config.domains.join(", ")}`,
140
+ );
141
+ } else {
142
+ console.error(
143
+ `Error: Excluded domain "${d}" not found in config. Available domains: ${config.domains.join(", ")}`,
144
+ );
145
+ }
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ }
150
+
151
+ let targetDomains = unique.length > 0 ? unique : config.domains;
152
+
153
+ targetDomains = targetDomains.filter((d) => !excluded.includes(d));
154
+
155
+ // Resolve changed files for --context or --files filtering
156
+ let filesToFilter: string[] | undefined;
157
+ if (options.context) {
158
+ const cwd = process.cwd();
159
+ if (!isGitRepo(cwd)) {
160
+ const msg = "Not in a git repository. --context requires git.";
161
+ if (jsonMode) {
162
+ outputJsonError("prime", msg);
163
+ } else {
164
+ console.error(`Error: ${msg}`);
165
+ }
166
+ process.exitCode = 1;
167
+ return;
168
+ }
169
+ filesToFilter = getChangedFiles(cwd, "HEAD~1");
170
+ if (filesToFilter.length === 0) {
171
+ if (jsonMode) {
172
+ outputJsonError(
173
+ "prime",
174
+ "No changed files found. Nothing to filter by.",
175
+ );
176
+ } else {
177
+ console.log("No changed files found. Nothing to filter by.");
178
+ }
179
+ return;
180
+ }
181
+ } else if (options.files && options.files.length > 0) {
182
+ filesToFilter = options.files;
183
+ }
184
+
185
+ // Determine budget settings
186
+ const isMachineOutput = options.mcp === true || jsonMode;
187
+ const budgetEnabled = !isMachineOutput && options.noLimit !== true;
188
+ const budget = options.budget
189
+ ? Number.parseInt(options.budget, 10)
190
+ : DEFAULT_BUDGET;
191
+
192
+ let output: string;
193
+
194
+ if (isMachineOutput) {
195
+ // --json and --mcp produce the same structured output — no budget
196
+ const domains: McpDomain[] = [];
197
+ for (const domain of targetDomains) {
198
+ const filePath = getExpertisePath(domain);
199
+ let records = await readExpertiseFile(filePath);
200
+ if (filesToFilter) {
201
+ records = filterByContext(records, filesToFilter);
202
+ }
203
+ if (!filesToFilter || records.length > 0) {
204
+ domains.push({ domain, entry_count: records.length, records });
205
+ }
206
+ }
207
+ output = formatMcpOutput(domains);
208
+ } else {
209
+ // Load all records per domain
210
+ const allDomainRecords: DomainRecords[] = [];
211
+ const modTimes = new Map<string, Date | null>();
212
+
213
+ for (const domain of targetDomains) {
214
+ const filePath = getExpertisePath(domain);
215
+ let records = await readExpertiseFile(filePath);
216
+ if (filesToFilter) {
217
+ records = filterByContext(records, filesToFilter);
218
+ if (records.length === 0) continue;
219
+ }
220
+ allDomainRecords.push({ domain, records });
221
+ const lastUpdated = await getFileModTime(filePath);
222
+ modTimes.set(domain, lastUpdated);
223
+ }
224
+
225
+ // Apply budget filtering
226
+ let domainRecordsToFormat: DomainRecords[];
227
+ let droppedCount = 0;
228
+ let droppedDomainCount = 0;
229
+
230
+ if (budgetEnabled) {
231
+ const result = applyBudget(allDomainRecords, budget, (record) =>
232
+ estimateRecordText(record),
233
+ );
234
+ domainRecordsToFormat = result.kept;
235
+ droppedCount = result.droppedCount;
236
+ droppedDomainCount = result.droppedDomainCount;
237
+ } else {
238
+ domainRecordsToFormat = allDomainRecords;
239
+ }
240
+
241
+ // Format domain sections
242
+ const domainSections: string[] = [];
243
+ for (const { domain, records } of domainRecordsToFormat) {
244
+ const lastUpdated = modTimes.get(domain) ?? null;
245
+
246
+ if (options.verbose || options.full || format !== "markdown") {
247
+ switch (format) {
248
+ case "xml":
249
+ domainSections.push(
250
+ formatDomainExpertiseXml(domain, records, lastUpdated),
251
+ );
252
+ break;
253
+ case "plain":
254
+ domainSections.push(
255
+ formatDomainExpertisePlain(domain, records, lastUpdated),
256
+ );
257
+ break;
258
+ default:
259
+ domainSections.push(
260
+ formatDomainExpertise(domain, records, lastUpdated, {
261
+ full: options.full,
262
+ }),
263
+ );
264
+ break;
265
+ }
266
+ } else {
267
+ domainSections.push(
268
+ formatDomainExpertiseCompact(domain, records, lastUpdated),
269
+ );
270
+ }
271
+ }
272
+
273
+ if (options.verbose || options.full || format !== "markdown") {
274
+ switch (format) {
275
+ case "xml":
276
+ output = formatPrimeOutputXml(domainSections);
277
+ break;
278
+ case "plain":
279
+ output = formatPrimeOutputPlain(domainSections);
280
+ break;
281
+ default:
282
+ output = formatPrimeOutput(domainSections);
283
+ break;
284
+ }
285
+ } else {
286
+ output = formatPrimeOutputCompact(domainSections);
287
+ }
288
+
289
+ // Append truncation summary before session reminder
290
+ if (droppedCount > 0) {
291
+ output += `\n\n${formatBudgetSummary(droppedCount, droppedDomainCount)}`;
292
+ }
293
+
294
+ output += `\n\n${getSessionEndReminder(format)}`;
295
+ }
296
+
297
+ if (options.export) {
298
+ await writeFile(options.export, `${output}\n`, "utf-8");
299
+ if (!jsonMode) {
300
+ console.log(chalk.green(`Exported to ${options.export}`));
301
+ }
302
+ } else {
303
+ console.log(output);
304
+ }
305
+ } catch (err) {
306
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
307
+ if (jsonMode) {
308
+ outputJsonError(
309
+ "prime",
310
+ "No .mulch/ directory found. Run `mulch init` first.",
311
+ );
312
+ } else {
313
+ console.error(
314
+ "Error: No .mulch/ directory found. Run `mulch init` first.",
315
+ );
316
+ }
317
+ } else {
318
+ if (jsonMode) {
319
+ outputJsonError("prime", (err as Error).message);
320
+ } else {
321
+ console.error(`Error: ${(err as Error).message}`);
322
+ }
323
+ }
324
+ process.exitCode = 1;
325
+ }
326
+ });
327
+ }