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