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.
- package/README.md +12 -1
- package/package.json +11 -16
- package/src/api.ts +310 -0
- package/src/cli.ts +54 -0
- package/src/commands/add.ts +61 -0
- package/src/commands/compact.ts +924 -0
- package/src/commands/delete.ts +103 -0
- package/src/commands/diff.ts +209 -0
- package/src/commands/doctor.ts +586 -0
- package/src/commands/edit.ts +253 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/learn.ts +170 -0
- package/src/commands/onboard.ts +362 -0
- package/src/commands/prime.ts +327 -0
- package/src/commands/prune.ts +128 -0
- package/src/commands/query.ts +177 -0
- package/src/commands/ready.ts +194 -0
- package/src/commands/record.ts +959 -0
- package/src/commands/search.ts +234 -0
- package/src/commands/setup.ts +823 -0
- package/src/commands/status.ts +83 -0
- package/src/commands/sync.ts +224 -0
- package/src/commands/update.ts +112 -0
- package/src/commands/validate.ts +107 -0
- package/src/index.ts +50 -0
- package/src/schemas/config.ts +31 -0
- package/src/schemas/index.ts +18 -0
- package/src/schemas/record-schema.ts +177 -0
- package/src/schemas/record.ts +83 -0
- package/src/utils/bm25.ts +243 -0
- package/src/utils/budget.ts +157 -0
- package/src/utils/config.ts +117 -0
- package/src/utils/expertise.ts +379 -0
- package/src/utils/format.ts +767 -0
- package/src/utils/git.ts +89 -0
- package/src/utils/index.ts +54 -0
- package/src/utils/json-output.ts +13 -0
- package/src/utils/lock.ts +82 -0
- package/src/utils/markers.ts +51 -0
- package/{dist/utils/scoring.d.ts → src/utils/scoring.ts} +53 -9
- package/src/utils/version.ts +46 -0
- package/dist/api.d.ts +0 -65
- package/dist/api.d.ts.map +0 -1
- package/dist/api.js +0 -196
- package/dist/api.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -50
- package/dist/cli.js.map +0 -1
- package/dist/commands/add.d.ts +0 -3
- package/dist/commands/add.d.ts.map +0 -1
- package/dist/commands/add.js +0 -47
- package/dist/commands/add.js.map +0 -1
- package/dist/commands/compact.d.ts +0 -5
- package/dist/commands/compact.d.ts.map +0 -1
- package/dist/commands/compact.js +0 -709
- package/dist/commands/compact.js.map +0 -1
- package/dist/commands/delete.d.ts +0 -3
- package/dist/commands/delete.d.ts.map +0 -1
- package/dist/commands/delete.js +0 -82
- package/dist/commands/delete.js.map +0 -1
- package/dist/commands/diff.d.ts +0 -11
- package/dist/commands/diff.d.ts.map +0 -1
- package/dist/commands/diff.js +0 -170
- package/dist/commands/diff.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -3
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -391
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/edit.d.ts +0 -3
- package/dist/commands/edit.d.ts.map +0 -1
- package/dist/commands/edit.js +0 -198
- package/dist/commands/edit.js.map +0 -1
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.d.ts.map +0 -1
- package/dist/commands/init.js +0 -30
- package/dist/commands/init.js.map +0 -1
- package/dist/commands/learn.d.ts +0 -12
- package/dist/commands/learn.d.ts.map +0 -1
- package/dist/commands/learn.js +0 -130
- package/dist/commands/learn.js.map +0 -1
- package/dist/commands/onboard.d.ts +0 -10
- package/dist/commands/onboard.d.ts.map +0 -1
- package/dist/commands/onboard.js +0 -286
- package/dist/commands/onboard.js.map +0 -1
- package/dist/commands/prime.d.ts +0 -3
- package/dist/commands/prime.d.ts.map +0 -1
- package/dist/commands/prime.js +0 -242
- package/dist/commands/prime.js.map +0 -1
- package/dist/commands/prune.d.ts +0 -8
- package/dist/commands/prune.d.ts.map +0 -1
- package/dist/commands/prune.js +0 -90
- package/dist/commands/prune.js.map +0 -1
- package/dist/commands/query.d.ts +0 -3
- package/dist/commands/query.d.ts.map +0 -1
- package/dist/commands/query.js +0 -133
- package/dist/commands/query.js.map +0 -1
- package/dist/commands/ready.d.ts +0 -3
- package/dist/commands/ready.d.ts.map +0 -1
- package/dist/commands/ready.js +0 -160
- package/dist/commands/ready.js.map +0 -1
- package/dist/commands/record.d.ts +0 -13
- package/dist/commands/record.d.ts.map +0 -1
- package/dist/commands/record.js +0 -689
- package/dist/commands/record.js.map +0 -1
- package/dist/commands/search.d.ts +0 -3
- package/dist/commands/search.d.ts.map +0 -1
- package/dist/commands/search.js +0 -163
- package/dist/commands/search.js.map +0 -1
- package/dist/commands/setup.d.ts +0 -29
- package/dist/commands/setup.d.ts.map +0 -1
- package/dist/commands/setup.js +0 -548
- package/dist/commands/setup.js.map +0 -1
- package/dist/commands/status.d.ts +0 -3
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -61
- package/dist/commands/status.js.map +0 -1
- package/dist/commands/sync.d.ts +0 -3
- package/dist/commands/sync.d.ts.map +0 -1
- package/dist/commands/sync.js +0 -176
- package/dist/commands/sync.js.map +0 -1
- package/dist/commands/update.d.ts +0 -3
- package/dist/commands/update.d.ts.map +0 -1
- package/dist/commands/update.js +0 -72
- package/dist/commands/update.js.map +0 -1
- package/dist/commands/validate.d.ts +0 -3
- package/dist/commands/validate.d.ts.map +0 -1
- package/dist/commands/validate.js +0 -86
- package/dist/commands/validate.js.map +0 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -10
- package/dist/index.js.map +0 -1
- package/dist/schemas/config.d.ts +0 -17
- package/dist/schemas/config.d.ts.map +0 -1
- package/dist/schemas/config.js +0 -16
- package/dist/schemas/config.js.map +0 -1
- package/dist/schemas/index.d.ts +0 -5
- package/dist/schemas/index.d.ts.map +0 -1
- package/dist/schemas/index.js +0 -3
- package/dist/schemas/index.js.map +0 -1
- package/dist/schemas/record-schema.d.ts +0 -403
- package/dist/schemas/record-schema.d.ts.map +0 -1
- package/dist/schemas/record-schema.js +0 -150
- package/dist/schemas/record-schema.js.map +0 -1
- package/dist/schemas/record.d.ts +0 -62
- package/dist/schemas/record.d.ts.map +0 -1
- package/dist/schemas/record.js +0 -2
- package/dist/schemas/record.js.map +0 -1
- package/dist/utils/bm25.d.ts +0 -39
- package/dist/utils/bm25.d.ts.map +0 -1
- package/dist/utils/bm25.js +0 -171
- package/dist/utils/bm25.js.map +0 -1
- package/dist/utils/budget.d.ts +0 -35
- package/dist/utils/budget.d.ts.map +0 -1
- package/dist/utils/budget.js +0 -114
- package/dist/utils/budget.js.map +0 -1
- package/dist/utils/config.d.ts +0 -12
- package/dist/utils/config.d.ts.map +0 -1
- package/dist/utils/config.js +0 -89
- package/dist/utils/config.js.map +0 -1
- package/dist/utils/expertise.d.ts +0 -57
- package/dist/utils/expertise.d.ts.map +0 -1
- package/dist/utils/expertise.js +0 -276
- package/dist/utils/expertise.js.map +0 -1
- package/dist/utils/format.d.ts +0 -31
- package/dist/utils/format.d.ts.map +0 -1
- package/dist/utils/format.js +0 -562
- package/dist/utils/format.js.map +0 -1
- package/dist/utils/git.d.ts +0 -6
- package/dist/utils/git.d.ts.map +0 -1
- package/dist/utils/git.js +0 -81
- package/dist/utils/git.js.map +0 -1
- package/dist/utils/index.d.ts +0 -8
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -8
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/json-output.d.ts +0 -8
- package/dist/utils/json-output.d.ts.map +0 -1
- package/dist/utils/json-output.js +0 -7
- package/dist/utils/json-output.js.map +0 -1
- package/dist/utils/lock.d.ts +0 -6
- package/dist/utils/lock.d.ts.map +0 -1
- package/dist/utils/lock.js +0 -70
- package/dist/utils/lock.js.map +0 -1
- package/dist/utils/markers.d.ts +0 -22
- package/dist/utils/markers.d.ts.map +0 -1
- package/dist/utils/markers.js +0 -42
- package/dist/utils/markers.js.map +0 -1
- package/dist/utils/scoring.d.ts.map +0 -1
- package/dist/utils/scoring.js +0 -80
- package/dist/utils/scoring.js.map +0 -1
- package/dist/utils/version.d.ts +0 -15
- package/dist/utils/version.d.ts.map +0 -1
- package/dist/utils/version.js +0 -48
- 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
|
+
}
|