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,959 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import Ajv from "ajv";
3
+ import chalk from "chalk";
4
+ import { type Command, Option } from "commander";
5
+ import { recordSchema } from "../schemas/record-schema.ts";
6
+ import type {
7
+ Classification,
8
+ Evidence,
9
+ ExpertiseRecord,
10
+ Outcome,
11
+ RecordType,
12
+ } from "../schemas/record.ts";
13
+ import { getExpertisePath, readConfig } from "../utils/config.ts";
14
+ import {
15
+ appendRecord,
16
+ findDuplicate,
17
+ readExpertiseFile,
18
+ writeExpertiseFile,
19
+ } from "../utils/expertise.ts";
20
+ import { outputJson, outputJsonError } from "../utils/json-output.ts";
21
+ import { withFileLock } from "../utils/lock.ts";
22
+
23
+ /**
24
+ * Process records from stdin (JSON single object or array)
25
+ * Validates, dedups, and appends with file locking
26
+ */
27
+ export async function processStdinRecords(
28
+ domain: string,
29
+ jsonMode: boolean,
30
+ force: boolean,
31
+ dryRun: boolean,
32
+ stdinData?: string,
33
+ cwd?: string,
34
+ ): Promise<{
35
+ created: number;
36
+ updated: number;
37
+ skipped: number;
38
+ errors: string[];
39
+ }> {
40
+ const config = await readConfig(cwd);
41
+
42
+ if (!config.domains.includes(domain)) {
43
+ throw new Error(
44
+ `Domain "${domain}" not found in config. Available domains: ${config.domains.join(", ") || "(none)"}`,
45
+ );
46
+ }
47
+
48
+ // Read stdin (or use provided data for testing)
49
+ const inputData = stdinData ?? readFileSync(0, "utf-8");
50
+ let inputRecords: unknown[];
51
+
52
+ try {
53
+ const parsed = JSON.parse(inputData);
54
+ inputRecords = Array.isArray(parsed) ? parsed : [parsed];
55
+ } catch (err) {
56
+ throw new Error(
57
+ `Failed to parse JSON from stdin: ${err instanceof Error ? err.message : String(err)}`,
58
+ );
59
+ }
60
+
61
+ // Validate each record against schema
62
+ const ajv = new Ajv();
63
+ const validate = ajv.compile(recordSchema);
64
+
65
+ const errors: string[] = [];
66
+ const validRecords: ExpertiseRecord[] = [];
67
+
68
+ for (let i = 0; i < inputRecords.length; i++) {
69
+ const record = inputRecords[i];
70
+
71
+ // Ensure recorded_at and classification are set
72
+ if (typeof record === "object" && record !== null) {
73
+ if (!("recorded_at" in record)) {
74
+ (record as Record<string, unknown>).recorded_at =
75
+ new Date().toISOString();
76
+ }
77
+ if (!("classification" in record)) {
78
+ (record as Record<string, unknown>).classification = "tactical";
79
+ }
80
+ }
81
+
82
+ if (!validate(record)) {
83
+ const validationErrors = (validate.errors ?? [])
84
+ .map((err) => `${err.instancePath} ${err.message}`)
85
+ .join("; ");
86
+ errors.push(`Record ${i}: ${validationErrors}`);
87
+ continue;
88
+ }
89
+
90
+ validRecords.push(record as ExpertiseRecord);
91
+ }
92
+
93
+ if (validRecords.length === 0) {
94
+ return { created: 0, updated: 0, skipped: 0, errors };
95
+ }
96
+
97
+ // Process valid records with file locking (skip write in dry-run mode)
98
+ const filePath = getExpertisePath(domain, cwd);
99
+ let created = 0;
100
+ let updated = 0;
101
+ let skipped = 0;
102
+
103
+ if (dryRun) {
104
+ // Dry-run: check for duplicates without writing
105
+ const existing = await readExpertiseFile(filePath);
106
+ const currentRecords = [...existing];
107
+
108
+ for (const record of validRecords) {
109
+ const dup = findDuplicate(currentRecords, record);
110
+
111
+ if (dup && !force) {
112
+ const isNamed =
113
+ record.type === "pattern" ||
114
+ record.type === "decision" ||
115
+ record.type === "reference" ||
116
+ record.type === "guide";
117
+
118
+ if (isNamed) {
119
+ updated++;
120
+ } else {
121
+ skipped++;
122
+ }
123
+ } else {
124
+ created++;
125
+ }
126
+ }
127
+ } else {
128
+ // Normal mode: write with file locking
129
+ await withFileLock(filePath, async () => {
130
+ const existing = await readExpertiseFile(filePath);
131
+ const currentRecords = [...existing];
132
+
133
+ for (const record of validRecords) {
134
+ const dup = findDuplicate(currentRecords, record);
135
+
136
+ if (dup && !force) {
137
+ const isNamed =
138
+ record.type === "pattern" ||
139
+ record.type === "decision" ||
140
+ record.type === "reference" ||
141
+ record.type === "guide";
142
+
143
+ if (isNamed) {
144
+ // Upsert: replace in place
145
+ currentRecords[dup.index] = record;
146
+ updated++;
147
+ } else {
148
+ // Exact match: skip
149
+ skipped++;
150
+ }
151
+ } else {
152
+ // New record: append
153
+ currentRecords.push(record);
154
+ created++;
155
+ }
156
+ }
157
+
158
+ // Write all changes at once
159
+ if (created > 0 || updated > 0) {
160
+ await writeExpertiseFile(filePath, currentRecords);
161
+ }
162
+ });
163
+ }
164
+
165
+ return { created, updated, skipped, errors };
166
+ }
167
+
168
+ export function registerRecordCommand(program: Command): void {
169
+ program
170
+ .command("record")
171
+ .argument("<domain>", "expertise domain")
172
+ .argument("[content]", "record content")
173
+ .description("Record an expertise record")
174
+ .addOption(
175
+ new Option("--type <type>", "record type").choices([
176
+ "convention",
177
+ "pattern",
178
+ "failure",
179
+ "decision",
180
+ "reference",
181
+ "guide",
182
+ ]),
183
+ )
184
+ .addOption(
185
+ new Option("--classification <classification>", "classification level")
186
+ .choices(["foundational", "tactical", "observational"])
187
+ .default("tactical"),
188
+ )
189
+ .option("--name <name>", "name of the convention or pattern")
190
+ .option("--description <description>", "description of the record")
191
+ .option("--resolution <resolution>", "resolution for failure records")
192
+ .option("--title <title>", "title for decision records")
193
+ .option("--rationale <rationale>", "rationale for decision records")
194
+ .option("--files <files>", "related files (comma-separated)")
195
+ .option("--tags <tags>", "comma-separated tags")
196
+ .option("--evidence-commit <commit>", "evidence: commit hash")
197
+ .option("--evidence-issue <issue>", "evidence: issue reference")
198
+ .option("--evidence-file <file>", "evidence: file path")
199
+ .option("--evidence-bead <bead>", "evidence: bead ID")
200
+ .option("--relates-to <ids>", "comma-separated record IDs this relates to")
201
+ .option("--supersedes <ids>", "comma-separated record IDs this supersedes")
202
+ .addOption(
203
+ new Option("--outcome-status <status>", "outcome status").choices([
204
+ "success",
205
+ "failure",
206
+ "partial",
207
+ ]),
208
+ )
209
+ .option("--outcome-duration <ms>", "outcome duration in milliseconds")
210
+ .option("--outcome-test-results <text>", "outcome test results summary")
211
+ .option("--outcome-agent <agent>", "outcome agent name")
212
+ .option("--force", "force recording even if duplicate exists")
213
+ .option(
214
+ "--stdin",
215
+ "read JSON record(s) from stdin (single object or array)",
216
+ )
217
+ .option(
218
+ "--batch <file>",
219
+ "read JSON record(s) from file (single object or array)",
220
+ )
221
+ .option("--dry-run", "preview what would be recorded without writing")
222
+ .addHelpText(
223
+ "after",
224
+ `
225
+ Required fields per record type:
226
+ convention [content] or --description
227
+ pattern --name, --description (or [content])
228
+ failure --description, --resolution
229
+ decision --title, --rationale
230
+ reference --name, --description (or [content])
231
+ guide --name, --description (or [content])
232
+
233
+ Batch recording examples:
234
+ mulch record cli --batch records.json
235
+ mulch record cli --batch records.json --dry-run
236
+ echo '[{"type":"convention","content":"test"}]' > batch.json && mulch record cli --batch batch.json
237
+ `,
238
+ )
239
+ .action(
240
+ async (
241
+ domain: string,
242
+ content: string | undefined,
243
+ options: Record<string, unknown>,
244
+ ) => {
245
+ const jsonMode = program.opts().json === true;
246
+
247
+ // Handle --batch mode
248
+ if (options.batch) {
249
+ const batchFile = options.batch as string;
250
+ const dryRun = options.dryRun === true;
251
+
252
+ if (!existsSync(batchFile)) {
253
+ if (jsonMode) {
254
+ outputJsonError("record", `Batch file not found: ${batchFile}`);
255
+ } else {
256
+ console.error(
257
+ chalk.red(`Error: batch file not found: ${batchFile}`),
258
+ );
259
+ }
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+
264
+ try {
265
+ const fileContent = readFileSync(batchFile, "utf-8");
266
+ const result = await processStdinRecords(
267
+ domain,
268
+ jsonMode,
269
+ options.force === true,
270
+ dryRun,
271
+ fileContent,
272
+ );
273
+
274
+ if (result.errors.length > 0) {
275
+ if (jsonMode) {
276
+ outputJsonError(
277
+ "record",
278
+ `Validation errors: ${result.errors.join("; ")}`,
279
+ );
280
+ } else {
281
+ console.error(chalk.red("Validation errors:"));
282
+ for (const error of result.errors) {
283
+ console.error(chalk.red(` ${error}`));
284
+ }
285
+ }
286
+ }
287
+
288
+ if (jsonMode) {
289
+ outputJson({
290
+ success:
291
+ result.errors.length === 0 ||
292
+ result.created + result.updated > 0,
293
+ command: "record",
294
+ action: dryRun ? "dry-run" : "batch",
295
+ domain,
296
+ created: result.created,
297
+ updated: result.updated,
298
+ skipped: result.skipped,
299
+ errors: result.errors,
300
+ });
301
+ } else {
302
+ if (dryRun) {
303
+ const total = result.created + result.updated;
304
+ if (total > 0 || result.skipped > 0) {
305
+ console.log(
306
+ chalk.green(
307
+ `✓ Dry-run complete. Would process ${total} record(s) in ${domain}:`,
308
+ ),
309
+ );
310
+ if (result.created > 0) {
311
+ console.log(chalk.dim(` Create: ${result.created}`));
312
+ }
313
+ if (result.updated > 0) {
314
+ console.log(chalk.dim(` Update: ${result.updated}`));
315
+ }
316
+ if (result.skipped > 0) {
317
+ console.log(chalk.dim(` Skip: ${result.skipped}`));
318
+ }
319
+ console.log(
320
+ chalk.dim(" Run without --dry-run to apply changes."),
321
+ );
322
+ } else {
323
+ console.log(chalk.yellow("No records would be processed."));
324
+ }
325
+ } else {
326
+ if (result.created > 0) {
327
+ console.log(
328
+ chalk.green(
329
+ `✔ Created ${result.created} record(s) in ${domain}`,
330
+ ),
331
+ );
332
+ }
333
+ if (result.updated > 0) {
334
+ console.log(
335
+ chalk.green(
336
+ `✔ Updated ${result.updated} record(s) in ${domain}`,
337
+ ),
338
+ );
339
+ }
340
+ if (result.skipped > 0) {
341
+ console.log(
342
+ chalk.yellow(
343
+ `Skipped ${result.skipped} duplicate(s) in ${domain}`,
344
+ ),
345
+ );
346
+ }
347
+ }
348
+ }
349
+
350
+ if (
351
+ result.errors.length > 0 &&
352
+ result.created + result.updated === 0
353
+ ) {
354
+ process.exitCode = 1;
355
+ }
356
+ } catch (err) {
357
+ if (jsonMode) {
358
+ outputJsonError(
359
+ "record",
360
+ err instanceof Error ? err.message : String(err),
361
+ );
362
+ } else {
363
+ console.error(
364
+ chalk.red(
365
+ `Error: ${err instanceof Error ? err.message : String(err)}`,
366
+ ),
367
+ );
368
+ }
369
+ process.exitCode = 1;
370
+ }
371
+ return;
372
+ }
373
+
374
+ // Handle --stdin mode
375
+ if (options.stdin === true) {
376
+ const dryRun = options.dryRun === true;
377
+
378
+ try {
379
+ const result = await processStdinRecords(
380
+ domain,
381
+ jsonMode,
382
+ options.force === true,
383
+ dryRun,
384
+ );
385
+
386
+ if (result.errors.length > 0) {
387
+ if (jsonMode) {
388
+ outputJsonError(
389
+ "record",
390
+ `Validation errors: ${result.errors.join("; ")}`,
391
+ );
392
+ } else {
393
+ console.error(chalk.red("Validation errors:"));
394
+ for (const error of result.errors) {
395
+ console.error(chalk.red(` ${error}`));
396
+ }
397
+ }
398
+ }
399
+
400
+ if (jsonMode) {
401
+ outputJson({
402
+ success:
403
+ result.errors.length === 0 ||
404
+ result.created + result.updated > 0,
405
+ command: "record",
406
+ action: dryRun ? "dry-run" : "stdin",
407
+ domain,
408
+ created: result.created,
409
+ updated: result.updated,
410
+ skipped: result.skipped,
411
+ errors: result.errors,
412
+ });
413
+ } else {
414
+ if (dryRun) {
415
+ const total = result.created + result.updated;
416
+ if (total > 0 || result.skipped > 0) {
417
+ console.log(
418
+ chalk.green(
419
+ `✓ Dry-run complete. Would process ${total} record(s) in ${domain}:`,
420
+ ),
421
+ );
422
+ if (result.created > 0) {
423
+ console.log(chalk.dim(` Create: ${result.created}`));
424
+ }
425
+ if (result.updated > 0) {
426
+ console.log(chalk.dim(` Update: ${result.updated}`));
427
+ }
428
+ if (result.skipped > 0) {
429
+ console.log(chalk.dim(` Skip: ${result.skipped}`));
430
+ }
431
+ console.log(
432
+ chalk.dim(" Run without --dry-run to apply changes."),
433
+ );
434
+ } else {
435
+ console.log(chalk.yellow("No records would be processed."));
436
+ }
437
+ } else {
438
+ if (result.created > 0) {
439
+ console.log(
440
+ chalk.green(
441
+ `✔ Created ${result.created} record(s) in ${domain}`,
442
+ ),
443
+ );
444
+ }
445
+ if (result.updated > 0) {
446
+ console.log(
447
+ chalk.green(
448
+ `✔ Updated ${result.updated} record(s) in ${domain}`,
449
+ ),
450
+ );
451
+ }
452
+ if (result.skipped > 0) {
453
+ console.log(
454
+ chalk.yellow(
455
+ `Skipped ${result.skipped} duplicate(s) in ${domain}`,
456
+ ),
457
+ );
458
+ }
459
+ }
460
+ }
461
+
462
+ if (
463
+ result.errors.length > 0 &&
464
+ result.created + result.updated === 0
465
+ ) {
466
+ process.exitCode = 1;
467
+ }
468
+ } catch (err) {
469
+ if (jsonMode) {
470
+ outputJsonError(
471
+ "record",
472
+ err instanceof Error ? err.message : String(err),
473
+ );
474
+ } else {
475
+ console.error(
476
+ chalk.red(
477
+ `Error: ${err instanceof Error ? err.message : String(err)}`,
478
+ ),
479
+ );
480
+ }
481
+ process.exitCode = 1;
482
+ }
483
+ return;
484
+ }
485
+ const config = await readConfig();
486
+
487
+ if (!config.domains.includes(domain)) {
488
+ if (jsonMode) {
489
+ outputJsonError(
490
+ "record",
491
+ `Domain "${domain}" not found in config. Available domains: ${config.domains.join(", ") || "(none)"}`,
492
+ );
493
+ } else {
494
+ console.error(
495
+ chalk.red(`Error: domain "${domain}" not found in config.`),
496
+ );
497
+ console.error(
498
+ chalk.red(
499
+ `Available domains: ${config.domains.join(", ") || "(none)"}`,
500
+ ),
501
+ );
502
+ }
503
+ process.exitCode = 1;
504
+ return;
505
+ }
506
+
507
+ // Validate --type is provided for non-stdin mode
508
+ if (!options.type) {
509
+ if (jsonMode) {
510
+ outputJsonError(
511
+ "record",
512
+ "--type is required (convention, pattern, failure, decision, reference, guide)",
513
+ );
514
+ } else {
515
+ console.error(
516
+ chalk.red(
517
+ "Error: --type is required (convention, pattern, failure, decision, reference, guide)",
518
+ ),
519
+ );
520
+ }
521
+ process.exitCode = 1;
522
+ return;
523
+ }
524
+
525
+ const recordType = options.type as RecordType;
526
+ const classification =
527
+ (options.classification as Classification) ?? "tactical";
528
+ const recordedAt = new Date().toISOString();
529
+
530
+ // Build evidence if any evidence option is provided
531
+ let evidence: Evidence | undefined;
532
+ if (
533
+ options.evidenceCommit ||
534
+ options.evidenceIssue ||
535
+ options.evidenceFile ||
536
+ options.evidenceBead
537
+ ) {
538
+ evidence = {};
539
+ if (options.evidenceCommit)
540
+ evidence.commit = options.evidenceCommit as string;
541
+ if (options.evidenceIssue)
542
+ evidence.issue = options.evidenceIssue as string;
543
+ if (options.evidenceFile)
544
+ evidence.file = options.evidenceFile as string;
545
+ if (options.evidenceBead)
546
+ evidence.bead = options.evidenceBead as string;
547
+ }
548
+
549
+ const tags =
550
+ typeof options.tags === "string"
551
+ ? options.tags
552
+ .split(",")
553
+ .map((t) => (t as string).trim())
554
+ .filter(Boolean)
555
+ : undefined;
556
+
557
+ const relatesTo =
558
+ typeof options.relatesTo === "string"
559
+ ? options.relatesTo
560
+ .split(",")
561
+ .map((id: string) => id.trim())
562
+ .filter(Boolean)
563
+ : undefined;
564
+
565
+ const supersedes =
566
+ typeof options.supersedes === "string"
567
+ ? options.supersedes
568
+ .split(",")
569
+ .map((id: string) => id.trim())
570
+ .filter(Boolean)
571
+ : undefined;
572
+
573
+ let outcomes: Outcome[] | undefined;
574
+ if (options.outcomeStatus) {
575
+ const o: Outcome = {
576
+ status: options.outcomeStatus as "success" | "failure" | "partial",
577
+ };
578
+ if (options.outcomeDuration !== undefined) {
579
+ o.duration = Number.parseFloat(options.outcomeDuration as string);
580
+ }
581
+ if (options.outcomeTestResults) {
582
+ o.test_results = options.outcomeTestResults as string;
583
+ }
584
+ if (options.outcomeAgent) {
585
+ o.agent = options.outcomeAgent as string;
586
+ }
587
+ outcomes = [o];
588
+ }
589
+
590
+ let record: ExpertiseRecord;
591
+
592
+ switch (recordType) {
593
+ case "convention": {
594
+ const conventionContent =
595
+ content ?? (options.description as string | undefined);
596
+ if (!conventionContent) {
597
+ if (jsonMode) {
598
+ outputJsonError(
599
+ "record",
600
+ "Convention records require content (positional argument or --description).",
601
+ );
602
+ } else {
603
+ console.error(
604
+ chalk.red(
605
+ "Error: convention records require content (positional argument or --description).",
606
+ ),
607
+ );
608
+ }
609
+ process.exitCode = 1;
610
+ return;
611
+ }
612
+ record = {
613
+ type: "convention",
614
+ content: conventionContent,
615
+ classification,
616
+ recorded_at: recordedAt,
617
+ ...(evidence && { evidence }),
618
+ ...(tags && tags.length > 0 && { tags }),
619
+ ...(relatesTo &&
620
+ relatesTo.length > 0 && { relates_to: relatesTo }),
621
+ ...(supersedes && supersedes.length > 0 && { supersedes }),
622
+ ...(outcomes && { outcomes }),
623
+ };
624
+ break;
625
+ }
626
+
627
+ case "pattern": {
628
+ const patternName = options.name as string | undefined;
629
+ const patternDesc =
630
+ (options.description as string | undefined) ?? content;
631
+ if (!patternName || !patternDesc) {
632
+ if (jsonMode) {
633
+ outputJsonError(
634
+ "record",
635
+ "Pattern records require --name and --description (or positional content).",
636
+ );
637
+ } else {
638
+ console.error(
639
+ chalk.red(
640
+ "Error: pattern records require --name and --description (or positional content).",
641
+ ),
642
+ );
643
+ }
644
+ process.exitCode = 1;
645
+ return;
646
+ }
647
+ record = {
648
+ type: "pattern",
649
+ name: patternName,
650
+ description: patternDesc,
651
+ classification,
652
+ recorded_at: recordedAt,
653
+ ...(evidence && { evidence }),
654
+ ...(typeof options.files === "string" && {
655
+ files: options.files.split(","),
656
+ }),
657
+ ...(tags && tags.length > 0 && { tags }),
658
+ ...(relatesTo &&
659
+ relatesTo.length > 0 && { relates_to: relatesTo }),
660
+ ...(supersedes && supersedes.length > 0 && { supersedes }),
661
+ ...(outcomes && { outcomes }),
662
+ };
663
+ break;
664
+ }
665
+
666
+ case "failure": {
667
+ const failureDesc = options.description as string | undefined;
668
+ const failureResolution = options.resolution as string | undefined;
669
+ if (!failureDesc || !failureResolution) {
670
+ if (jsonMode) {
671
+ outputJsonError(
672
+ "record",
673
+ "Failure records require --description and --resolution.",
674
+ );
675
+ } else {
676
+ console.error(
677
+ chalk.red(
678
+ "Error: failure records require --description and --resolution.",
679
+ ),
680
+ );
681
+ }
682
+ process.exitCode = 1;
683
+ return;
684
+ }
685
+ record = {
686
+ type: "failure",
687
+ description: failureDesc,
688
+ resolution: failureResolution,
689
+ classification,
690
+ recorded_at: recordedAt,
691
+ ...(evidence && { evidence }),
692
+ ...(tags && tags.length > 0 && { tags }),
693
+ ...(relatesTo &&
694
+ relatesTo.length > 0 && { relates_to: relatesTo }),
695
+ ...(supersedes && supersedes.length > 0 && { supersedes }),
696
+ ...(outcomes && { outcomes }),
697
+ };
698
+ break;
699
+ }
700
+
701
+ case "decision": {
702
+ const decisionTitle = options.title as string | undefined;
703
+ const decisionRationale = options.rationale as string | undefined;
704
+ if (!decisionTitle || !decisionRationale) {
705
+ if (jsonMode) {
706
+ outputJsonError(
707
+ "record",
708
+ "Decision records require --title and --rationale.",
709
+ );
710
+ } else {
711
+ console.error(
712
+ chalk.red(
713
+ "Error: decision records require --title and --rationale.",
714
+ ),
715
+ );
716
+ }
717
+ process.exitCode = 1;
718
+ return;
719
+ }
720
+ record = {
721
+ type: "decision",
722
+ title: decisionTitle,
723
+ rationale: decisionRationale,
724
+ classification,
725
+ recorded_at: recordedAt,
726
+ ...(evidence && { evidence }),
727
+ ...(tags && tags.length > 0 && { tags }),
728
+ ...(relatesTo &&
729
+ relatesTo.length > 0 && { relates_to: relatesTo }),
730
+ ...(supersedes && supersedes.length > 0 && { supersedes }),
731
+ ...(outcomes && { outcomes }),
732
+ };
733
+ break;
734
+ }
735
+
736
+ case "reference": {
737
+ const refName = options.name as string | undefined;
738
+ const refDesc =
739
+ (options.description as string | undefined) ?? content;
740
+ if (!refName || !refDesc) {
741
+ if (jsonMode) {
742
+ outputJsonError(
743
+ "record",
744
+ "Reference records require --name and --description (or positional content).",
745
+ );
746
+ } else {
747
+ console.error(
748
+ chalk.red(
749
+ "Error: reference records require --name and --description (or positional content).",
750
+ ),
751
+ );
752
+ }
753
+ process.exitCode = 1;
754
+ return;
755
+ }
756
+ record = {
757
+ type: "reference",
758
+ name: refName,
759
+ description: refDesc,
760
+ classification,
761
+ recorded_at: recordedAt,
762
+ ...(evidence && { evidence }),
763
+ ...(typeof options.files === "string" && {
764
+ files: options.files.split(","),
765
+ }),
766
+ ...(tags && tags.length > 0 && { tags }),
767
+ ...(relatesTo &&
768
+ relatesTo.length > 0 && { relates_to: relatesTo }),
769
+ ...(supersedes && supersedes.length > 0 && { supersedes }),
770
+ ...(outcomes && { outcomes }),
771
+ };
772
+ break;
773
+ }
774
+
775
+ case "guide": {
776
+ const guideName = options.name as string | undefined;
777
+ const guideDesc =
778
+ (options.description as string | undefined) ?? content;
779
+ if (!guideName || !guideDesc) {
780
+ if (jsonMode) {
781
+ outputJsonError(
782
+ "record",
783
+ "Guide records require --name and --description (or positional content).",
784
+ );
785
+ } else {
786
+ console.error(
787
+ chalk.red(
788
+ "Error: guide records require --name and --description (or positional content).",
789
+ ),
790
+ );
791
+ }
792
+ process.exitCode = 1;
793
+ return;
794
+ }
795
+ record = {
796
+ type: "guide",
797
+ name: guideName,
798
+ description: guideDesc,
799
+ classification,
800
+ recorded_at: recordedAt,
801
+ ...(evidence && { evidence }),
802
+ ...(tags && tags.length > 0 && { tags }),
803
+ ...(relatesTo &&
804
+ relatesTo.length > 0 && { relates_to: relatesTo }),
805
+ ...(supersedes && supersedes.length > 0 && { supersedes }),
806
+ ...(outcomes && { outcomes }),
807
+ };
808
+ break;
809
+ }
810
+ }
811
+
812
+ // Validate against JSON schema
813
+ const ajv = new Ajv();
814
+ const validate = ajv.compile(recordSchema);
815
+ if (!validate(record)) {
816
+ const errors = (validate.errors ?? []).map(
817
+ (err) => `${err.instancePath} ${err.message}`,
818
+ );
819
+ if (jsonMode) {
820
+ outputJsonError(
821
+ "record",
822
+ `Schema validation failed: ${errors.join("; ")}`,
823
+ );
824
+ } else {
825
+ console.error(chalk.red("Error: record failed schema validation:"));
826
+ for (const err of validate.errors ?? []) {
827
+ console.error(chalk.red(` ${err.instancePath} ${err.message}`));
828
+ }
829
+ }
830
+ process.exitCode = 1;
831
+ return;
832
+ }
833
+
834
+ const filePath = getExpertisePath(domain);
835
+ const dryRun = options.dryRun === true;
836
+
837
+ if (dryRun) {
838
+ // Dry-run: check for duplicates without writing
839
+ const existing = await readExpertiseFile(filePath);
840
+ const dup = findDuplicate(existing, record);
841
+
842
+ let action = "created";
843
+ if (dup && !options.force) {
844
+ const isNamed =
845
+ record.type === "pattern" ||
846
+ record.type === "decision" ||
847
+ record.type === "reference" ||
848
+ record.type === "guide";
849
+
850
+ action = isNamed ? "updated" : "skipped";
851
+ }
852
+
853
+ if (jsonMode) {
854
+ outputJson({
855
+ success: true,
856
+ command: "record",
857
+ action: "dry-run",
858
+ wouldDo: action,
859
+ domain,
860
+ type: recordType,
861
+ record,
862
+ });
863
+ } else {
864
+ if (action === "created") {
865
+ console.log(
866
+ chalk.green(
867
+ `✓ Dry-run: Would create ${recordType} in ${domain}`,
868
+ ),
869
+ );
870
+ } else if (action === "updated") {
871
+ console.log(
872
+ chalk.green(
873
+ `✓ Dry-run: Would update existing ${recordType} in ${domain}`,
874
+ ),
875
+ );
876
+ } else {
877
+ console.log(
878
+ chalk.yellow(
879
+ `Dry-run: Duplicate ${recordType} already exists in ${domain}. Would skip.`,
880
+ ),
881
+ );
882
+ }
883
+ console.log(chalk.dim(" Run without --dry-run to apply changes."));
884
+ }
885
+ } else {
886
+ // Normal mode: write with file locking
887
+ await withFileLock(filePath, async () => {
888
+ const existing = await readExpertiseFile(filePath);
889
+ const dup = findDuplicate(existing, record);
890
+
891
+ if (dup && !options.force) {
892
+ const isNamed =
893
+ record.type === "pattern" ||
894
+ record.type === "decision" ||
895
+ record.type === "reference" ||
896
+ record.type === "guide";
897
+
898
+ if (isNamed) {
899
+ // Upsert: replace in place
900
+ existing[dup.index] = record;
901
+ await writeExpertiseFile(filePath, existing);
902
+ if (jsonMode) {
903
+ outputJson({
904
+ success: true,
905
+ command: "record",
906
+ action: "updated",
907
+ domain,
908
+ type: recordType,
909
+ index: dup.index + 1,
910
+ record,
911
+ });
912
+ } else {
913
+ console.log(
914
+ chalk.green(
915
+ `\u2714 Updated existing ${recordType} in ${domain} (record #${dup.index + 1})`,
916
+ ),
917
+ );
918
+ }
919
+ } else {
920
+ // Exact match: skip
921
+ if (jsonMode) {
922
+ outputJson({
923
+ success: true,
924
+ command: "record",
925
+ action: "skipped",
926
+ domain,
927
+ type: recordType,
928
+ index: dup.index + 1,
929
+ });
930
+ } else {
931
+ console.log(
932
+ chalk.yellow(
933
+ `Duplicate ${recordType} already exists in ${domain} (record #${dup.index + 1}). Use --force to add anyway.`,
934
+ ),
935
+ );
936
+ }
937
+ }
938
+ } else {
939
+ await appendRecord(filePath, record);
940
+ if (jsonMode) {
941
+ outputJson({
942
+ success: true,
943
+ command: "record",
944
+ action: "created",
945
+ domain,
946
+ type: recordType,
947
+ record,
948
+ });
949
+ } else {
950
+ console.log(
951
+ chalk.green(`\u2714 Recorded ${recordType} in ${domain}`),
952
+ );
953
+ }
954
+ }
955
+ });
956
+ }
957
+ },
958
+ );
959
+ }