@mcptoolshop/loadout-os 0.0.0 → 1.0.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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/dist/loadout-os.js +3759 -0
- package/package.json +34 -4
|
@@ -0,0 +1,3759 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync12 } from "node:fs";
|
|
5
|
+
import { dirname as dirname8, join as join9, resolve as resolve11 } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { homedir as homedir5 } from "node:os";
|
|
8
|
+
|
|
9
|
+
// src/console.ts
|
|
10
|
+
var BOLD = "\x1B[1m";
|
|
11
|
+
var GREEN = "\x1B[32m";
|
|
12
|
+
var RED = "\x1B[31m";
|
|
13
|
+
var CYAN = "\x1B[36m";
|
|
14
|
+
var YELLOW = "\x1B[33m";
|
|
15
|
+
var DIM = "\x1B[2m";
|
|
16
|
+
var RESET = "\x1B[0m";
|
|
17
|
+
function log(msg = "") {
|
|
18
|
+
console.log(msg);
|
|
19
|
+
}
|
|
20
|
+
function ok(msg) {
|
|
21
|
+
log(` ${GREEN}\u2713${RESET} ${msg}`);
|
|
22
|
+
}
|
|
23
|
+
function warn(msg) {
|
|
24
|
+
log(` ${YELLOW}!${RESET} ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
function info(msg) {
|
|
27
|
+
log(` ${CYAN}i${RESET} ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
var CliError = class extends Error {
|
|
30
|
+
code;
|
|
31
|
+
hint;
|
|
32
|
+
exitCode;
|
|
33
|
+
constructor(code, message, hint, exitCode = 1) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "CliError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.hint = hint;
|
|
38
|
+
this.exitCode = exitCode;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function fail(code, message, hint, exitCode = 1) {
|
|
42
|
+
throw new CliError(code, message, hint, exitCode);
|
|
43
|
+
}
|
|
44
|
+
function hasFlag(args, flag) {
|
|
45
|
+
return args.includes(`--${flag}`);
|
|
46
|
+
}
|
|
47
|
+
function flagValue(args, flag) {
|
|
48
|
+
const eqPrefix = `--${flag}=`;
|
|
49
|
+
for (const a of args) {
|
|
50
|
+
if (a.startsWith(eqPrefix)) return a.slice(eqPrefix.length);
|
|
51
|
+
}
|
|
52
|
+
const idx = args.indexOf(`--${flag}`);
|
|
53
|
+
if (idx === -1) return void 0;
|
|
54
|
+
const next = args[idx + 1];
|
|
55
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
56
|
+
fail(
|
|
57
|
+
"MISSING_FLAG_VALUE",
|
|
58
|
+
`--${flag} was given without a value${next ? ` (the next token "${next}" is a flag, not a value)` : ""}.`,
|
|
59
|
+
`Provide a value, e.g. '--${flag} <path>' or '--${flag}=<path>'.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
function positionalArgs(args) {
|
|
65
|
+
return args.filter((a) => !a.startsWith("--"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/commands.ts
|
|
69
|
+
import {
|
|
70
|
+
readFileSync as readFileSync9,
|
|
71
|
+
writeFileSync as writeFileSync2,
|
|
72
|
+
mkdirSync as mkdirSync2,
|
|
73
|
+
existsSync as existsSync8,
|
|
74
|
+
statSync as statSync2,
|
|
75
|
+
copyFileSync,
|
|
76
|
+
rmSync,
|
|
77
|
+
mkdtempSync
|
|
78
|
+
} from "node:fs";
|
|
79
|
+
import { resolve as resolve7, dirname as dirname5, join as join5, relative as relative2 } from "node:path";
|
|
80
|
+
import { tmpdir } from "node:os";
|
|
81
|
+
import { createInterface } from "node:readline";
|
|
82
|
+
|
|
83
|
+
// ../kernel/dist/types.js
|
|
84
|
+
var DEFAULT_TRIGGERS = {
|
|
85
|
+
task: true,
|
|
86
|
+
plan: true,
|
|
87
|
+
edit: false
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ../kernel/dist/frontmatter.js
|
|
91
|
+
var VALID_PRIORITIES = /* @__PURE__ */ new Set(["core", "domain", "manual"]);
|
|
92
|
+
function parseFrontmatter(content) {
|
|
93
|
+
const lines = content.split("\n");
|
|
94
|
+
if (lines[0]?.trim() !== "---") {
|
|
95
|
+
return { frontmatter: null, body: content };
|
|
96
|
+
}
|
|
97
|
+
let closeIndex = -1;
|
|
98
|
+
for (let i = 1; i < lines.length; i++) {
|
|
99
|
+
if (lines[i].trim() === "---") {
|
|
100
|
+
closeIndex = i;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (closeIndex === -1) {
|
|
105
|
+
return { frontmatter: null, body: content };
|
|
106
|
+
}
|
|
107
|
+
const fmLines = lines.slice(1, closeIndex);
|
|
108
|
+
const body = lines.slice(closeIndex + 1).join("\n");
|
|
109
|
+
const data = {};
|
|
110
|
+
let currentKey = "";
|
|
111
|
+
let currentArray = null;
|
|
112
|
+
let currentObject = null;
|
|
113
|
+
for (let i = 0; i < fmLines.length; i++) {
|
|
114
|
+
const line = fmLines[i];
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (trimmed === "")
|
|
117
|
+
continue;
|
|
118
|
+
if (currentArray !== null && trimmed.startsWith("- ")) {
|
|
119
|
+
currentArray.push(trimmed.slice(2).trim());
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (currentObject !== null && line.startsWith(" ")) {
|
|
123
|
+
const colonIdx2 = trimmed.indexOf(":");
|
|
124
|
+
if (colonIdx2 !== -1) {
|
|
125
|
+
const key2 = trimmed.slice(0, colonIdx2).trim();
|
|
126
|
+
const val = trimmed.slice(colonIdx2 + 1).trim();
|
|
127
|
+
currentObject[key2] = val === "true";
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (currentArray !== null) {
|
|
132
|
+
data[currentKey] = currentArray;
|
|
133
|
+
currentArray = null;
|
|
134
|
+
}
|
|
135
|
+
if (currentObject !== null) {
|
|
136
|
+
data[currentKey] = currentObject;
|
|
137
|
+
currentObject = null;
|
|
138
|
+
}
|
|
139
|
+
const colonIdx = trimmed.indexOf(":");
|
|
140
|
+
if (colonIdx === -1)
|
|
141
|
+
continue;
|
|
142
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
143
|
+
const rawVal = trimmed.slice(colonIdx + 1).trim();
|
|
144
|
+
currentKey = key;
|
|
145
|
+
if (rawVal === "") {
|
|
146
|
+
if (i + 1 < fmLines.length) {
|
|
147
|
+
const nextTrimmed = fmLines[i + 1].trim();
|
|
148
|
+
if (nextTrimmed.startsWith("- ")) {
|
|
149
|
+
currentArray = [];
|
|
150
|
+
} else if (fmLines[i + 1].startsWith(" ")) {
|
|
151
|
+
currentObject = {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (rawVal.startsWith("[") && rawVal.endsWith("]")) {
|
|
157
|
+
const inner = rawVal.slice(1, -1);
|
|
158
|
+
data[key] = inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (rawVal === "true") {
|
|
162
|
+
data[key] = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (rawVal === "false") {
|
|
166
|
+
data[key] = false;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
data[key] = rawVal.startsWith('"') && rawVal.endsWith('"') ? rawVal.slice(1, -1) : rawVal;
|
|
170
|
+
}
|
|
171
|
+
if (currentArray !== null)
|
|
172
|
+
data[currentKey] = currentArray;
|
|
173
|
+
if (currentObject !== null)
|
|
174
|
+
data[currentKey] = currentObject;
|
|
175
|
+
if (typeof data.id !== "string" || !data.id) {
|
|
176
|
+
return { frontmatter: null, body: content };
|
|
177
|
+
}
|
|
178
|
+
const fm = {
|
|
179
|
+
id: data.id,
|
|
180
|
+
keywords: Array.isArray(data.keywords) ? data.keywords : [],
|
|
181
|
+
patterns: Array.isArray(data.patterns) ? data.patterns : [],
|
|
182
|
+
priority: VALID_PRIORITIES.has(data.priority) ? data.priority : "domain",
|
|
183
|
+
triggers: parseTriggers(data.triggers)
|
|
184
|
+
};
|
|
185
|
+
return { frontmatter: fm, body };
|
|
186
|
+
}
|
|
187
|
+
function parseTriggers(raw) {
|
|
188
|
+
if (!raw || typeof raw !== "object")
|
|
189
|
+
return { ...DEFAULT_TRIGGERS };
|
|
190
|
+
const obj = raw;
|
|
191
|
+
return {
|
|
192
|
+
task: typeof obj.task === "boolean" ? obj.task : DEFAULT_TRIGGERS.task,
|
|
193
|
+
plan: typeof obj.plan === "boolean" ? obj.plan : DEFAULT_TRIGGERS.plan,
|
|
194
|
+
edit: typeof obj.edit === "boolean" ? obj.edit : DEFAULT_TRIGGERS.edit
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function serializeFrontmatter(fm) {
|
|
198
|
+
const lines = ["---"];
|
|
199
|
+
lines.push(`id: ${fm.id}`);
|
|
200
|
+
lines.push(`keywords: [${fm.keywords.join(", ")}]`);
|
|
201
|
+
if (fm.patterns.length > 0) {
|
|
202
|
+
lines.push(`patterns: [${fm.patterns.join(", ")}]`);
|
|
203
|
+
}
|
|
204
|
+
lines.push(`priority: ${fm.priority}`);
|
|
205
|
+
lines.push("triggers:");
|
|
206
|
+
lines.push(` task: ${fm.triggers.task}`);
|
|
207
|
+
lines.push(` plan: ${fm.triggers.plan}`);
|
|
208
|
+
lines.push(` edit: ${fm.triggers.edit}`);
|
|
209
|
+
lines.push("---");
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ../kernel/dist/tokens.js
|
|
214
|
+
function estimateTokens(text) {
|
|
215
|
+
return Math.ceil(text.length / 4);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ../kernel/dist/validate.js
|
|
219
|
+
var KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
220
|
+
var VALID_PRIORITIES2 = /* @__PURE__ */ new Set(["core", "domain", "manual"]);
|
|
221
|
+
function validateIndex(index) {
|
|
222
|
+
const issues = [];
|
|
223
|
+
if (!index.version) {
|
|
224
|
+
issues.push({
|
|
225
|
+
severity: "error",
|
|
226
|
+
code: "MISSING_VERSION",
|
|
227
|
+
message: "Index is missing a version field"
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (!index.generated) {
|
|
231
|
+
issues.push({
|
|
232
|
+
severity: "warning",
|
|
233
|
+
code: "MISSING_GENERATED",
|
|
234
|
+
message: "Index is missing a generated timestamp"
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (!Array.isArray(index.entries)) {
|
|
238
|
+
issues.push({
|
|
239
|
+
severity: "error",
|
|
240
|
+
code: "INVALID_ENTRIES",
|
|
241
|
+
message: "Index entries must be an array"
|
|
242
|
+
});
|
|
243
|
+
return issues;
|
|
244
|
+
}
|
|
245
|
+
const ids = /* @__PURE__ */ new Set();
|
|
246
|
+
for (const entry of index.entries) {
|
|
247
|
+
if (!entry.id) {
|
|
248
|
+
issues.push({
|
|
249
|
+
severity: "error",
|
|
250
|
+
code: "MISSING_ID",
|
|
251
|
+
message: "Entry is missing an id field",
|
|
252
|
+
hint: "Every entry needs a unique kebab-case id"
|
|
253
|
+
});
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!KEBAB_RE.test(entry.id)) {
|
|
257
|
+
issues.push({
|
|
258
|
+
severity: "warning",
|
|
259
|
+
code: "BAD_ID_FORMAT",
|
|
260
|
+
message: `ID "${entry.id}" is not kebab-case`,
|
|
261
|
+
entryId: entry.id
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (ids.has(entry.id)) {
|
|
265
|
+
issues.push({
|
|
266
|
+
severity: "error",
|
|
267
|
+
code: "DUPLICATE_ID",
|
|
268
|
+
message: `Duplicate entry ID: "${entry.id}"`,
|
|
269
|
+
entryId: entry.id
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
ids.add(entry.id);
|
|
273
|
+
if (!entry.path) {
|
|
274
|
+
issues.push({
|
|
275
|
+
severity: "error",
|
|
276
|
+
code: "MISSING_PATH",
|
|
277
|
+
message: `Entry "${entry.id}" has no path`,
|
|
278
|
+
hint: "Set path to the relative file location (e.g. .claude/rules/my-rule.md)",
|
|
279
|
+
entryId: entry.id
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (!VALID_PRIORITIES2.has(entry.priority)) {
|
|
283
|
+
issues.push({
|
|
284
|
+
severity: "error",
|
|
285
|
+
code: "INVALID_PRIORITY",
|
|
286
|
+
message: `Entry "${entry.id}" has invalid priority "${entry.priority}"`,
|
|
287
|
+
entryId: entry.id
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (!entry.summary || entry.summary.length === 0) {
|
|
291
|
+
issues.push({
|
|
292
|
+
severity: "error",
|
|
293
|
+
code: "MISSING_SUMMARY",
|
|
294
|
+
message: `Entry "${entry.id}" has no summary`,
|
|
295
|
+
entryId: entry.id
|
|
296
|
+
});
|
|
297
|
+
} else if (entry.summary.length > 120) {
|
|
298
|
+
issues.push({
|
|
299
|
+
severity: "warning",
|
|
300
|
+
code: "LONG_SUMMARY",
|
|
301
|
+
message: `Entry "${entry.id}" summary exceeds 120 chars (${entry.summary.length})`,
|
|
302
|
+
entryId: entry.id
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (entry.priority === "domain" && (!entry.keywords || entry.keywords.length === 0)) {
|
|
306
|
+
issues.push({
|
|
307
|
+
severity: "error",
|
|
308
|
+
code: "EMPTY_KEYWORDS",
|
|
309
|
+
message: `Domain entry "${entry.id}" has no keywords \u2014 cannot be routed`,
|
|
310
|
+
hint: "Add keywords to frontmatter so the matcher can find this entry",
|
|
311
|
+
entryId: entry.id
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (typeof entry.tokens_est !== "number" || entry.tokens_est < 0) {
|
|
315
|
+
issues.push({
|
|
316
|
+
severity: "warning",
|
|
317
|
+
code: "BAD_TOKEN_EST",
|
|
318
|
+
message: `Entry "${entry.id}" has invalid token estimate: ${entry.tokens_est}`,
|
|
319
|
+
entryId: entry.id
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (index.budget) {
|
|
324
|
+
if (index.budget.always_loaded_est < 0) {
|
|
325
|
+
issues.push({
|
|
326
|
+
severity: "warning",
|
|
327
|
+
code: "NEGATIVE_BUDGET",
|
|
328
|
+
message: "always_loaded_est is negative"
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (index.budget.on_demand_total_est < 0) {
|
|
332
|
+
issues.push({
|
|
333
|
+
severity: "warning",
|
|
334
|
+
code: "NEGATIVE_BUDGET",
|
|
335
|
+
message: "on_demand_total_est is negative"
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return issues;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ../kernel/dist/merge.js
|
|
343
|
+
function mergeIndexes(layers) {
|
|
344
|
+
const entryMap = /* @__PURE__ */ new Map();
|
|
345
|
+
const provenance = {};
|
|
346
|
+
const conflicts = [];
|
|
347
|
+
const idLayers = /* @__PURE__ */ new Map();
|
|
348
|
+
for (const { name, index } of layers) {
|
|
349
|
+
for (const entry of index.entries) {
|
|
350
|
+
const existing = idLayers.get(entry.id) ?? [];
|
|
351
|
+
existing.push(name);
|
|
352
|
+
idLayers.set(entry.id, existing);
|
|
353
|
+
entryMap.set(entry.id, { entry, layer: name });
|
|
354
|
+
provenance[entry.id] = name;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const [id, layerNames] of idLayers) {
|
|
358
|
+
if (layerNames.length > 1) {
|
|
359
|
+
conflicts.push({
|
|
360
|
+
entryId: id,
|
|
361
|
+
layers: layerNames,
|
|
362
|
+
resolution: "override"
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const entries = [...entryMap.values()].map((v) => v.entry);
|
|
367
|
+
const coreTokens = entries.filter((e) => e.priority === "core").reduce((sum, e) => sum + e.tokens_est, 0);
|
|
368
|
+
const onDemandTokens = entries.filter((e) => e.priority !== "core").reduce((sum, e) => sum + e.tokens_est, 0);
|
|
369
|
+
const domainEntries = entries.filter((e) => e.priority === "domain");
|
|
370
|
+
const avgTaskLoad = domainEntries.length > 0 ? Math.round(onDemandTokens / domainEntries.length) : 0;
|
|
371
|
+
const budget = {
|
|
372
|
+
always_loaded_est: coreTokens,
|
|
373
|
+
on_demand_total_est: onDemandTokens,
|
|
374
|
+
avg_task_load_est: avgTaskLoad,
|
|
375
|
+
avg_task_load_observed: null
|
|
376
|
+
};
|
|
377
|
+
return {
|
|
378
|
+
version: "1.0.0",
|
|
379
|
+
generated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
380
|
+
entries,
|
|
381
|
+
budget,
|
|
382
|
+
provenance,
|
|
383
|
+
conflicts
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ../kernel/dist/usage.js
|
|
388
|
+
import { appendFileSync, readFileSync, existsSync } from "node:fs";
|
|
389
|
+
function readUsageWithStats(filePath) {
|
|
390
|
+
if (!existsSync(filePath))
|
|
391
|
+
return { events: [], skipped: 0 };
|
|
392
|
+
const content = readFileSync(filePath, "utf-8");
|
|
393
|
+
const events = [];
|
|
394
|
+
let skipped = 0;
|
|
395
|
+
for (const line of content.split("\n")) {
|
|
396
|
+
const trimmed = line.trim();
|
|
397
|
+
if (!trimmed)
|
|
398
|
+
continue;
|
|
399
|
+
try {
|
|
400
|
+
events.push(JSON.parse(trimmed));
|
|
401
|
+
} catch {
|
|
402
|
+
skipped++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { events, skipped };
|
|
406
|
+
}
|
|
407
|
+
function readUsage(filePath) {
|
|
408
|
+
return readUsageWithStats(filePath).events;
|
|
409
|
+
}
|
|
410
|
+
function summarizeUsage(events) {
|
|
411
|
+
const map = /* @__PURE__ */ new Map();
|
|
412
|
+
for (const event of events) {
|
|
413
|
+
const existing = map.get(event.entryId);
|
|
414
|
+
if (existing) {
|
|
415
|
+
existing.loadCount++;
|
|
416
|
+
existing.totalTokens += event.tokensEst;
|
|
417
|
+
if (event.timestamp > existing.lastLoaded) {
|
|
418
|
+
existing.lastLoaded = event.timestamp;
|
|
419
|
+
}
|
|
420
|
+
existing.triggers.add(event.trigger);
|
|
421
|
+
existing.modes.add(event.mode);
|
|
422
|
+
} else {
|
|
423
|
+
map.set(event.entryId, {
|
|
424
|
+
loadCount: 1,
|
|
425
|
+
totalTokens: event.tokensEst,
|
|
426
|
+
lastLoaded: event.timestamp,
|
|
427
|
+
triggers: /* @__PURE__ */ new Set([event.trigger]),
|
|
428
|
+
modes: /* @__PURE__ */ new Set([event.mode])
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return [...map.entries()].map(([entryId, data]) => ({
|
|
433
|
+
entryId,
|
|
434
|
+
loadCount: data.loadCount,
|
|
435
|
+
totalTokens: data.totalTokens,
|
|
436
|
+
lastLoaded: data.lastLoaded,
|
|
437
|
+
triggers: [...data.triggers],
|
|
438
|
+
modes: data.modes
|
|
439
|
+
})).sort((a, b) => b.loadCount - a.loadCount);
|
|
440
|
+
}
|
|
441
|
+
function summaryToJSON(summary) {
|
|
442
|
+
return {
|
|
443
|
+
entryId: summary.entryId,
|
|
444
|
+
loadCount: summary.loadCount,
|
|
445
|
+
totalTokens: summary.totalTokens,
|
|
446
|
+
lastLoaded: summary.lastLoaded,
|
|
447
|
+
triggers: summary.triggers,
|
|
448
|
+
modes: [...summary.modes]
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ../kernel/dist/analysis.js
|
|
453
|
+
function findDeadEntries(index, events) {
|
|
454
|
+
const loadedIds = new Set(events.map((e) => e.entryId));
|
|
455
|
+
const dead = [];
|
|
456
|
+
for (const entry of index.entries) {
|
|
457
|
+
if (entry.priority === "core")
|
|
458
|
+
continue;
|
|
459
|
+
if (!loadedIds.has(entry.id)) {
|
|
460
|
+
dead.push({
|
|
461
|
+
entry,
|
|
462
|
+
reason: `Never loaded (${entry.tokens_est} tokens wasted in index)`
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
dead.sort((a, b) => b.entry.tokens_est - a.entry.tokens_est);
|
|
467
|
+
return dead;
|
|
468
|
+
}
|
|
469
|
+
function findKeywordOverlaps(index) {
|
|
470
|
+
const keywordMap = /* @__PURE__ */ new Map();
|
|
471
|
+
for (const entry of index.entries) {
|
|
472
|
+
for (const kw of entry.keywords) {
|
|
473
|
+
const existing = keywordMap.get(kw) ?? [];
|
|
474
|
+
existing.push(entry.id);
|
|
475
|
+
keywordMap.set(kw, existing);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const overlaps = [];
|
|
479
|
+
for (const [keyword, entries] of keywordMap) {
|
|
480
|
+
if (entries.length > 1) {
|
|
481
|
+
overlaps.push({ keyword, entries });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
overlaps.sort((a, b) => b.entries.length - a.entries.length);
|
|
485
|
+
return overlaps;
|
|
486
|
+
}
|
|
487
|
+
function analyzeBudget(index, usage) {
|
|
488
|
+
const core = index.entries.filter((e) => e.priority === "core");
|
|
489
|
+
const domain = index.entries.filter((e) => e.priority === "domain");
|
|
490
|
+
const manual = index.entries.filter((e) => e.priority === "manual");
|
|
491
|
+
const coreTokens = core.reduce((s, e) => s + e.tokens_est, 0);
|
|
492
|
+
const domainTokens = domain.reduce((s, e) => s + e.tokens_est, 0);
|
|
493
|
+
const manualTokens = manual.reduce((s, e) => s + e.tokens_est, 0);
|
|
494
|
+
const allEntries = index.entries;
|
|
495
|
+
const sorted = [...allEntries].sort((a, b) => b.tokens_est - a.tokens_est);
|
|
496
|
+
let observedAvg = null;
|
|
497
|
+
if (usage && usage.length > 0) {
|
|
498
|
+
const totalObservedTokens = usage.reduce((s, u) => s + u.totalTokens, 0);
|
|
499
|
+
const totalLoads = usage.reduce((s, u) => s + u.loadCount, 0);
|
|
500
|
+
if (totalLoads > 0) {
|
|
501
|
+
observedAvg = Math.round(totalObservedTokens / totalLoads);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
totalTokens: coreTokens + domainTokens + manualTokens,
|
|
506
|
+
coreTokens,
|
|
507
|
+
domainTokens,
|
|
508
|
+
manualTokens,
|
|
509
|
+
coreEntries: core.length,
|
|
510
|
+
domainEntries: domain.length,
|
|
511
|
+
manualEntries: manual.length,
|
|
512
|
+
avgDomainSize: domain.length > 0 ? Math.round(domainTokens / domain.length) : 0,
|
|
513
|
+
largestEntry: sorted.length > 0 ? { id: sorted[0].id, tokens: sorted[0].tokens_est } : null,
|
|
514
|
+
smallestEntry: sorted.length > 0 ? { id: sorted[sorted.length - 1].id, tokens: sorted[sorted.length - 1].tokens_est } : null,
|
|
515
|
+
observedAvg
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ../kernel/dist/resolve.js
|
|
520
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
|
|
521
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
522
|
+
import { homedir } from "node:os";
|
|
523
|
+
function discoverLayers(opts) {
|
|
524
|
+
const projectRoot = resolvePath(opts?.projectRoot ?? process.cwd());
|
|
525
|
+
const globalDir = opts?.globalDir ?? join(homedir(), ".ai-loadout");
|
|
526
|
+
const orgPath = opts?.orgPath ?? process.env.AI_LOADOUT_ORG ?? null;
|
|
527
|
+
const sessionPath = opts?.sessionPath ?? process.env.AI_LOADOUT_SESSION ?? null;
|
|
528
|
+
const candidates = [
|
|
529
|
+
{ name: "global", path: join(globalDir, "index.json") }
|
|
530
|
+
];
|
|
531
|
+
if (orgPath) {
|
|
532
|
+
candidates.push({ name: "org", path: resolvePath(orgPath) });
|
|
533
|
+
}
|
|
534
|
+
candidates.push({
|
|
535
|
+
name: "project",
|
|
536
|
+
path: join(projectRoot, ".claude", "loadout", "index.json")
|
|
537
|
+
});
|
|
538
|
+
if (sessionPath) {
|
|
539
|
+
candidates.push({ name: "session", path: resolvePath(sessionPath) });
|
|
540
|
+
}
|
|
541
|
+
const layers = [];
|
|
542
|
+
const searched = [];
|
|
543
|
+
for (const { name, path } of candidates) {
|
|
544
|
+
const found = existsSync2(path);
|
|
545
|
+
searched.push({ name, path, found });
|
|
546
|
+
if (found) {
|
|
547
|
+
try {
|
|
548
|
+
const raw = readFileSync2(path, "utf-8");
|
|
549
|
+
const index = JSON.parse(raw);
|
|
550
|
+
layers.push({ name, path, index });
|
|
551
|
+
} catch (e) {
|
|
552
|
+
const rec = searched[searched.length - 1];
|
|
553
|
+
rec.found = false;
|
|
554
|
+
rec.malformed = true;
|
|
555
|
+
rec.error = e.message;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return { layers, searched };
|
|
560
|
+
}
|
|
561
|
+
function resolveLoadout(opts) {
|
|
562
|
+
const { layers, searched } = discoverLayers(opts);
|
|
563
|
+
const merged = mergeIndexes(layers.map((l) => ({ name: l.name, index: l.index })));
|
|
564
|
+
return { merged, layers, searched };
|
|
565
|
+
}
|
|
566
|
+
function explainEntry(entryId, layers) {
|
|
567
|
+
const definitions = [];
|
|
568
|
+
const overrideChain = [];
|
|
569
|
+
for (const layer of layers) {
|
|
570
|
+
const entry = layer.index.entries.find((e) => e.id === entryId);
|
|
571
|
+
if (entry) {
|
|
572
|
+
definitions.push({
|
|
573
|
+
layer: layer.name,
|
|
574
|
+
summary: entry.summary,
|
|
575
|
+
priority: entry.priority,
|
|
576
|
+
tokens: entry.tokens_est,
|
|
577
|
+
keywords: entry.keywords,
|
|
578
|
+
path: entry.path
|
|
579
|
+
});
|
|
580
|
+
overrideChain.push(layer.name);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (definitions.length === 0) {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
id: entryId,
|
|
588
|
+
finalLayer: overrideChain[overrideChain.length - 1],
|
|
589
|
+
definitions,
|
|
590
|
+
overrideChain,
|
|
591
|
+
isConflict: definitions.length > 1
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ../memories/dist/index-gen.js
|
|
596
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
597
|
+
import { dirname as dirname2, resolve as resolve2 } from "node:path";
|
|
598
|
+
|
|
599
|
+
// ../memories/dist/analyze.js
|
|
600
|
+
import { readFileSync as readFileSync3, existsSync as existsSync4, readdirSync, statSync } from "node:fs";
|
|
601
|
+
import { join as join3, dirname, relative, extname, basename, resolve } from "node:path";
|
|
602
|
+
|
|
603
|
+
// ../memories/dist/paths.js
|
|
604
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
605
|
+
import { join as join2 } from "node:path";
|
|
606
|
+
function resolveRefPath(refPath, ...baseDirs) {
|
|
607
|
+
for (const base of baseDirs) {
|
|
608
|
+
const full = join2(base, refPath);
|
|
609
|
+
if (existsSync3(full))
|
|
610
|
+
return full;
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ../memories/dist/analyze.js
|
|
616
|
+
function analyzeMemoryMd(filePath) {
|
|
617
|
+
let content;
|
|
618
|
+
try {
|
|
619
|
+
content = readFileSync3(filePath, "utf-8");
|
|
620
|
+
} catch (err) {
|
|
621
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
622
|
+
throw new Error(`Cannot read MEMORY.md at ${filePath}: ${msg}`);
|
|
623
|
+
}
|
|
624
|
+
const fileDir = dirname(resolve(filePath));
|
|
625
|
+
const parentDir = dirname(fileDir);
|
|
626
|
+
const { sections, refs } = parseMemoryMd(content);
|
|
627
|
+
const inlineTokens = estimateTokens(content);
|
|
628
|
+
const diagnostics = [];
|
|
629
|
+
const missingFiles = [];
|
|
630
|
+
let topicTokens = 0;
|
|
631
|
+
for (const ref of refs) {
|
|
632
|
+
const resolved = resolveRefPath(ref.path, fileDir, parentDir);
|
|
633
|
+
if (resolved) {
|
|
634
|
+
try {
|
|
635
|
+
const topicContent = readFileSync3(resolved, "utf-8");
|
|
636
|
+
topicTokens += estimateTokens(topicContent);
|
|
637
|
+
} catch {
|
|
638
|
+
missingFiles.push(ref.path);
|
|
639
|
+
diagnostics.push({
|
|
640
|
+
severity: "error",
|
|
641
|
+
code: "MISSING_TOPIC_FILE",
|
|
642
|
+
message: `Referenced topic file not found: ${ref.path}`,
|
|
643
|
+
refPath: ref.path,
|
|
644
|
+
line: ref.line,
|
|
645
|
+
hint: "Create the file or remove the reference from MEMORY.md"
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
missingFiles.push(ref.path);
|
|
650
|
+
diagnostics.push({
|
|
651
|
+
severity: "error",
|
|
652
|
+
code: "MISSING_TOPIC_FILE",
|
|
653
|
+
message: `Referenced topic file not found: ${ref.path}`,
|
|
654
|
+
refPath: ref.path,
|
|
655
|
+
line: ref.line,
|
|
656
|
+
hint: "Create the file or remove the reference from MEMORY.md"
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const referencedBasenames = /* @__PURE__ */ new Set();
|
|
661
|
+
for (const ref of refs) {
|
|
662
|
+
referencedBasenames.add(ref.path);
|
|
663
|
+
referencedBasenames.add(basename(ref.path));
|
|
664
|
+
}
|
|
665
|
+
const orphanFiles = [];
|
|
666
|
+
if (existsSync4(fileDir)) {
|
|
667
|
+
try {
|
|
668
|
+
for (const entry of readdirSync(fileDir)) {
|
|
669
|
+
const fullPath = join3(fileDir, entry);
|
|
670
|
+
try {
|
|
671
|
+
const stat = statSync(fullPath);
|
|
672
|
+
if (stat.isFile() && extname(entry) === ".md" && entry !== "MEMORY.md") {
|
|
673
|
+
if (!referencedBasenames.has(entry)) {
|
|
674
|
+
orphanFiles.push(entry);
|
|
675
|
+
diagnostics.push(orphanDiagnostic(entry));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (stat.isDirectory()) {
|
|
679
|
+
scanDir(fullPath, fileDir, referencedBasenames, orphanFiles, diagnostics);
|
|
680
|
+
}
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
filePath,
|
|
689
|
+
sections,
|
|
690
|
+
refs,
|
|
691
|
+
orphanFiles,
|
|
692
|
+
missingFiles,
|
|
693
|
+
diagnostics,
|
|
694
|
+
totalTokens: inlineTokens + topicTokens,
|
|
695
|
+
inlineTokens,
|
|
696
|
+
topicTokens
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function orphanDiagnostic(path) {
|
|
700
|
+
return {
|
|
701
|
+
severity: "warning",
|
|
702
|
+
code: "ORPHAN_TOPIC_FILE",
|
|
703
|
+
message: `Topic file not referenced in MEMORY.md: ${path}`,
|
|
704
|
+
refPath: path,
|
|
705
|
+
hint: "Add a reference in MEMORY.md or delete the file"
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function scanDir(dir, baseDir, referenced, orphans, diagnostics) {
|
|
709
|
+
let entries;
|
|
710
|
+
try {
|
|
711
|
+
entries = readdirSync(dir);
|
|
712
|
+
} catch {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
for (const entry of entries) {
|
|
716
|
+
const fullPath = join3(dir, entry);
|
|
717
|
+
try {
|
|
718
|
+
const stat = statSync(fullPath);
|
|
719
|
+
if (stat.isDirectory()) {
|
|
720
|
+
scanDir(fullPath, baseDir, referenced, orphans, diagnostics);
|
|
721
|
+
} else if (extname(entry) === ".md") {
|
|
722
|
+
const relPath = relative(baseDir, fullPath).replace(/\\/g, "/");
|
|
723
|
+
if (!referenced.has(relPath) && !referenced.has(basename(relPath))) {
|
|
724
|
+
orphans.push(relPath);
|
|
725
|
+
diagnostics.push(orphanDiagnostic(relPath));
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function extractKeywords(name, content) {
|
|
733
|
+
const words = /* @__PURE__ */ new Set();
|
|
734
|
+
for (const w of name.toLowerCase().split(/[\s-]+/)) {
|
|
735
|
+
if (w.length > 2 && !STOP_WORDS.has(w)) {
|
|
736
|
+
words.add(w);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
for (const line of content.split("\n")) {
|
|
740
|
+
const headingMatch = line.match(/^#{1,3}\s+(.+)/);
|
|
741
|
+
if (headingMatch) {
|
|
742
|
+
for (const w of headingMatch[1].toLowerCase().split(/[\s-]+/)) {
|
|
743
|
+
const cleaned = w.replace(/[^a-z0-9]/g, "");
|
|
744
|
+
if (cleaned.length > 2 && !STOP_WORDS.has(cleaned)) {
|
|
745
|
+
words.add(cleaned);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return [...words].sort();
|
|
751
|
+
}
|
|
752
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
753
|
+
"the",
|
|
754
|
+
"and",
|
|
755
|
+
"for",
|
|
756
|
+
"are",
|
|
757
|
+
"not",
|
|
758
|
+
"but",
|
|
759
|
+
"with",
|
|
760
|
+
"from",
|
|
761
|
+
"this",
|
|
762
|
+
"that",
|
|
763
|
+
"have",
|
|
764
|
+
"has",
|
|
765
|
+
"had",
|
|
766
|
+
"was",
|
|
767
|
+
"were",
|
|
768
|
+
"been",
|
|
769
|
+
"will",
|
|
770
|
+
"can",
|
|
771
|
+
"may",
|
|
772
|
+
"should",
|
|
773
|
+
"would",
|
|
774
|
+
"could",
|
|
775
|
+
"about",
|
|
776
|
+
"into",
|
|
777
|
+
"than",
|
|
778
|
+
"then",
|
|
779
|
+
"when",
|
|
780
|
+
"where",
|
|
781
|
+
"which",
|
|
782
|
+
"what",
|
|
783
|
+
"how",
|
|
784
|
+
"all",
|
|
785
|
+
"each",
|
|
786
|
+
"every",
|
|
787
|
+
"both",
|
|
788
|
+
"few",
|
|
789
|
+
"more",
|
|
790
|
+
"most",
|
|
791
|
+
"other",
|
|
792
|
+
"some",
|
|
793
|
+
"such",
|
|
794
|
+
"only",
|
|
795
|
+
"own",
|
|
796
|
+
"same",
|
|
797
|
+
"just",
|
|
798
|
+
"also",
|
|
799
|
+
"very",
|
|
800
|
+
"often",
|
|
801
|
+
"once",
|
|
802
|
+
"here",
|
|
803
|
+
"there",
|
|
804
|
+
"why",
|
|
805
|
+
"use",
|
|
806
|
+
"used",
|
|
807
|
+
"using",
|
|
808
|
+
"note",
|
|
809
|
+
"notes"
|
|
810
|
+
]);
|
|
811
|
+
|
|
812
|
+
// ../memories/dist/index-gen.js
|
|
813
|
+
function generateIndex(analysis, opts = {}) {
|
|
814
|
+
const fileDir = dirname2(resolve2(analysis.filePath));
|
|
815
|
+
const parentDir = dirname2(fileDir);
|
|
816
|
+
const entries = [];
|
|
817
|
+
for (const ref of analysis.refs) {
|
|
818
|
+
const fullPath = resolveRefPath(ref.path, fileDir, parentDir);
|
|
819
|
+
if (!fullPath) {
|
|
820
|
+
if (!analysis.missingFiles.includes(ref.path)) {
|
|
821
|
+
analysis.missingFiles.push(ref.path);
|
|
822
|
+
}
|
|
823
|
+
if (!analysis.diagnostics.some((d) => d.code === "UNRESOLVED_REF" && d.refPath === ref.path)) {
|
|
824
|
+
analysis.diagnostics.push({
|
|
825
|
+
severity: "error",
|
|
826
|
+
code: "UNRESOLVED_REF",
|
|
827
|
+
message: `Reference could not be resolved to a file on disk: ${ref.path}`,
|
|
828
|
+
refPath: ref.path,
|
|
829
|
+
line: ref.line,
|
|
830
|
+
hint: "Create the file, fix the path, or remove the reference from MEMORY.md"
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const content = readFileSync4(fullPath, "utf-8");
|
|
836
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
837
|
+
if (frontmatter) {
|
|
838
|
+
entries.push(entryFromFrontmatter(frontmatter, ref, content));
|
|
839
|
+
} else {
|
|
840
|
+
entries.push(entryFromContent(ref, content));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const coreTokens = entries.filter((e) => e.priority === "core").reduce((sum, e) => sum + e.tokens_est, 0);
|
|
844
|
+
const onDemandTokens = entries.filter((e) => e.priority !== "core").reduce((sum, e) => sum + e.tokens_est, 0);
|
|
845
|
+
const domainEntries = entries.filter((e) => e.priority === "domain");
|
|
846
|
+
const avgTaskLoad = domainEntries.length > 0 ? Math.round(onDemandTokens / domainEntries.length) : 0;
|
|
847
|
+
const budget = {
|
|
848
|
+
always_loaded_est: analysis.inlineTokens + coreTokens,
|
|
849
|
+
on_demand_total_est: onDemandTokens,
|
|
850
|
+
avg_task_load_est: avgTaskLoad,
|
|
851
|
+
avg_task_load_observed: null
|
|
852
|
+
};
|
|
853
|
+
return {
|
|
854
|
+
version: "1.0.0",
|
|
855
|
+
generated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
856
|
+
source: analysis.filePath,
|
|
857
|
+
entries,
|
|
858
|
+
budget,
|
|
859
|
+
...opts.lazyLoad ? { lazyLoad: true } : {}
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
var MAX_SUMMARY = 120;
|
|
863
|
+
function truncateSummary(summary) {
|
|
864
|
+
return summary.slice(0, MAX_SUMMARY);
|
|
865
|
+
}
|
|
866
|
+
function entryFromFrontmatter(fm, ref, content) {
|
|
867
|
+
const lines = content.split("\n").length;
|
|
868
|
+
return {
|
|
869
|
+
id: fm.id,
|
|
870
|
+
path: ref.path,
|
|
871
|
+
keywords: fm.keywords,
|
|
872
|
+
patterns: fm.patterns,
|
|
873
|
+
priority: fm.priority,
|
|
874
|
+
// MEM-007: truncate here too — entryFromContent already truncated to
|
|
875
|
+
// 120, this branch did not, so a long summary survived asymmetrically.
|
|
876
|
+
summary: truncateSummary(ref.description || `Memory: ${ref.name}`),
|
|
877
|
+
triggers: fm.triggers,
|
|
878
|
+
tokens_est: estimateTokens(content),
|
|
879
|
+
lines
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
function entryFromContent(ref, content) {
|
|
883
|
+
const id = nameToId(ref.name);
|
|
884
|
+
const keywords = extractKeywords(ref.name, content);
|
|
885
|
+
const lines = content.split("\n").length;
|
|
886
|
+
return {
|
|
887
|
+
id,
|
|
888
|
+
path: ref.path,
|
|
889
|
+
keywords,
|
|
890
|
+
patterns: [],
|
|
891
|
+
priority: "domain",
|
|
892
|
+
summary: ref.description ? truncateSummary(ref.description) : `Memory: ${ref.name}`,
|
|
893
|
+
triggers: { ...DEFAULT_TRIGGERS },
|
|
894
|
+
tokens_est: estimateTokens(content),
|
|
895
|
+
lines
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function nameToId(name) {
|
|
899
|
+
return name.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ../memories/dist/parser.js
|
|
903
|
+
var ARROW_REF_RE = /^(?:[-*]\s+)?(.+?)\s+→\s+`?([^\s`]+)`?\s*$/;
|
|
904
|
+
var INLINE_PATH_RE = /`(memory\/[^\s`]+|[^\s`]+\.md)`/;
|
|
905
|
+
function isRelativeTopicPath(path) {
|
|
906
|
+
if (!path)
|
|
907
|
+
return false;
|
|
908
|
+
if (path.includes("*"))
|
|
909
|
+
return false;
|
|
910
|
+
if (path.startsWith("/"))
|
|
911
|
+
return false;
|
|
912
|
+
if (/^[a-zA-Z]:[\\/]/.test(path))
|
|
913
|
+
return false;
|
|
914
|
+
if (/^\\\\/.test(path))
|
|
915
|
+
return false;
|
|
916
|
+
return true;
|
|
917
|
+
}
|
|
918
|
+
function parseMemoryMd(content) {
|
|
919
|
+
const lines = content.split("\n");
|
|
920
|
+
const sections = [];
|
|
921
|
+
const allRefs = [];
|
|
922
|
+
let currentSection = null;
|
|
923
|
+
for (let i = 0; i < lines.length; i++) {
|
|
924
|
+
const line = lines[i];
|
|
925
|
+
const trimmed = line.trim();
|
|
926
|
+
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)/);
|
|
927
|
+
if (headingMatch) {
|
|
928
|
+
if (currentSection) {
|
|
929
|
+
currentSection.endLine = i - 1;
|
|
930
|
+
sections.push(currentSection);
|
|
931
|
+
}
|
|
932
|
+
currentSection = {
|
|
933
|
+
heading: headingMatch[2].trim(),
|
|
934
|
+
level: headingMatch[1].length,
|
|
935
|
+
entries: [],
|
|
936
|
+
startLine: i,
|
|
937
|
+
endLine: i
|
|
938
|
+
};
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (!trimmed)
|
|
942
|
+
continue;
|
|
943
|
+
const ref = parseRefLine(trimmed, i);
|
|
944
|
+
if (ref) {
|
|
945
|
+
allRefs.push(ref);
|
|
946
|
+
if (currentSection) {
|
|
947
|
+
currentSection.entries.push(ref);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (currentSection) {
|
|
952
|
+
currentSection.endLine = lines.length - 1;
|
|
953
|
+
sections.push(currentSection);
|
|
954
|
+
}
|
|
955
|
+
return { sections, refs: allRefs };
|
|
956
|
+
}
|
|
957
|
+
function parseRefLine(line, lineNum) {
|
|
958
|
+
const stripped = line.replace(/^[-*]\s+/, "").trim();
|
|
959
|
+
if (!stripped)
|
|
960
|
+
return null;
|
|
961
|
+
const arrowMatch = line.match(ARROW_REF_RE);
|
|
962
|
+
if (arrowMatch) {
|
|
963
|
+
const [, nameDesc, path] = arrowMatch;
|
|
964
|
+
const { name, description } = splitNameDesc(nameDesc);
|
|
965
|
+
return { name, description, path, line: lineNum };
|
|
966
|
+
}
|
|
967
|
+
const isBullet = /^[-*]\s+/.test(line);
|
|
968
|
+
const hasArrow = line.includes(" \u2192 ");
|
|
969
|
+
if (isBullet && hasArrow) {
|
|
970
|
+
const pathMatch = stripped.match(INLINE_PATH_RE);
|
|
971
|
+
if (pathMatch) {
|
|
972
|
+
const path = pathMatch[1];
|
|
973
|
+
if (isRelativeTopicPath(path)) {
|
|
974
|
+
const pathIdx = stripped.indexOf("`" + path);
|
|
975
|
+
if (pathIdx !== -1) {
|
|
976
|
+
const beforePath = stripped.slice(0, pathIdx).trim();
|
|
977
|
+
const cleaned = beforePath.replace(/\s*→\s*$/, "").trim();
|
|
978
|
+
if (cleaned) {
|
|
979
|
+
const { name, description } = splitNameDesc(cleaned);
|
|
980
|
+
const id = nameToId(name);
|
|
981
|
+
if (id && !id.startsWith("-") && !id.endsWith("-")) {
|
|
982
|
+
return { name, description, path, line: lineNum };
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
function splitNameDesc(text) {
|
|
992
|
+
const emIdx = text.indexOf("\u2014");
|
|
993
|
+
if (emIdx !== -1) {
|
|
994
|
+
return {
|
|
995
|
+
name: text.slice(0, emIdx).trim(),
|
|
996
|
+
description: text.slice(emIdx + 1).trim()
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const hhIdx = text.indexOf("--");
|
|
1000
|
+
if (hhIdx !== -1) {
|
|
1001
|
+
return {
|
|
1002
|
+
name: text.slice(0, hhIdx).trim(),
|
|
1003
|
+
description: text.slice(hhIdx + 2).trim()
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
return { name: text.trim(), description: "" };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// ../memories/dist/validate.js
|
|
1010
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
1011
|
+
import { dirname as dirname3, resolve as resolve3 } from "node:path";
|
|
1012
|
+
var MAX_ID_LENGTH = 60;
|
|
1013
|
+
function validateMemory(analysis) {
|
|
1014
|
+
const issues = [];
|
|
1015
|
+
for (const path of analysis.missingFiles) {
|
|
1016
|
+
issues.push({
|
|
1017
|
+
severity: "error",
|
|
1018
|
+
code: "MISSING_TOPIC_FILE",
|
|
1019
|
+
message: `Referenced topic file not found: ${path}`,
|
|
1020
|
+
hint: "Create the file or remove the reference from MEMORY.md"
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
for (const path of analysis.orphanFiles) {
|
|
1024
|
+
issues.push({
|
|
1025
|
+
severity: "warning",
|
|
1026
|
+
code: "ORPHAN_TOPIC_FILE",
|
|
1027
|
+
message: `Topic file not referenced in MEMORY.md: ${path}`,
|
|
1028
|
+
hint: "Add a reference in MEMORY.md or delete the file"
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
if (analysis.refs.length === 0) {
|
|
1032
|
+
issues.push({
|
|
1033
|
+
severity: "warning",
|
|
1034
|
+
code: "NO_REFS",
|
|
1035
|
+
message: "MEMORY.md has no topic file references",
|
|
1036
|
+
hint: "Add references using the format: Name \u2014 description \u2192 `path`"
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
const pathCounts = /* @__PURE__ */ new Map();
|
|
1040
|
+
for (const ref of analysis.refs) {
|
|
1041
|
+
pathCounts.set(ref.path, (pathCounts.get(ref.path) ?? 0) + 1);
|
|
1042
|
+
}
|
|
1043
|
+
for (const [path, count] of pathCounts) {
|
|
1044
|
+
if (count > 1) {
|
|
1045
|
+
issues.push({
|
|
1046
|
+
severity: "warning",
|
|
1047
|
+
code: "DUPLICATE_REF",
|
|
1048
|
+
message: `Topic file referenced ${count} times: ${path}`
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
for (const ref of analysis.refs) {
|
|
1053
|
+
if (!ref.name || ref.name.trim().length === 0) {
|
|
1054
|
+
issues.push({
|
|
1055
|
+
severity: "warning",
|
|
1056
|
+
code: "EMPTY_NAME",
|
|
1057
|
+
message: `Reference at line ${ref.line + 1} has no name`
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
const fileDir = dirname3(resolve3(analysis.filePath));
|
|
1062
|
+
const parentDir = dirname3(fileDir);
|
|
1063
|
+
for (const ref of analysis.refs) {
|
|
1064
|
+
let effectiveId = nameToId(ref.name);
|
|
1065
|
+
let source = "Derived";
|
|
1066
|
+
const resolved = resolveRefPath(ref.path, fileDir, parentDir);
|
|
1067
|
+
if (resolved) {
|
|
1068
|
+
try {
|
|
1069
|
+
const { frontmatter } = parseFrontmatter(readFileSync5(resolved, "utf-8"));
|
|
1070
|
+
if (frontmatter && typeof frontmatter.id === "string" && frontmatter.id.length > 0) {
|
|
1071
|
+
effectiveId = frontmatter.id;
|
|
1072
|
+
source = "Frontmatter";
|
|
1073
|
+
}
|
|
1074
|
+
} catch {
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (effectiveId.length > MAX_ID_LENGTH) {
|
|
1078
|
+
issues.push({
|
|
1079
|
+
severity: "warning",
|
|
1080
|
+
code: "ID_TOO_LONG",
|
|
1081
|
+
message: `${source} id at line ${ref.line + 1} is ${effectiveId.length} chars (max ${MAX_ID_LENGTH}): "${effectiveId}"`,
|
|
1082
|
+
hint: source === "Frontmatter" ? `Shorten the frontmatter id in ${ref.path} so it stays under ${MAX_ID_LENGTH} chars` : `Shorten the reference name so its kebab-case id stays under ${MAX_ID_LENGTH} chars`
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return issues;
|
|
1087
|
+
}
|
|
1088
|
+
function validateMemoryIndex(index) {
|
|
1089
|
+
return validateIndex(index);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ../memories/dist/stats.js
|
|
1093
|
+
function generateStats(analysis, index) {
|
|
1094
|
+
const coreCount = index.entries.filter((e) => e.priority === "core").length;
|
|
1095
|
+
const domainCount = index.entries.filter((e) => e.priority === "domain").length;
|
|
1096
|
+
const manualCount = index.entries.filter((e) => e.priority === "manual").length;
|
|
1097
|
+
const savingsPercent = analysis.totalTokens > 0 ? Math.round(index.budget.on_demand_total_est / analysis.totalTokens * 100) : 0;
|
|
1098
|
+
const topEntries = [...index.entries].sort((a, b) => b.tokens_est - a.tokens_est).slice(0, 10).map((e) => ({ id: e.id, tokens: e.tokens_est, priority: e.priority }));
|
|
1099
|
+
return {
|
|
1100
|
+
totalTokens: analysis.totalTokens,
|
|
1101
|
+
inlineTokens: analysis.inlineTokens,
|
|
1102
|
+
topicTokens: analysis.topicTokens,
|
|
1103
|
+
entryCount: index.entries.length,
|
|
1104
|
+
coreCount,
|
|
1105
|
+
domainCount,
|
|
1106
|
+
manualCount,
|
|
1107
|
+
alwaysLoadedEst: index.budget.always_loaded_est,
|
|
1108
|
+
onDemandTotalEst: index.budget.on_demand_total_est,
|
|
1109
|
+
avgTaskLoadEst: index.budget.avg_task_load_est,
|
|
1110
|
+
savingsPercent,
|
|
1111
|
+
topEntries,
|
|
1112
|
+
orphanCount: analysis.orphanFiles.length,
|
|
1113
|
+
missingCount: analysis.missingFiles.length
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
function formatStats(stats) {
|
|
1117
|
+
const lines = [];
|
|
1118
|
+
lines.push("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
|
|
1119
|
+
lines.push("\u2551 Memory Token Budget \u2551");
|
|
1120
|
+
lines.push("\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
|
|
1121
|
+
lines.push("");
|
|
1122
|
+
lines.push(` Total tokens: ${stats.totalTokens.toLocaleString()}`);
|
|
1123
|
+
lines.push(` MEMORY.md inline: ${stats.inlineTokens.toLocaleString()}`);
|
|
1124
|
+
lines.push(` Topic files: ${stats.topicTokens.toLocaleString()}`);
|
|
1125
|
+
lines.push("");
|
|
1126
|
+
lines.push(` Entries: ${stats.entryCount}`);
|
|
1127
|
+
lines.push(` Core: ${stats.coreCount}`);
|
|
1128
|
+
lines.push(` Domain: ${stats.domainCount}`);
|
|
1129
|
+
lines.push(` Manual: ${stats.manualCount}`);
|
|
1130
|
+
lines.push("");
|
|
1131
|
+
lines.push(` Always loaded: ${stats.alwaysLoadedEst.toLocaleString()} tokens`);
|
|
1132
|
+
lines.push(` On-demand total: ${stats.onDemandTotalEst.toLocaleString()} tokens`);
|
|
1133
|
+
lines.push(` Avg task load: ${stats.avgTaskLoadEst.toLocaleString()} tokens`);
|
|
1134
|
+
lines.push(` Savings (lazy): ${stats.savingsPercent}%`);
|
|
1135
|
+
if (stats.topEntries.length > 0) {
|
|
1136
|
+
lines.push("");
|
|
1137
|
+
lines.push(" Top entries by token cost:");
|
|
1138
|
+
for (const e of stats.topEntries) {
|
|
1139
|
+
lines.push(` ${e.id.padEnd(30)} ${String(e.tokens).padStart(6)} tokens [${e.priority}]`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (stats.orphanCount > 0 || stats.missingCount > 0) {
|
|
1143
|
+
lines.push("");
|
|
1144
|
+
if (stats.orphanCount > 0)
|
|
1145
|
+
lines.push(` \u26A0 ${stats.orphanCount} orphan files`);
|
|
1146
|
+
if (stats.missingCount > 0)
|
|
1147
|
+
lines.push(` \u2717 ${stats.missingCount} missing files`);
|
|
1148
|
+
}
|
|
1149
|
+
return lines.join("\n");
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// ../rules/dist/parser.js
|
|
1153
|
+
var HEADING_RE = /^(#{1,6})\s+(.+)$/;
|
|
1154
|
+
function detectHeadings(lines) {
|
|
1155
|
+
const headings = [];
|
|
1156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1157
|
+
const match = HEADING_RE.exec(lines[i]);
|
|
1158
|
+
if (match) {
|
|
1159
|
+
headings.push({
|
|
1160
|
+
level: match[1].length,
|
|
1161
|
+
text: match[2].trim(),
|
|
1162
|
+
lineIndex: i
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return headings;
|
|
1167
|
+
}
|
|
1168
|
+
function parseSections(content) {
|
|
1169
|
+
const lines = content.split("\n");
|
|
1170
|
+
const headings = detectHeadings(lines);
|
|
1171
|
+
const splitHeadings = headings.filter((h) => h.level === 2 || h.level === 3);
|
|
1172
|
+
if (splitHeadings.length === 0) {
|
|
1173
|
+
if (lines.length === 0 || lines.length === 1 && lines[0].trim() === "") {
|
|
1174
|
+
return [];
|
|
1175
|
+
}
|
|
1176
|
+
return [
|
|
1177
|
+
{
|
|
1178
|
+
heading: "(preamble)",
|
|
1179
|
+
level: 0,
|
|
1180
|
+
startLine: 0,
|
|
1181
|
+
endLine: lines.length,
|
|
1182
|
+
content,
|
|
1183
|
+
lines: lines.length,
|
|
1184
|
+
tokens_est: estimateTokens(content)
|
|
1185
|
+
}
|
|
1186
|
+
];
|
|
1187
|
+
}
|
|
1188
|
+
const sections = [];
|
|
1189
|
+
if (splitHeadings[0].lineIndex > 0) {
|
|
1190
|
+
const preambleLines = lines.slice(0, splitHeadings[0].lineIndex);
|
|
1191
|
+
const preambleContent = preambleLines.join("\n");
|
|
1192
|
+
if (preambleContent.trim().length > 0) {
|
|
1193
|
+
sections.push({
|
|
1194
|
+
heading: "(preamble)",
|
|
1195
|
+
level: 0,
|
|
1196
|
+
startLine: 0,
|
|
1197
|
+
endLine: splitHeadings[0].lineIndex,
|
|
1198
|
+
content: preambleContent,
|
|
1199
|
+
lines: preambleLines.length,
|
|
1200
|
+
tokens_est: estimateTokens(preambleContent)
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
for (let i = 0; i < splitHeadings.length; i++) {
|
|
1205
|
+
const h = splitHeadings[i];
|
|
1206
|
+
if (h.level === 3) {
|
|
1207
|
+
let ownedByParent = false;
|
|
1208
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
1209
|
+
if (splitHeadings[j].level === 2) {
|
|
1210
|
+
ownedByParent = true;
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1213
|
+
if (splitHeadings[j].level < 3)
|
|
1214
|
+
break;
|
|
1215
|
+
}
|
|
1216
|
+
if (ownedByParent)
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
let endLine = lines.length;
|
|
1220
|
+
for (let j = i + 1; j < splitHeadings.length; j++) {
|
|
1221
|
+
if (splitHeadings[j].level <= h.level) {
|
|
1222
|
+
endLine = splitHeadings[j].lineIndex;
|
|
1223
|
+
break;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
while (endLine > h.lineIndex + 1 && lines[endLine - 1].trim() === "") {
|
|
1227
|
+
endLine--;
|
|
1228
|
+
}
|
|
1229
|
+
const sectionLines = lines.slice(h.lineIndex, endLine);
|
|
1230
|
+
const sectionContent = sectionLines.join("\n");
|
|
1231
|
+
sections.push({
|
|
1232
|
+
heading: h.text,
|
|
1233
|
+
level: h.level,
|
|
1234
|
+
startLine: h.lineIndex,
|
|
1235
|
+
endLine,
|
|
1236
|
+
content: sectionContent,
|
|
1237
|
+
lines: sectionLines.length,
|
|
1238
|
+
tokens_est: estimateTokens(sectionContent)
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
return sections;
|
|
1242
|
+
}
|
|
1243
|
+
var NOISE_WORDS = /* @__PURE__ */ new Set([
|
|
1244
|
+
"rules",
|
|
1245
|
+
"rule",
|
|
1246
|
+
"non-negotiable",
|
|
1247
|
+
"nonnegotiable",
|
|
1248
|
+
"contract",
|
|
1249
|
+
"source",
|
|
1250
|
+
"of",
|
|
1251
|
+
"truth",
|
|
1252
|
+
"the",
|
|
1253
|
+
"a",
|
|
1254
|
+
"an",
|
|
1255
|
+
"and",
|
|
1256
|
+
"for",
|
|
1257
|
+
"in",
|
|
1258
|
+
"required",
|
|
1259
|
+
"before",
|
|
1260
|
+
"after",
|
|
1261
|
+
"hard"
|
|
1262
|
+
]);
|
|
1263
|
+
function headingToId(heading) {
|
|
1264
|
+
return heading.toLowerCase().replace(/\([^)]*\)/g, "").replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 0 && !NOISE_WORDS.has(w)).slice(0, 3).join("-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unnamed";
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// ../rules/dist/analyze.js
|
|
1268
|
+
import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
|
|
1269
|
+
import { resolve as resolve5 } from "node:path";
|
|
1270
|
+
|
|
1271
|
+
// ../rules/dist/console.js
|
|
1272
|
+
var RED2 = "\x1B[31m";
|
|
1273
|
+
var DIM2 = "\x1B[2m";
|
|
1274
|
+
var RESET2 = "\x1B[0m";
|
|
1275
|
+
function fail2(code, message, hint, exitCode = 1) {
|
|
1276
|
+
console.error(`${RED2}Error [${code}]:${RESET2} ${message}`);
|
|
1277
|
+
console.error(`${DIM2}Hint: ${hint}${RESET2}`);
|
|
1278
|
+
process.exit(exitCode);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ../rules/dist/signals.js
|
|
1282
|
+
import { readFileSync as readFileSync6, existsSync as existsSync5, writeFileSync, mkdirSync } from "node:fs";
|
|
1283
|
+
import { resolve as resolve4, dirname as dirname4 } from "node:path";
|
|
1284
|
+
var DEFAULT_SIGNALS = {
|
|
1285
|
+
domainSignals: [
|
|
1286
|
+
"github actions",
|
|
1287
|
+
"ci",
|
|
1288
|
+
"workflow",
|
|
1289
|
+
"runner",
|
|
1290
|
+
"marketing",
|
|
1291
|
+
"site",
|
|
1292
|
+
"publishing",
|
|
1293
|
+
"automation",
|
|
1294
|
+
"shipping",
|
|
1295
|
+
"publish",
|
|
1296
|
+
"release",
|
|
1297
|
+
"npm",
|
|
1298
|
+
"ownership",
|
|
1299
|
+
"repo",
|
|
1300
|
+
"org",
|
|
1301
|
+
"canonical",
|
|
1302
|
+
"preview",
|
|
1303
|
+
"verification",
|
|
1304
|
+
"dev server",
|
|
1305
|
+
"shipcheck",
|
|
1306
|
+
"treatment",
|
|
1307
|
+
"landing page",
|
|
1308
|
+
"product",
|
|
1309
|
+
"development",
|
|
1310
|
+
"output-first",
|
|
1311
|
+
"guardian",
|
|
1312
|
+
"self-check",
|
|
1313
|
+
"document delight"
|
|
1314
|
+
],
|
|
1315
|
+
stopWords: [
|
|
1316
|
+
"the",
|
|
1317
|
+
"and",
|
|
1318
|
+
"for",
|
|
1319
|
+
"are",
|
|
1320
|
+
"but",
|
|
1321
|
+
"not",
|
|
1322
|
+
"you",
|
|
1323
|
+
"all",
|
|
1324
|
+
"can",
|
|
1325
|
+
"had",
|
|
1326
|
+
"her",
|
|
1327
|
+
"was",
|
|
1328
|
+
"one",
|
|
1329
|
+
"our",
|
|
1330
|
+
"out",
|
|
1331
|
+
"has",
|
|
1332
|
+
"this",
|
|
1333
|
+
"that",
|
|
1334
|
+
"with",
|
|
1335
|
+
"have",
|
|
1336
|
+
"from",
|
|
1337
|
+
"they",
|
|
1338
|
+
"been",
|
|
1339
|
+
"must",
|
|
1340
|
+
"will",
|
|
1341
|
+
"each",
|
|
1342
|
+
"make",
|
|
1343
|
+
"like",
|
|
1344
|
+
"when",
|
|
1345
|
+
"never",
|
|
1346
|
+
"only",
|
|
1347
|
+
"rule",
|
|
1348
|
+
"rules",
|
|
1349
|
+
"non",
|
|
1350
|
+
"negotiable"
|
|
1351
|
+
],
|
|
1352
|
+
patterns: {
|
|
1353
|
+
ci_pipeline: ["ci", "workflow", "github actions"],
|
|
1354
|
+
package_release: ["publish", "release", "npm"],
|
|
1355
|
+
marketing_ops: ["marketing", "site", "landing page"],
|
|
1356
|
+
repo_governance: ["ownership", "canonical", "repo"],
|
|
1357
|
+
dev_workflow: ["preview", "dev server", "verification"],
|
|
1358
|
+
quality_gate: ["shipcheck", "ship_gate", "treatment"],
|
|
1359
|
+
product_dev: ["product", "output-first"]
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
function loadSignals(signalsPath) {
|
|
1363
|
+
const effectivePath = signalsPath ?? resolve4(".claude", "signals.json");
|
|
1364
|
+
if (!existsSync5(effectivePath)) {
|
|
1365
|
+
return DEFAULT_SIGNALS;
|
|
1366
|
+
}
|
|
1367
|
+
let raw;
|
|
1368
|
+
try {
|
|
1369
|
+
raw = JSON.parse(readFileSync6(effectivePath, "utf8"));
|
|
1370
|
+
} catch (e) {
|
|
1371
|
+
return fail2("INVALID_SIGNALS", `Failed to parse signals config: ${e.message}`, `Check that ${effectivePath} is valid JSON, or run 'claude-rules init-signals' to regenerate it.`);
|
|
1372
|
+
}
|
|
1373
|
+
return {
|
|
1374
|
+
domainSignals: Array.isArray(raw.domainSignals) ? raw.domainSignals : DEFAULT_SIGNALS.domainSignals,
|
|
1375
|
+
stopWords: Array.isArray(raw.stopWords) ? raw.stopWords : DEFAULT_SIGNALS.stopWords,
|
|
1376
|
+
patterns: raw.patterns && typeof raw.patterns === "object" ? raw.patterns : DEFAULT_SIGNALS.patterns
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ../rules/dist/analyze.js
|
|
1381
|
+
var CORE_THRESHOLD = 8;
|
|
1382
|
+
var EXTRACT_THRESHOLD = 15;
|
|
1383
|
+
var NON_NEGOTIABLE_RE = /non-negotiable/i;
|
|
1384
|
+
function extractKeywords2(section, signals = DEFAULT_SIGNALS) {
|
|
1385
|
+
const words = /* @__PURE__ */ new Set();
|
|
1386
|
+
const headingWords = section.heading.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2);
|
|
1387
|
+
for (const w of headingWords)
|
|
1388
|
+
words.add(w);
|
|
1389
|
+
const contentLower = section.content.toLowerCase();
|
|
1390
|
+
for (const signal of signals.domainSignals) {
|
|
1391
|
+
if (contentLower.includes(signal)) {
|
|
1392
|
+
for (const w of signal.split(/\s+/)) {
|
|
1393
|
+
if (w.length > 2)
|
|
1394
|
+
words.add(w);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
const stopWords = new Set(signals.stopWords);
|
|
1399
|
+
return [...words].filter((w) => !stopWords.has(w));
|
|
1400
|
+
}
|
|
1401
|
+
var ALWAYS_CORE_RE = /^role$/i;
|
|
1402
|
+
function classifyPriority(section, signals = DEFAULT_SIGNALS) {
|
|
1403
|
+
if (ALWAYS_CORE_RE.test(section.heading.trim()))
|
|
1404
|
+
return "core";
|
|
1405
|
+
if (section.lines <= CORE_THRESHOLD)
|
|
1406
|
+
return "core";
|
|
1407
|
+
if (NON_NEGOTIABLE_RE.test(section.heading) && section.lines > CORE_THRESHOLD) {
|
|
1408
|
+
return "domain";
|
|
1409
|
+
}
|
|
1410
|
+
if (section.lines >= EXTRACT_THRESHOLD)
|
|
1411
|
+
return "domain";
|
|
1412
|
+
const headingLower = section.heading.toLowerCase();
|
|
1413
|
+
for (const signal of signals.domainSignals) {
|
|
1414
|
+
if (headingLower.includes(signal))
|
|
1415
|
+
return "domain";
|
|
1416
|
+
}
|
|
1417
|
+
return "core";
|
|
1418
|
+
}
|
|
1419
|
+
function generateSummary(section) {
|
|
1420
|
+
const lines = section.content.split("\n").filter((l) => l.trim().length > 0);
|
|
1421
|
+
const contentLines = lines.filter((l) => !l.startsWith("#"));
|
|
1422
|
+
if (contentLines.length === 0)
|
|
1423
|
+
return section.heading;
|
|
1424
|
+
let summary = contentLines[0].replace(/^[-*]\s*/, "").trim();
|
|
1425
|
+
if (summary.length > 117)
|
|
1426
|
+
summary = summary.slice(0, 117) + "...";
|
|
1427
|
+
return summary;
|
|
1428
|
+
}
|
|
1429
|
+
function suggestPatterns(section, signals = DEFAULT_SIGNALS) {
|
|
1430
|
+
const result = [];
|
|
1431
|
+
const lower = section.content.toLowerCase();
|
|
1432
|
+
for (const [patternName, triggers] of Object.entries(signals.patterns)) {
|
|
1433
|
+
if (triggers.some((t) => lower.includes(t))) {
|
|
1434
|
+
result.push(patternName);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return result;
|
|
1438
|
+
}
|
|
1439
|
+
function analyzeFile(filePath, rulesDir, signals) {
|
|
1440
|
+
const cfg = signals ?? DEFAULT_SIGNALS;
|
|
1441
|
+
const content = readFileSync7(filePath, "utf8");
|
|
1442
|
+
const sections = parseSections(content);
|
|
1443
|
+
const totalTokens = estimateTokens(content);
|
|
1444
|
+
const totalLines = content.split("\n").length;
|
|
1445
|
+
const proposals = [];
|
|
1446
|
+
const coreCandidate = [];
|
|
1447
|
+
for (const section of sections) {
|
|
1448
|
+
if (section.level === 0) {
|
|
1449
|
+
coreCandidate.push(section);
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (section.lines <= 1) {
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
const priority = classifyPriority(section, cfg);
|
|
1456
|
+
if (priority === "core") {
|
|
1457
|
+
coreCandidate.push(section);
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
const id = headingToId(section.heading);
|
|
1461
|
+
const keywords = extractKeywords2(section, cfg);
|
|
1462
|
+
const patterns = suggestPatterns(section, cfg);
|
|
1463
|
+
const summary = generateSummary(section);
|
|
1464
|
+
proposals.push({
|
|
1465
|
+
section,
|
|
1466
|
+
suggestedId: id,
|
|
1467
|
+
suggestedPath: `${rulesDir}/${id}.md`,
|
|
1468
|
+
suggestedKeywords: keywords,
|
|
1469
|
+
suggestedPatterns: patterns,
|
|
1470
|
+
suggestedPriority: priority,
|
|
1471
|
+
suggestedSummary: summary,
|
|
1472
|
+
reason: section.lines >= EXTRACT_THRESHOLD ? `${section.lines} lines \u2014 too large to load every session` : `Domain-specific content (${keywords.slice(0, 3).join(", ")})`
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
filePath,
|
|
1477
|
+
totalLines,
|
|
1478
|
+
totalTokens,
|
|
1479
|
+
sections,
|
|
1480
|
+
proposals,
|
|
1481
|
+
coreCandidate
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
function resolveClaudeMd(positional) {
|
|
1485
|
+
if (positional.length > 0) {
|
|
1486
|
+
return resolve5(positional[0]);
|
|
1487
|
+
}
|
|
1488
|
+
const candidates = [
|
|
1489
|
+
resolve5(".claude/CLAUDE.md"),
|
|
1490
|
+
resolve5("CLAUDE.md")
|
|
1491
|
+
];
|
|
1492
|
+
for (const c of candidates) {
|
|
1493
|
+
if (existsSync6(c))
|
|
1494
|
+
return c;
|
|
1495
|
+
}
|
|
1496
|
+
return candidates[0];
|
|
1497
|
+
}
|
|
1498
|
+
function resolveMemoryMd() {
|
|
1499
|
+
const candidates = [
|
|
1500
|
+
resolve5(".claude/MEMORY.md"),
|
|
1501
|
+
resolve5("MEMORY.md")
|
|
1502
|
+
];
|
|
1503
|
+
for (const c of candidates) {
|
|
1504
|
+
if (existsSync6(c))
|
|
1505
|
+
return c;
|
|
1506
|
+
}
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// ../rules/dist/split.js
|
|
1511
|
+
function generateRuleFile(proposal) {
|
|
1512
|
+
const fm = {
|
|
1513
|
+
id: proposal.suggestedId,
|
|
1514
|
+
keywords: proposal.suggestedKeywords,
|
|
1515
|
+
patterns: proposal.suggestedPatterns,
|
|
1516
|
+
priority: proposal.suggestedPriority,
|
|
1517
|
+
triggers: { ...DEFAULT_TRIGGERS }
|
|
1518
|
+
};
|
|
1519
|
+
const header = serializeFrontmatter(fm);
|
|
1520
|
+
return `${header}
|
|
1521
|
+
|
|
1522
|
+
${proposal.section.content}
|
|
1523
|
+
`;
|
|
1524
|
+
}
|
|
1525
|
+
function generateIndex2(accepted, coreTokens, lazyLoad = false) {
|
|
1526
|
+
const entries = accepted.map((p) => ({
|
|
1527
|
+
id: p.suggestedId,
|
|
1528
|
+
path: p.suggestedPath,
|
|
1529
|
+
keywords: p.suggestedKeywords,
|
|
1530
|
+
patterns: p.suggestedPatterns,
|
|
1531
|
+
priority: p.suggestedPriority,
|
|
1532
|
+
summary: p.suggestedSummary,
|
|
1533
|
+
triggers: { ...DEFAULT_TRIGGERS },
|
|
1534
|
+
tokens_est: p.section.tokens_est,
|
|
1535
|
+
lines: p.section.lines
|
|
1536
|
+
}));
|
|
1537
|
+
const onDemandTotal = entries.reduce((sum, r) => sum + r.tokens_est, 0);
|
|
1538
|
+
const avgTaskLoad = entries.length > 0 ? Math.round(onDemandTotal / entries.length) : 0;
|
|
1539
|
+
const budget = {
|
|
1540
|
+
always_loaded_est: coreTokens,
|
|
1541
|
+
on_demand_total_est: onDemandTotal,
|
|
1542
|
+
avg_task_load_est: avgTaskLoad,
|
|
1543
|
+
avg_task_load_observed: null
|
|
1544
|
+
};
|
|
1545
|
+
return {
|
|
1546
|
+
version: "1.0.0",
|
|
1547
|
+
generated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1548
|
+
entries,
|
|
1549
|
+
budget,
|
|
1550
|
+
...lazyLoad ? { lazyLoad: true } : {}
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
function generateClaudeMd(coreSections, accepted, index, rulesDir, lazyLoad = false) {
|
|
1554
|
+
const lines = [];
|
|
1555
|
+
for (const section of coreSections) {
|
|
1556
|
+
const cleaned = section.content.replace(/\n---\s*$/, "").trimEnd();
|
|
1557
|
+
lines.push(cleaned);
|
|
1558
|
+
lines.push("");
|
|
1559
|
+
}
|
|
1560
|
+
lines.push("---");
|
|
1561
|
+
lines.push("");
|
|
1562
|
+
lines.push("## Rules Index");
|
|
1563
|
+
lines.push("");
|
|
1564
|
+
lines.push(`Rules are split into topic files under \`${rulesDir}/\`.`);
|
|
1565
|
+
lines.push(`The dispatch table is at \`${rulesDir}/index.json\`.`);
|
|
1566
|
+
lines.push("");
|
|
1567
|
+
if (lazyLoad) {
|
|
1568
|
+
lines.push("**Rule files are NOT pre-loaded. When a task matches a domain rule's keywords, use the Read tool to load the rule file before planning or editing.**");
|
|
1569
|
+
} else {
|
|
1570
|
+
lines.push("**When a task matches a domain rule's keywords, read that rule file before planning or editing.**");
|
|
1571
|
+
}
|
|
1572
|
+
lines.push("");
|
|
1573
|
+
lines.push("| Topic | Keywords | Priority | File |");
|
|
1574
|
+
lines.push("|-------|----------|----------|------|");
|
|
1575
|
+
for (let i = 0; i < index.entries.length; i++) {
|
|
1576
|
+
const rule = index.entries[i];
|
|
1577
|
+
const topic = accepted[i]?.section.heading ?? rule.id;
|
|
1578
|
+
const kw = rule.keywords.slice(0, 4).join(", ");
|
|
1579
|
+
lines.push(`| ${topic} | ${kw} | ${rule.priority} | \`${rule.path}\` |`);
|
|
1580
|
+
}
|
|
1581
|
+
lines.push("");
|
|
1582
|
+
return lines.join("\n");
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// ../rules/dist/validate.js
|
|
1586
|
+
import { readFileSync as readFileSync8, existsSync as existsSync7, readdirSync as readdirSync2 } from "node:fs";
|
|
1587
|
+
import { resolve as resolve6, join as join4 } from "node:path";
|
|
1588
|
+
var FIX_HINTS = {
|
|
1589
|
+
DRIFT_ID: "frontmatter is canonical; update index.json (or re-run `claude-rules split`)",
|
|
1590
|
+
DRIFT_PRIORITY: "frontmatter is canonical; update index.json (or re-run `claude-rules split`)",
|
|
1591
|
+
DRIFT_KEYWORDS: "frontmatter is canonical; update index.json (or re-run `claude-rules split`)",
|
|
1592
|
+
ORPHAN_FILE: "add it to index.json or delete the file",
|
|
1593
|
+
DUPLICATE_ID: "rename one rule's id"
|
|
1594
|
+
};
|
|
1595
|
+
function frontmatterKeyLine(content, key) {
|
|
1596
|
+
const lines = content.split("\n");
|
|
1597
|
+
if (lines[0]?.trim() !== "---")
|
|
1598
|
+
return void 0;
|
|
1599
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1600
|
+
if (lines[i].trim() === "---")
|
|
1601
|
+
break;
|
|
1602
|
+
if (new RegExp(`^\\s*${key}\\s*:`).test(lines[i]))
|
|
1603
|
+
return i + 1;
|
|
1604
|
+
}
|
|
1605
|
+
return void 0;
|
|
1606
|
+
}
|
|
1607
|
+
function validateRules(rulesDir, repoRoot) {
|
|
1608
|
+
const issues = [];
|
|
1609
|
+
const absRulesDir = resolve6(repoRoot, rulesDir);
|
|
1610
|
+
const indexPath = join4(absRulesDir, "index.json");
|
|
1611
|
+
if (!existsSync7(indexPath)) {
|
|
1612
|
+
issues.push({
|
|
1613
|
+
severity: "error",
|
|
1614
|
+
code: "MISSING_INDEX",
|
|
1615
|
+
message: `index.json not found at ${rulesDir}/index.json`,
|
|
1616
|
+
file: indexPath
|
|
1617
|
+
});
|
|
1618
|
+
return issues;
|
|
1619
|
+
}
|
|
1620
|
+
let index;
|
|
1621
|
+
try {
|
|
1622
|
+
index = JSON.parse(readFileSync8(indexPath, "utf8"));
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
issues.push({
|
|
1625
|
+
severity: "error",
|
|
1626
|
+
code: "INVALID_INDEX",
|
|
1627
|
+
message: `Failed to parse index.json: ${e.message}`,
|
|
1628
|
+
file: indexPath
|
|
1629
|
+
});
|
|
1630
|
+
return issues;
|
|
1631
|
+
}
|
|
1632
|
+
if (!Array.isArray(index.entries)) {
|
|
1633
|
+
issues.push({
|
|
1634
|
+
severity: "error",
|
|
1635
|
+
code: "INVALID_INDEX",
|
|
1636
|
+
message: "index.json is missing an `entries` array \u2014 regenerate with `claude-rules split`",
|
|
1637
|
+
file: indexPath
|
|
1638
|
+
});
|
|
1639
|
+
return issues;
|
|
1640
|
+
}
|
|
1641
|
+
if (index.budget === null || typeof index.budget !== "object") {
|
|
1642
|
+
issues.push({
|
|
1643
|
+
severity: "error",
|
|
1644
|
+
code: "INVALID_INDEX",
|
|
1645
|
+
message: "index.json is missing a `budget` object \u2014 regenerate with `claude-rules split`",
|
|
1646
|
+
file: indexPath
|
|
1647
|
+
});
|
|
1648
|
+
return issues;
|
|
1649
|
+
}
|
|
1650
|
+
const indexedFiles = /* @__PURE__ */ new Set();
|
|
1651
|
+
for (const rule of index.entries) {
|
|
1652
|
+
const absPath = resolve6(repoRoot, rule.path);
|
|
1653
|
+
indexedFiles.add(rule.path);
|
|
1654
|
+
if (!existsSync7(absPath)) {
|
|
1655
|
+
issues.push({
|
|
1656
|
+
severity: "error",
|
|
1657
|
+
code: "MISSING_REF",
|
|
1658
|
+
message: `Rule file not found: ${rule.path}`,
|
|
1659
|
+
file: absPath
|
|
1660
|
+
});
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
const content = readFileSync8(absPath, "utf8");
|
|
1664
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
1665
|
+
if (!frontmatter) {
|
|
1666
|
+
issues.push({
|
|
1667
|
+
severity: "error",
|
|
1668
|
+
code: "MISSING_FRONTMATTER",
|
|
1669
|
+
message: `Rule file has no valid frontmatter: ${rule.path}`,
|
|
1670
|
+
file: absPath
|
|
1671
|
+
});
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (frontmatter.id !== rule.id) {
|
|
1675
|
+
issues.push({
|
|
1676
|
+
severity: "error",
|
|
1677
|
+
code: "DRIFT_ID",
|
|
1678
|
+
message: `ID mismatch: index says "${rule.id}" but frontmatter says "${frontmatter.id}" in ${rule.path}`,
|
|
1679
|
+
file: absPath,
|
|
1680
|
+
line: frontmatterKeyLine(content, "id"),
|
|
1681
|
+
hint: FIX_HINTS.DRIFT_ID
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
if (frontmatter.priority !== rule.priority) {
|
|
1685
|
+
issues.push({
|
|
1686
|
+
severity: "warning",
|
|
1687
|
+
code: "DRIFT_PRIORITY",
|
|
1688
|
+
message: `Priority mismatch: index says "${rule.priority}" but frontmatter says "${frontmatter.priority}" in ${rule.path}`,
|
|
1689
|
+
file: absPath,
|
|
1690
|
+
line: frontmatterKeyLine(content, "priority"),
|
|
1691
|
+
hint: FIX_HINTS.DRIFT_PRIORITY
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
const indexKw = new Set(rule.keywords);
|
|
1695
|
+
const fmKw = new Set(frontmatter.keywords);
|
|
1696
|
+
if (indexKw.size !== fmKw.size || ![...indexKw].every((k) => fmKw.has(k))) {
|
|
1697
|
+
issues.push({
|
|
1698
|
+
severity: "warning",
|
|
1699
|
+
code: "DRIFT_KEYWORDS",
|
|
1700
|
+
message: `Keywords mismatch in ${rule.path}: index has [${rule.keywords.join(", ")}], frontmatter has [${frontmatter.keywords.join(", ")}]`,
|
|
1701
|
+
file: absPath,
|
|
1702
|
+
line: frontmatterKeyLine(content, "keywords"),
|
|
1703
|
+
hint: FIX_HINTS.DRIFT_KEYWORDS
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
if (!rule.summary || rule.summary.length === 0) {
|
|
1707
|
+
issues.push({
|
|
1708
|
+
severity: "error",
|
|
1709
|
+
code: "MISSING_SUMMARY",
|
|
1710
|
+
message: `Rule "${rule.id}" has no summary`,
|
|
1711
|
+
file: absPath
|
|
1712
|
+
});
|
|
1713
|
+
} else if (rule.summary.length > 120) {
|
|
1714
|
+
issues.push({
|
|
1715
|
+
severity: "warning",
|
|
1716
|
+
code: "LONG_SUMMARY",
|
|
1717
|
+
message: `Rule "${rule.id}" summary exceeds 120 chars (${rule.summary.length})`,
|
|
1718
|
+
file: absPath
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
if (rule.priority === "domain" && rule.keywords.length === 0) {
|
|
1722
|
+
issues.push({
|
|
1723
|
+
severity: "error",
|
|
1724
|
+
code: "EMPTY_KEYWORDS",
|
|
1725
|
+
message: `Domain rule "${rule.id}" has no keywords \u2014 agent cannot route to it`,
|
|
1726
|
+
file: absPath
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
if (existsSync7(absRulesDir)) {
|
|
1731
|
+
const files = readdirSync2(absRulesDir);
|
|
1732
|
+
for (const file of files) {
|
|
1733
|
+
if (!file.endsWith(".md"))
|
|
1734
|
+
continue;
|
|
1735
|
+
const relativePath = `${rulesDir}/${file}`;
|
|
1736
|
+
if (!indexedFiles.has(relativePath)) {
|
|
1737
|
+
issues.push({
|
|
1738
|
+
severity: "warning",
|
|
1739
|
+
code: "ORPHAN_FILE",
|
|
1740
|
+
message: `Rule file exists but not in index.json: ${relativePath}`,
|
|
1741
|
+
file: join4(absRulesDir, file),
|
|
1742
|
+
hint: FIX_HINTS.ORPHAN_FILE
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
const ids = index.entries.map((r) => r.id);
|
|
1748
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1749
|
+
for (const id of ids) {
|
|
1750
|
+
if (seen.has(id)) {
|
|
1751
|
+
issues.push({
|
|
1752
|
+
severity: "error",
|
|
1753
|
+
code: "DUPLICATE_ID",
|
|
1754
|
+
message: `Duplicate rule ID: "${id}"`,
|
|
1755
|
+
hint: FIX_HINTS.DUPLICATE_ID
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
seen.add(id);
|
|
1759
|
+
}
|
|
1760
|
+
const kwMap = /* @__PURE__ */ new Map();
|
|
1761
|
+
for (const rule of index.entries) {
|
|
1762
|
+
for (const kw of rule.keywords) {
|
|
1763
|
+
const existing = kwMap.get(kw) ?? [];
|
|
1764
|
+
existing.push(rule.id);
|
|
1765
|
+
kwMap.set(kw, existing);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
for (const [kw, ruleIds] of kwMap) {
|
|
1769
|
+
if (ruleIds.length > 1) {
|
|
1770
|
+
issues.push({
|
|
1771
|
+
severity: "warning",
|
|
1772
|
+
code: "DUPLICATE_KEYWORD",
|
|
1773
|
+
message: `Keyword "${kw}" appears in multiple rules: ${ruleIds.join(", ")}`
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return issues;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// src/commands.ts
|
|
1781
|
+
function requireFile(path, what) {
|
|
1782
|
+
const abs = resolve7(path);
|
|
1783
|
+
if (!existsSync8(abs)) {
|
|
1784
|
+
fail("FILE_NOT_FOUND", `${what} not found: ${path}`);
|
|
1785
|
+
}
|
|
1786
|
+
try {
|
|
1787
|
+
if (!statSync2(abs).isFile()) {
|
|
1788
|
+
fail("NOT_A_FILE", `Not a file: ${path}`, `Point at a ${what} file, not a directory.`);
|
|
1789
|
+
}
|
|
1790
|
+
} catch {
|
|
1791
|
+
fail("FILE_NOT_FOUND", `${what} not found: ${path}`);
|
|
1792
|
+
}
|
|
1793
|
+
return abs;
|
|
1794
|
+
}
|
|
1795
|
+
function loadKernelIndex(path) {
|
|
1796
|
+
const abs = requireFile(path, "index");
|
|
1797
|
+
let parsed;
|
|
1798
|
+
try {
|
|
1799
|
+
parsed = JSON.parse(readFileSync9(abs, "utf-8"));
|
|
1800
|
+
} catch (e) {
|
|
1801
|
+
fail("PARSE_ERROR", `Failed to parse index: ${path}`, e.message);
|
|
1802
|
+
}
|
|
1803
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {
|
|
1804
|
+
fail(
|
|
1805
|
+
"INVALID_INDEX",
|
|
1806
|
+
`File is valid JSON but not a loadout index: ${path}`,
|
|
1807
|
+
"Expected an object with an 'entries' array. Run 'loadout-os validate <index>' for details."
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
return parsed;
|
|
1811
|
+
}
|
|
1812
|
+
function memoriesPath(args) {
|
|
1813
|
+
const pos = positionalArgs(args);
|
|
1814
|
+
if (pos.length === 0) {
|
|
1815
|
+
fail(
|
|
1816
|
+
"MISSING_ARG",
|
|
1817
|
+
"A path to MEMORY.md is required",
|
|
1818
|
+
"Usage: loadout-os memories <index|validate|stats|health> <MEMORY.md>"
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
return requireFile(pos[0], "MEMORY.md");
|
|
1822
|
+
}
|
|
1823
|
+
function memoriesIndex(args) {
|
|
1824
|
+
const file = memoriesPath(args);
|
|
1825
|
+
const json = hasFlag(args, "json");
|
|
1826
|
+
const lazyLoad = hasFlag(args, "lazy");
|
|
1827
|
+
const analysis = analyzeMemoryMd(file);
|
|
1828
|
+
const index = generateIndex(analysis, { lazyLoad });
|
|
1829
|
+
const issues = validateMemoryIndex(index);
|
|
1830
|
+
if (json) {
|
|
1831
|
+
log(JSON.stringify({ index, issues, missingFiles: analysis.missingFiles }, null, 2));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
log(`
|
|
1835
|
+
${BOLD}Memory index${RESET} from ${file}
|
|
1836
|
+
`);
|
|
1837
|
+
ok(`${index.entries.length} entries`);
|
|
1838
|
+
if (analysis.missingFiles.length > 0) {
|
|
1839
|
+
warn(`${analysis.missingFiles.length} unresolved ref(s) skipped \u2014 run \`memories validate\` for detail`);
|
|
1840
|
+
}
|
|
1841
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
1842
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
1843
|
+
for (const i of errors) warn(`[${i.code}] ${i.message}`);
|
|
1844
|
+
for (const i of warnings) info(`[${i.code}] ${i.message}`);
|
|
1845
|
+
log(`
|
|
1846
|
+
${BOLD}Budget:${RESET}`);
|
|
1847
|
+
log(` Always loaded: ${index.budget.always_loaded_est.toLocaleString()} tokens`);
|
|
1848
|
+
log(` On-demand total: ${index.budget.on_demand_total_est.toLocaleString()} tokens`);
|
|
1849
|
+
log(` Avg task load: ${index.budget.avg_task_load_est.toLocaleString()} tokens`);
|
|
1850
|
+
if (lazyLoad) info("Lazy loading enabled");
|
|
1851
|
+
log("");
|
|
1852
|
+
}
|
|
1853
|
+
function memoriesValidate(args) {
|
|
1854
|
+
const file = memoriesPath(args);
|
|
1855
|
+
const json = hasFlag(args, "json");
|
|
1856
|
+
const analysis = analyzeMemoryMd(file);
|
|
1857
|
+
const memoryIssues = validateMemory(analysis);
|
|
1858
|
+
const index = generateIndex(analysis);
|
|
1859
|
+
const indexIssues = validateMemoryIndex(index);
|
|
1860
|
+
const all = [...memoryIssues, ...indexIssues];
|
|
1861
|
+
const errors = all.filter((i) => i.severity === "error");
|
|
1862
|
+
const warnings = all.filter((i) => i.severity === "warning");
|
|
1863
|
+
if (json) {
|
|
1864
|
+
log(
|
|
1865
|
+
JSON.stringify(
|
|
1866
|
+
{ valid: errors.length === 0, errors: errors.length, warnings: warnings.length, issues: all },
|
|
1867
|
+
null,
|
|
1868
|
+
2
|
|
1869
|
+
)
|
|
1870
|
+
);
|
|
1871
|
+
if (errors.length > 0) fail("VALIDATION_FAILED", `${errors.length} error(s)`, void 0, 1);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
log(`
|
|
1875
|
+
${BOLD}Validating${RESET} ${file}
|
|
1876
|
+
`);
|
|
1877
|
+
if (errors.length > 0) {
|
|
1878
|
+
log(`${RED}${BOLD}Errors:${RESET}`);
|
|
1879
|
+
for (const i of errors) {
|
|
1880
|
+
log(` ${RED}\u2717${RESET} [${i.code}] ${i.message}`);
|
|
1881
|
+
if (i.hint) log(` ${DIM}${i.hint}${RESET}`);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (warnings.length > 0) {
|
|
1885
|
+
log(`${YELLOW}${BOLD}Warnings:${RESET}`);
|
|
1886
|
+
for (const i of warnings) {
|
|
1887
|
+
log(` ${YELLOW}!${RESET} [${i.code}] ${i.message}`);
|
|
1888
|
+
if (i.hint) log(` ${DIM}${i.hint}${RESET}`);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (all.length === 0) ok("No issues found");
|
|
1892
|
+
log(`
|
|
1893
|
+
${errors.length} errors, ${warnings.length} warnings
|
|
1894
|
+
`);
|
|
1895
|
+
if (errors.length > 0) fail("VALIDATION_FAILED", `${errors.length} error(s)`, void 0, 1);
|
|
1896
|
+
}
|
|
1897
|
+
function memoriesStats(args) {
|
|
1898
|
+
const file = memoriesPath(args);
|
|
1899
|
+
const json = hasFlag(args, "json");
|
|
1900
|
+
const analysis = analyzeMemoryMd(file);
|
|
1901
|
+
const index = generateIndex(analysis);
|
|
1902
|
+
const stats = generateStats(analysis, index);
|
|
1903
|
+
if (json) {
|
|
1904
|
+
log(JSON.stringify(stats, null, 2));
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
log("");
|
|
1908
|
+
log(formatStats(stats));
|
|
1909
|
+
log("");
|
|
1910
|
+
}
|
|
1911
|
+
function memoriesHealth(args) {
|
|
1912
|
+
const json = hasFlag(args, "json");
|
|
1913
|
+
const nodeMajor = parseInt(process.version.slice(1), 10);
|
|
1914
|
+
const nodeOk = nodeMajor >= 20;
|
|
1915
|
+
const pos = positionalArgs(args);
|
|
1916
|
+
const checked = [];
|
|
1917
|
+
if (pos.length > 0) {
|
|
1918
|
+
const abs = resolve7(pos[0]);
|
|
1919
|
+
checked.push({ label: "given path", path: abs, found: existsSync8(abs) });
|
|
1920
|
+
} else {
|
|
1921
|
+
for (const c of ["MEMORY.md", ".claude/MEMORY.md"]) {
|
|
1922
|
+
const abs = resolve7(c);
|
|
1923
|
+
checked.push({ label: c, path: abs, found: existsSync8(abs) });
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
const found = checked.some((c) => c.found);
|
|
1927
|
+
if (json) {
|
|
1928
|
+
log(JSON.stringify({ nodeVersion: process.version, nodeOk, checked, found }, null, 2));
|
|
1929
|
+
if (!nodeOk) fail("NODE_TOO_OLD", `Node ${process.version} < 20`, void 0, 1);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
log(`
|
|
1933
|
+
${BOLD}loadout-os memories health${RESET}`);
|
|
1934
|
+
log(` Node.js: ${process.version} ${nodeOk ? `${GREEN}(OK)${RESET}` : `${RED}(requires >=20)${RESET}`}`);
|
|
1935
|
+
log(` Platform: ${process.platform} ${process.arch}`);
|
|
1936
|
+
log(`
|
|
1937
|
+
${BOLD}MEMORY.md detection:${RESET}`);
|
|
1938
|
+
for (const c of checked) {
|
|
1939
|
+
if (c.found) ok(`${c.label} \u2192 ${c.path}`);
|
|
1940
|
+
else info(`${c.label} \u2014 not found`);
|
|
1941
|
+
}
|
|
1942
|
+
if (!found) warn("No MEMORY.md detected. Provide a path when running commands.");
|
|
1943
|
+
log("");
|
|
1944
|
+
if (!nodeOk) fail("NODE_TOO_OLD", `Node ${process.version} < 20`, void 0, 1);
|
|
1945
|
+
}
|
|
1946
|
+
function rulesAnalyze(args) {
|
|
1947
|
+
const pos = positionalArgs(args);
|
|
1948
|
+
if (pos.length === 0) {
|
|
1949
|
+
fail(
|
|
1950
|
+
"MISSING_ARG",
|
|
1951
|
+
"A path to CLAUDE.md is required",
|
|
1952
|
+
"Usage: loadout-os rules analyze <CLAUDE.md> [--rules-dir <dir>]"
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
const file = requireFile(pos[0], "CLAUDE.md");
|
|
1956
|
+
const rulesDir = flagValue(args, "rules-dir") ?? ".claude/rules";
|
|
1957
|
+
const json = hasFlag(args, "json");
|
|
1958
|
+
const report = analyzeFile(file, rulesDir);
|
|
1959
|
+
if (json) {
|
|
1960
|
+
log(JSON.stringify(report, null, 2));
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
log(`
|
|
1964
|
+
${BOLD}Analyzing${RESET} ${file} ${DIM}(${report.totalLines} lines, ~${report.totalTokens} tokens)${RESET}
|
|
1965
|
+
`);
|
|
1966
|
+
log(`${BOLD}Sections:${RESET} ${report.sections.length}`);
|
|
1967
|
+
log(`${BOLD}Keep inline (core):${RESET} ${report.coreCandidate.length}`);
|
|
1968
|
+
log(`${BOLD}Proposed extractions:${RESET} ${report.proposals.length}`);
|
|
1969
|
+
for (const p of report.proposals) {
|
|
1970
|
+
log(` ${CYAN}${p.suggestedId}${RESET} \u2192 ${p.suggestedPath} ${DIM}[${p.suggestedPriority}]${RESET}`);
|
|
1971
|
+
log(` ${DIM}${p.reason}${RESET}`);
|
|
1972
|
+
}
|
|
1973
|
+
log("");
|
|
1974
|
+
}
|
|
1975
|
+
function rulesValidate(args) {
|
|
1976
|
+
const json = hasFlag(args, "json");
|
|
1977
|
+
const lazy = hasFlag(args, "lazy");
|
|
1978
|
+
const rulesDir = flagValue(args, "rules-dir") ?? (lazy ? ".claude/loadout" : ".claude/rules");
|
|
1979
|
+
const repoRoot = flagValue(args, "repo-root") ?? process.cwd();
|
|
1980
|
+
const issues = validateRules(rulesDir, resolve7(repoRoot));
|
|
1981
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
1982
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
1983
|
+
if (json) {
|
|
1984
|
+
log(
|
|
1985
|
+
JSON.stringify(
|
|
1986
|
+
{ valid: errors.length === 0, errors: errors.length, warnings: warnings.length, issues },
|
|
1987
|
+
null,
|
|
1988
|
+
2
|
|
1989
|
+
)
|
|
1990
|
+
);
|
|
1991
|
+
if (errors.length > 0) fail("VALIDATION_FAILED", `${errors.length} error(s)`, void 0, 1);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
log(`
|
|
1995
|
+
${BOLD}Validating${RESET} ${rulesDir}/
|
|
1996
|
+
`);
|
|
1997
|
+
if (issues.length === 0) {
|
|
1998
|
+
ok("All rules valid. No issues found.");
|
|
1999
|
+
log("");
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
for (const i of errors) {
|
|
2003
|
+
log(` ${RED}error${RESET} [${i.code}] ${i.message}`);
|
|
2004
|
+
if (i.hint) log(` ${DIM}fix: ${i.hint}${RESET}`);
|
|
2005
|
+
}
|
|
2006
|
+
for (const i of warnings) {
|
|
2007
|
+
log(` ${YELLOW}warn${RESET} [${i.code}] ${i.message}`);
|
|
2008
|
+
if (i.hint) log(` ${DIM}fix: ${i.hint}${RESET}`);
|
|
2009
|
+
}
|
|
2010
|
+
log(`
|
|
2011
|
+
${errors.length} error(s), ${warnings.length} warning(s)
|
|
2012
|
+
`);
|
|
2013
|
+
if (errors.length > 0) fail("VALIDATION_FAILED", `${errors.length} error(s)`, void 0, 1);
|
|
2014
|
+
}
|
|
2015
|
+
function rulesStats(args) {
|
|
2016
|
+
const pos = positionalArgs(args);
|
|
2017
|
+
if (pos.length === 0) {
|
|
2018
|
+
fail(
|
|
2019
|
+
"MISSING_ARG",
|
|
2020
|
+
"A path to CLAUDE.md is required",
|
|
2021
|
+
"Usage: loadout-os rules stats <CLAUDE.md> [--rules-dir <dir>]"
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
const file = requireFile(pos[0], "CLAUDE.md");
|
|
2025
|
+
const rulesDir = flagValue(args, "rules-dir") ?? ".claude/rules";
|
|
2026
|
+
const json = hasFlag(args, "json");
|
|
2027
|
+
const report = analyzeFile(file, rulesDir);
|
|
2028
|
+
const coreTokens = report.coreCandidate.reduce((s, c) => s + c.tokens_est, 0);
|
|
2029
|
+
const extractTokens = report.proposals.reduce((s, p) => s + p.section.tokens_est, 0);
|
|
2030
|
+
const savingsPct = report.totalTokens > 0 ? Math.round(extractTokens / report.totalTokens * 100) : 0;
|
|
2031
|
+
const stats = {
|
|
2032
|
+
file: report.filePath,
|
|
2033
|
+
totalLines: report.totalLines,
|
|
2034
|
+
totalTokens: report.totalTokens,
|
|
2035
|
+
sections: report.sections.length,
|
|
2036
|
+
coreSections: report.coreCandidate.length,
|
|
2037
|
+
proposedExtractions: report.proposals.length,
|
|
2038
|
+
alwaysLoadedTokens: coreTokens,
|
|
2039
|
+
onDemandTokens: extractTokens,
|
|
2040
|
+
savingsPercent: savingsPct
|
|
2041
|
+
};
|
|
2042
|
+
if (json) {
|
|
2043
|
+
log(JSON.stringify(stats, null, 2));
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
log(`
|
|
2047
|
+
${BOLD}Rules budget${RESET} for ${report.filePath}
|
|
2048
|
+
`);
|
|
2049
|
+
log(` Sections: ${stats.sections} (${stats.coreSections} core, ${stats.proposedExtractions} extractable)`);
|
|
2050
|
+
log(` Total: ${stats.totalLines} lines, ~${stats.totalTokens} tokens`);
|
|
2051
|
+
log(` Always loaded: ~${coreTokens} tokens`);
|
|
2052
|
+
log(` On-demand: ~${extractTokens} tokens`);
|
|
2053
|
+
log(` ${BOLD}Savings: ${savingsPct}% per session${RESET}`);
|
|
2054
|
+
log("");
|
|
2055
|
+
}
|
|
2056
|
+
function confirm(question) {
|
|
2057
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2058
|
+
return new Promise((res) => {
|
|
2059
|
+
rl.question(question, (answer) => {
|
|
2060
|
+
rl.close();
|
|
2061
|
+
res(answer.trim());
|
|
2062
|
+
});
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
function rulesSplit(args) {
|
|
2066
|
+
void runSplit(args).catch((err) => {
|
|
2067
|
+
fail("RULES_SPLIT_FAILED", `split failed: ${err.message}`);
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
async function runSplit(args) {
|
|
2071
|
+
const filePath = resolveClaudeMd(positionalArgs(args));
|
|
2072
|
+
const lazyLoad = hasFlag(args, "lazy");
|
|
2073
|
+
const rulesDir = flagValue(args, "rules-dir") ?? (lazyLoad ? ".claude/loadout" : ".claude/rules");
|
|
2074
|
+
const dryRun = hasFlag(args, "dry-run");
|
|
2075
|
+
const yesMode = hasFlag(args, "yes");
|
|
2076
|
+
const signals = loadSignals(flagValue(args, "signals") ?? void 0);
|
|
2077
|
+
if (!existsSync8(filePath)) {
|
|
2078
|
+
fail("FILE_NOT_FOUND", `CLAUDE.md not found: ${filePath}`, "Provide a path to your CLAUDE.md.");
|
|
2079
|
+
}
|
|
2080
|
+
info(`Analyzing ${CYAN}${filePath}${RESET}`);
|
|
2081
|
+
const report = analyzeFile(filePath, rulesDir, signals);
|
|
2082
|
+
if (hasFlag(args, "memory")) {
|
|
2083
|
+
const memoryPath = resolveMemoryMd();
|
|
2084
|
+
if (memoryPath) {
|
|
2085
|
+
info(`Also analyzing ${CYAN}${memoryPath}${RESET}`);
|
|
2086
|
+
report.proposals.push(...analyzeFile(memoryPath, rulesDir, signals).proposals);
|
|
2087
|
+
} else {
|
|
2088
|
+
warn("No MEMORY.md found. Skipping --memory.");
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
if (report.proposals.length === 0) {
|
|
2092
|
+
ok("Nothing to split \u2014 all sections are already lean enough.");
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
log("");
|
|
2096
|
+
log(
|
|
2097
|
+
`${BOLD}Found ${report.proposals.length} sections to extract${RESET} ${DIM}(${report.totalLines} lines, ~${report.totalTokens} tokens)${RESET}`
|
|
2098
|
+
);
|
|
2099
|
+
log("");
|
|
2100
|
+
const accepted = [];
|
|
2101
|
+
for (let i = 0; i < report.proposals.length; i++) {
|
|
2102
|
+
const p = report.proposals[i];
|
|
2103
|
+
log(`${BOLD}${i + 1}/${report.proposals.length}. "${p.section.heading}"${RESET}`);
|
|
2104
|
+
log(` ${DIM}Lines ${p.section.startLine + 1}-${p.section.endLine} (${p.section.lines} lines, ~${p.section.tokens_est} tokens)${RESET}`);
|
|
2105
|
+
log(` \u2192 ${CYAN}${p.suggestedPath}${RESET}`);
|
|
2106
|
+
log(` keywords: [${p.suggestedKeywords.join(", ")}]`);
|
|
2107
|
+
if (p.suggestedPatterns.length > 0) log(` patterns: [${p.suggestedPatterns.join(", ")}]`);
|
|
2108
|
+
log(` priority: ${p.suggestedPriority}`);
|
|
2109
|
+
log(` summary: ${p.suggestedSummary}`);
|
|
2110
|
+
log("");
|
|
2111
|
+
if (dryRun) {
|
|
2112
|
+
info("(dry run \u2014 would extract)");
|
|
2113
|
+
accepted.push(p);
|
|
2114
|
+
log("");
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
if (yesMode) {
|
|
2118
|
+
accepted.push(p);
|
|
2119
|
+
ok(`Accepted "${p.section.heading}"`);
|
|
2120
|
+
log("");
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
const answer = (await confirm(` Extract? [${GREEN}Y${RESET}/n/skip] `)).toLowerCase();
|
|
2124
|
+
if (answer === "n" || answer === "skip") {
|
|
2125
|
+
warn(`Skipped "${p.section.heading}"`);
|
|
2126
|
+
} else {
|
|
2127
|
+
accepted.push(p);
|
|
2128
|
+
ok(`Accepted "${p.section.heading}"`);
|
|
2129
|
+
}
|
|
2130
|
+
log("");
|
|
2131
|
+
}
|
|
2132
|
+
if (accepted.length === 0) {
|
|
2133
|
+
info("No sections accepted. Nothing to do.");
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
const coreTokens = report.coreCandidate.reduce((sum, s) => sum + s.tokens_est, 0);
|
|
2137
|
+
const index = generateIndex2(accepted, coreTokens, lazyLoad);
|
|
2138
|
+
const newClaudeMd = generateClaudeMd(report.coreCandidate, accepted, index, rulesDir, lazyLoad);
|
|
2139
|
+
if (dryRun) {
|
|
2140
|
+
log(`${BOLD}Dry run complete.${RESET} Would generate:`);
|
|
2141
|
+
log(` ${accepted.length} rule files in ${rulesDir}/`);
|
|
2142
|
+
log(` ${rulesDir}/index.json`);
|
|
2143
|
+
log(` Rewritten ${filePath}`);
|
|
2144
|
+
log("");
|
|
2145
|
+
log(`${BOLD}New CLAUDE.md preview:${RESET}`);
|
|
2146
|
+
log(DIM + "\u2500".repeat(60) + RESET);
|
|
2147
|
+
log(newClaudeMd);
|
|
2148
|
+
log(DIM + "\u2500".repeat(60) + RESET);
|
|
2149
|
+
log("");
|
|
2150
|
+
const after2 = estimateTokens(newClaudeMd);
|
|
2151
|
+
log(`${BOLD}Budget:${RESET}`);
|
|
2152
|
+
log(` Before: ${report.totalLines} lines, ~${report.totalTokens} tokens (every session)`);
|
|
2153
|
+
log(` After: ~${after2} tokens always loaded`);
|
|
2154
|
+
log(` Savings: ${report.totalTokens > 0 ? Math.round((report.totalTokens - after2) / report.totalTokens * 100) : 0}%`);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const absRulesDir = resolve7(dirname5(filePath), "..", rulesDir);
|
|
2158
|
+
const writes = [];
|
|
2159
|
+
for (const p of accepted) {
|
|
2160
|
+
writes.push({ dest: resolve7(dirname5(filePath), "..", p.suggestedPath), content: generateRuleFile(p) });
|
|
2161
|
+
}
|
|
2162
|
+
writes.push({ dest: resolve7(absRulesDir, "index.json"), content: JSON.stringify(index, null, 2) + "\n" });
|
|
2163
|
+
writes.push({ dest: filePath, content: newClaudeMd });
|
|
2164
|
+
const stagingDir = mkdtempSync(join5(tmpdir(), "loadout-os-split-"));
|
|
2165
|
+
try {
|
|
2166
|
+
for (let i = 0; i < writes.length; i++) {
|
|
2167
|
+
writeFileSync2(join5(stagingDir, `file-${i}`), writes[i].content, "utf8");
|
|
2168
|
+
}
|
|
2169
|
+
const backupPath = resolve7(dirname5(filePath), "CLAUDE.md.bak");
|
|
2170
|
+
copyFileSync(filePath, backupPath);
|
|
2171
|
+
info(`Backed up ${relative2(process.cwd(), filePath)} \u2192 CLAUDE.md.bak`);
|
|
2172
|
+
mkdirSync2(absRulesDir, { recursive: true });
|
|
2173
|
+
for (const w of writes) mkdirSync2(dirname5(w.dest), { recursive: true });
|
|
2174
|
+
for (let i = 0; i < writes.length; i++) {
|
|
2175
|
+
try {
|
|
2176
|
+
copyFileSync(join5(stagingDir, `file-${i}`), writes[i].dest);
|
|
2177
|
+
} catch (copyErr) {
|
|
2178
|
+
warn(`Split failed writing ${relative2(process.cwd(), writes[i].dest)}. Original CLAUDE.md backed up to CLAUDE.md.bak.`);
|
|
2179
|
+
warn(`Error: ${copyErr.message}`);
|
|
2180
|
+
process.exitCode = 1;
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
for (const p of accepted) ok(`${p.suggestedPath}`);
|
|
2185
|
+
ok(`${rulesDir}/index.json`);
|
|
2186
|
+
ok(`Rewrote ${relative2(process.cwd(), filePath)}`);
|
|
2187
|
+
} finally {
|
|
2188
|
+
try {
|
|
2189
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
2190
|
+
} catch {
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
const after = estimateTokens(newClaudeMd);
|
|
2194
|
+
log("");
|
|
2195
|
+
log(`${BOLD}Split complete.${RESET}`);
|
|
2196
|
+
log(` ${accepted.length} rule files created`);
|
|
2197
|
+
log(` Before: ${report.totalLines} lines, ~${report.totalTokens} tokens`);
|
|
2198
|
+
log(` After: ~${after} tokens always loaded`);
|
|
2199
|
+
log(` Savings: ${report.totalTokens > 0 ? Math.round((report.totalTokens - after) / report.totalTokens * 100) : 0}%`);
|
|
2200
|
+
log("");
|
|
2201
|
+
info("Run 'loadout-os rules validate' to confirm invariants.");
|
|
2202
|
+
}
|
|
2203
|
+
function kernelResolve(args) {
|
|
2204
|
+
const json = hasFlag(args, "json");
|
|
2205
|
+
const result = resolveLoadout({
|
|
2206
|
+
projectRoot: flagValue(args, "project"),
|
|
2207
|
+
globalDir: flagValue(args, "global"),
|
|
2208
|
+
orgPath: flagValue(args, "org"),
|
|
2209
|
+
sessionPath: flagValue(args, "session")
|
|
2210
|
+
});
|
|
2211
|
+
if (json) {
|
|
2212
|
+
log(
|
|
2213
|
+
JSON.stringify(
|
|
2214
|
+
{
|
|
2215
|
+
layers: result.searched,
|
|
2216
|
+
entries: result.merged.entries.map((e) => ({
|
|
2217
|
+
id: e.id,
|
|
2218
|
+
priority: e.priority,
|
|
2219
|
+
tokens: e.tokens_est,
|
|
2220
|
+
source: result.merged.provenance[e.id]
|
|
2221
|
+
})),
|
|
2222
|
+
conflicts: result.merged.conflicts,
|
|
2223
|
+
budget: result.merged.budget
|
|
2224
|
+
},
|
|
2225
|
+
null,
|
|
2226
|
+
2
|
|
2227
|
+
)
|
|
2228
|
+
);
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
log(`
|
|
2232
|
+
${BOLD}Layer Discovery${RESET}
|
|
2233
|
+
`);
|
|
2234
|
+
for (const s of result.searched) {
|
|
2235
|
+
if (s.found) ok(`${s.name.padEnd(10)} ${DIM}${s.path}${RESET}`);
|
|
2236
|
+
else if (s.malformed) warn(`${s.name.padEnd(10)} ${DIM}${s.path}${RESET} ${YELLOW}(malformed JSON \u2014 skipped)${RESET}`);
|
|
2237
|
+
else log(` ${DIM}\u2014${RESET} ${s.name.padEnd(10)} ${DIM}${s.path} (not found)${RESET}`);
|
|
2238
|
+
}
|
|
2239
|
+
if (result.layers.length === 0) {
|
|
2240
|
+
log(`
|
|
2241
|
+
${YELLOW}No loadout indexes found.${RESET}
|
|
2242
|
+
`);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
log(`
|
|
2246
|
+
${BOLD}Resolved Entries${RESET} (${result.merged.entries.length} from ${result.layers.length} layer(s))
|
|
2247
|
+
`);
|
|
2248
|
+
for (const e of result.merged.entries) {
|
|
2249
|
+
const src = result.merged.provenance[e.id] ?? "?";
|
|
2250
|
+
log(` ${e.id.padEnd(30)} ${e.priority.padEnd(8)} ${String(e.tokens_est).padStart(6)} ${src}`);
|
|
2251
|
+
}
|
|
2252
|
+
log("");
|
|
2253
|
+
}
|
|
2254
|
+
function kernelExplain(args) {
|
|
2255
|
+
const pos = positionalArgs(args);
|
|
2256
|
+
if (pos.length === 0) {
|
|
2257
|
+
fail("MISSING_ARG", "Usage: loadout-os explain <entry-id>", "Run 'loadout-os resolve' to see entries.");
|
|
2258
|
+
}
|
|
2259
|
+
const entryId = pos[0];
|
|
2260
|
+
const json = hasFlag(args, "json");
|
|
2261
|
+
const { layers } = resolveLoadout({
|
|
2262
|
+
projectRoot: flagValue(args, "project"),
|
|
2263
|
+
globalDir: flagValue(args, "global"),
|
|
2264
|
+
orgPath: flagValue(args, "org"),
|
|
2265
|
+
sessionPath: flagValue(args, "session")
|
|
2266
|
+
});
|
|
2267
|
+
const explanation = explainEntry(entryId, layers);
|
|
2268
|
+
if (!explanation) {
|
|
2269
|
+
if (json) {
|
|
2270
|
+
log(JSON.stringify({ error: "NOT_FOUND", entryId }, null, 2));
|
|
2271
|
+
fail("NOT_FOUND", `Entry "${entryId}" not found in any layer`, void 0, 1);
|
|
2272
|
+
}
|
|
2273
|
+
fail("NOT_FOUND", `Entry "${entryId}" not found in any layer`, "Run 'loadout-os resolve' to see entries.");
|
|
2274
|
+
}
|
|
2275
|
+
if (json) {
|
|
2276
|
+
log(JSON.stringify(explanation, null, 2));
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
log(`
|
|
2280
|
+
${BOLD}Entry Explanation: ${CYAN}${explanation.id}${RESET}
|
|
2281
|
+
`);
|
|
2282
|
+
log(` Final layer: ${GREEN}${explanation.finalLayer}${RESET}`);
|
|
2283
|
+
log(` Override chain: ${explanation.overrideChain.join(" \u2192 ")}`);
|
|
2284
|
+
for (const def of explanation.definitions) {
|
|
2285
|
+
log(` ${def.layer}: ${def.priority}, ${def.tokens} tokens \u2014 ${def.summary}`);
|
|
2286
|
+
}
|
|
2287
|
+
log("");
|
|
2288
|
+
}
|
|
2289
|
+
function kernelUsage(args) {
|
|
2290
|
+
const pos = positionalArgs(args);
|
|
2291
|
+
if (pos.length < 1) fail("MISSING_ARG", "Usage: loadout-os usage <jsonl>");
|
|
2292
|
+
const jsonl = resolve7(pos[0]);
|
|
2293
|
+
const json = hasFlag(args, "json");
|
|
2294
|
+
const { events, skipped } = readUsageWithStats(jsonl);
|
|
2295
|
+
if (events.length === 0) {
|
|
2296
|
+
if (json) {
|
|
2297
|
+
log("[]");
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
info("No usage events found");
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
const summary = summarizeUsage(events);
|
|
2304
|
+
if (json) {
|
|
2305
|
+
log(JSON.stringify(summary.map(summaryToJSON), null, 2));
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
if (skipped > 0) warn(`Skipped ${skipped} malformed line(s)`);
|
|
2309
|
+
log(`
|
|
2310
|
+
${BOLD}Usage Summary${RESET} (${events.length} events)
|
|
2311
|
+
`);
|
|
2312
|
+
log(` ${"Entry".padEnd(30)} ${"Loads".padStart(6)} ${"Tokens".padStart(8)}`);
|
|
2313
|
+
for (const s of summary) {
|
|
2314
|
+
log(` ${s.entryId.padEnd(30)} ${String(s.loadCount).padStart(6)} ${String(s.totalTokens).padStart(8)}`);
|
|
2315
|
+
}
|
|
2316
|
+
log("");
|
|
2317
|
+
}
|
|
2318
|
+
function kernelDead(args) {
|
|
2319
|
+
const pos = positionalArgs(args);
|
|
2320
|
+
if (pos.length < 2) fail("MISSING_ARG", "Usage: loadout-os dead <index> <jsonl>");
|
|
2321
|
+
const index = loadKernelIndex(pos[0]);
|
|
2322
|
+
const events = readUsage(resolve7(pos[1]));
|
|
2323
|
+
const dead = findDeadEntries(index, events);
|
|
2324
|
+
const json = hasFlag(args, "json");
|
|
2325
|
+
if (json) {
|
|
2326
|
+
log(JSON.stringify(dead.map((d) => ({ id: d.entry.id, tokens: d.entry.tokens_est, reason: d.reason })), null, 2));
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
if (dead.length === 0) {
|
|
2330
|
+
ok("No dead entries \u2014 all entries loaded at least once");
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
log(`
|
|
2334
|
+
${BOLD}Dead Entries${RESET} (${dead.length} never loaded)
|
|
2335
|
+
`);
|
|
2336
|
+
let wasted = 0;
|
|
2337
|
+
for (const d of dead) {
|
|
2338
|
+
warn(`${d.entry.id} (${d.entry.tokens_est} tokens)`);
|
|
2339
|
+
wasted += d.entry.tokens_est;
|
|
2340
|
+
}
|
|
2341
|
+
log(`
|
|
2342
|
+
${RED}${wasted.toLocaleString()} tokens${RESET} in entries never loaded
|
|
2343
|
+
`);
|
|
2344
|
+
}
|
|
2345
|
+
function kernelOverlaps(args) {
|
|
2346
|
+
const pos = positionalArgs(args);
|
|
2347
|
+
if (pos.length < 1) fail("MISSING_ARG", "Usage: loadout-os overlaps <index>");
|
|
2348
|
+
const index = loadKernelIndex(pos[0]);
|
|
2349
|
+
const overlaps = findKeywordOverlaps(index);
|
|
2350
|
+
const json = hasFlag(args, "json");
|
|
2351
|
+
if (json) {
|
|
2352
|
+
log(JSON.stringify(overlaps, null, 2));
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (overlaps.length === 0) {
|
|
2356
|
+
ok("No keyword overlaps \u2014 routing is unambiguous");
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
log(`
|
|
2360
|
+
${BOLD}Keyword Overlaps${RESET} (${overlaps.length})
|
|
2361
|
+
`);
|
|
2362
|
+
for (const o of overlaps) log(` ${YELLOW}${o.keyword}${RESET} \u2192 ${o.entries.join(", ")}`);
|
|
2363
|
+
log("");
|
|
2364
|
+
}
|
|
2365
|
+
function kernelBudget(args) {
|
|
2366
|
+
const pos = positionalArgs(args);
|
|
2367
|
+
if (pos.length < 1) fail("MISSING_ARG", "Usage: loadout-os budget <index> [jsonl]");
|
|
2368
|
+
const index = loadKernelIndex(pos[0]);
|
|
2369
|
+
const usage = pos.length >= 2 ? summarizeUsage(readUsage(resolve7(pos[1]))) : void 0;
|
|
2370
|
+
const breakdown = analyzeBudget(index, usage);
|
|
2371
|
+
const json = hasFlag(args, "json");
|
|
2372
|
+
if (json) {
|
|
2373
|
+
log(JSON.stringify(breakdown, null, 2));
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
log(`
|
|
2377
|
+
${BOLD}Token Budget Breakdown${RESET}
|
|
2378
|
+
`);
|
|
2379
|
+
log(` Total: ${breakdown.totalTokens.toLocaleString()} tokens`);
|
|
2380
|
+
log(` Core: ${breakdown.coreTokens.toLocaleString()} (${breakdown.coreEntries}) \xB7 Domain: ${breakdown.domainTokens.toLocaleString()} (${breakdown.domainEntries}) \xB7 Manual: ${breakdown.manualTokens.toLocaleString()} (${breakdown.manualEntries})`);
|
|
2381
|
+
if (breakdown.observedAvg !== null) log(` ${GREEN}Observed avg load:${RESET} ${breakdown.observedAvg.toLocaleString()} tokens/load`);
|
|
2382
|
+
log("");
|
|
2383
|
+
}
|
|
2384
|
+
function kernelValidate(args) {
|
|
2385
|
+
const pos = positionalArgs(args);
|
|
2386
|
+
if (pos.length < 1) fail("MISSING_ARG", "Usage: loadout-os validate <index>");
|
|
2387
|
+
const index = loadKernelIndex(pos[0]);
|
|
2388
|
+
const issues = validateIndex(index);
|
|
2389
|
+
const json = hasFlag(args, "json");
|
|
2390
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
2391
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
2392
|
+
if (json) {
|
|
2393
|
+
log(JSON.stringify({ valid: errors.length === 0, errors: errors.length, warnings: warnings.length, issues }, null, 2));
|
|
2394
|
+
if (errors.length > 0) fail("VALIDATION_FAILED", `${errors.length} error(s)`, void 0, 1);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
if (issues.length === 0) {
|
|
2398
|
+
ok(`Index is valid (${index.entries.length} entries)`);
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
log(`
|
|
2402
|
+
${BOLD}Validation Results${RESET}
|
|
2403
|
+
`);
|
|
2404
|
+
for (const i of errors) {
|
|
2405
|
+
log(` ${RED}\u2717 [${i.code}]${RESET} ${i.message}`);
|
|
2406
|
+
if (i.hint) log(` ${DIM}${i.hint}${RESET}`);
|
|
2407
|
+
}
|
|
2408
|
+
for (const i of warnings) warn(`[${i.code}] ${i.message}`);
|
|
2409
|
+
log(`
|
|
2410
|
+
${errors.length} error(s), ${warnings.length} warning(s)`);
|
|
2411
|
+
if (errors.length > 0) fail("VALIDATION_FAILED", `${errors.length} error(s)`, void 0, 1);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/doctor.ts
|
|
2415
|
+
import { readFileSync as readFileSync10, existsSync as existsSync9, statSync as statSync3 } from "node:fs";
|
|
2416
|
+
import { createHash } from "node:crypto";
|
|
2417
|
+
import { join as join6, resolve as resolve8 } from "node:path";
|
|
2418
|
+
import { homedir as homedir2 } from "node:os";
|
|
2419
|
+
var RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
2420
|
+
function defaultDoctorPaths(repoRoot = process.cwd()) {
|
|
2421
|
+
const home = homedir2();
|
|
2422
|
+
return {
|
|
2423
|
+
store: resolve8(home, ".claude", "projects", "F--AI", "memory"),
|
|
2424
|
+
index: resolve8(home, ".ai-loadout", "index.json"),
|
|
2425
|
+
settings: resolve8(home, ".claude", "settings.json"),
|
|
2426
|
+
usage: resolve8(home, ".ai-loadout", "usage.jsonl"),
|
|
2427
|
+
hookSource: resolve8(repoRoot, "apps", "hook", "loadout-hook.mjs"),
|
|
2428
|
+
hookMirror: resolve8(home, ".claude", "loadout-hook", "loadout-hook.mjs")
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
function sha256(path) {
|
|
2432
|
+
return createHash("sha256").update(readFileSync10(path)).digest("hex");
|
|
2433
|
+
}
|
|
2434
|
+
function loadIndex(path) {
|
|
2435
|
+
if (!existsSync9(path)) {
|
|
2436
|
+
return {
|
|
2437
|
+
check: {
|
|
2438
|
+
id: "index-parse",
|
|
2439
|
+
status: "fail",
|
|
2440
|
+
message: `Global index not found: ${path}`,
|
|
2441
|
+
hint: "Run the Index Freshness Ritual (loadout-os refresh, once implemented) to generate it."
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
let parsed;
|
|
2446
|
+
try {
|
|
2447
|
+
parsed = JSON.parse(readFileSync10(path, "utf-8"));
|
|
2448
|
+
} catch (e) {
|
|
2449
|
+
return {
|
|
2450
|
+
check: {
|
|
2451
|
+
id: "index-parse",
|
|
2452
|
+
status: "fail",
|
|
2453
|
+
message: `Global index is not valid JSON: ${path}`,
|
|
2454
|
+
hint: e.message
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {
|
|
2459
|
+
return {
|
|
2460
|
+
check: {
|
|
2461
|
+
id: "index-parse",
|
|
2462
|
+
status: "fail",
|
|
2463
|
+
message: `Global index parsed but has no entries[] array: ${path}`,
|
|
2464
|
+
hint: "Regenerate the index from the store."
|
|
2465
|
+
}
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
return { index: parsed };
|
|
2469
|
+
}
|
|
2470
|
+
function runDoctor(paths) {
|
|
2471
|
+
const checks = [];
|
|
2472
|
+
const memoryMd = join6(paths.store, "MEMORY.md");
|
|
2473
|
+
if (!existsSync9(memoryMd)) {
|
|
2474
|
+
checks.push({
|
|
2475
|
+
id: "store-validates",
|
|
2476
|
+
status: "fail",
|
|
2477
|
+
message: `Store MEMORY.md not found: ${memoryMd}`,
|
|
2478
|
+
hint: "Point --store at the directory containing MEMORY.md."
|
|
2479
|
+
});
|
|
2480
|
+
} else {
|
|
2481
|
+
try {
|
|
2482
|
+
const analysis = analyzeMemoryMd(memoryMd);
|
|
2483
|
+
const issues = validateMemory(analysis);
|
|
2484
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
2485
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
2486
|
+
if (errors > 0) {
|
|
2487
|
+
checks.push({
|
|
2488
|
+
id: "store-validates",
|
|
2489
|
+
status: "fail",
|
|
2490
|
+
message: `Store MEMORY.md has ${errors} error(s), ${warnings} warning(s)`,
|
|
2491
|
+
hint: "Run `loadout-os memories validate <MEMORY.md>` for detail."
|
|
2492
|
+
});
|
|
2493
|
+
} else if (warnings > 0) {
|
|
2494
|
+
checks.push({
|
|
2495
|
+
id: "store-validates",
|
|
2496
|
+
status: "warn",
|
|
2497
|
+
message: `Store MEMORY.md valid (0 errors) with ${warnings} warning(s)`,
|
|
2498
|
+
hint: "Run `loadout-os memories validate <MEMORY.md>` for detail."
|
|
2499
|
+
});
|
|
2500
|
+
} else {
|
|
2501
|
+
checks.push({
|
|
2502
|
+
id: "store-validates",
|
|
2503
|
+
status: "pass",
|
|
2504
|
+
message: `Store MEMORY.md validates (0 errors, ${analysis.refs.length} refs)`
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
} catch (e) {
|
|
2508
|
+
checks.push({
|
|
2509
|
+
id: "store-validates",
|
|
2510
|
+
status: "fail",
|
|
2511
|
+
message: `Could not analyze store MEMORY.md: ${e.message}`
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
const loaded = loadIndex(paths.index);
|
|
2516
|
+
let index = null;
|
|
2517
|
+
if ("check" in loaded) {
|
|
2518
|
+
checks.push(loaded.check);
|
|
2519
|
+
} else {
|
|
2520
|
+
index = loaded.index;
|
|
2521
|
+
const issues = validateIndex(index);
|
|
2522
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
2523
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
2524
|
+
if (errors > 0) {
|
|
2525
|
+
checks.push({
|
|
2526
|
+
id: "index-parse",
|
|
2527
|
+
status: "fail",
|
|
2528
|
+
message: `Global index has ${errors} structural error(s), ${warnings} warning(s)`,
|
|
2529
|
+
hint: "Run `loadout-os validate <index>` for detail."
|
|
2530
|
+
});
|
|
2531
|
+
} else if (warnings > 0) {
|
|
2532
|
+
checks.push({
|
|
2533
|
+
id: "index-parse",
|
|
2534
|
+
status: "warn",
|
|
2535
|
+
message: `Global index valid (0 errors) with ${warnings} warning(s), ${index.entries.length} entries`
|
|
2536
|
+
});
|
|
2537
|
+
} else {
|
|
2538
|
+
checks.push({
|
|
2539
|
+
id: "index-parse",
|
|
2540
|
+
status: "pass",
|
|
2541
|
+
message: `Global index parses + validates (${index.entries.length} entries)`
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
if (!existsSync9(paths.hookSource)) {
|
|
2546
|
+
checks.push({
|
|
2547
|
+
id: "hook-drift",
|
|
2548
|
+
status: "warn",
|
|
2549
|
+
message: `Hook source not found: ${paths.hookSource}`,
|
|
2550
|
+
hint: "Expected apps/hook/loadout-hook.mjs in the repo."
|
|
2551
|
+
});
|
|
2552
|
+
} else if (!existsSync9(paths.hookMirror)) {
|
|
2553
|
+
checks.push({
|
|
2554
|
+
id: "hook-drift",
|
|
2555
|
+
status: "warn",
|
|
2556
|
+
message: `Hook mirror not installed: ${paths.hookMirror}`,
|
|
2557
|
+
hint: "The runtime hook has not been copied to ~/.claude/loadout-hook/ yet."
|
|
2558
|
+
});
|
|
2559
|
+
} else {
|
|
2560
|
+
try {
|
|
2561
|
+
const a = sha256(paths.hookSource);
|
|
2562
|
+
const b = sha256(paths.hookMirror);
|
|
2563
|
+
if (a === b) {
|
|
2564
|
+
checks.push({
|
|
2565
|
+
id: "hook-drift",
|
|
2566
|
+
status: "pass",
|
|
2567
|
+
message: "Hook mirror matches repo source (no drift)"
|
|
2568
|
+
});
|
|
2569
|
+
} else {
|
|
2570
|
+
checks.push({
|
|
2571
|
+
id: "hook-drift",
|
|
2572
|
+
status: "fail",
|
|
2573
|
+
message: "Hook mirror DRIFTS from repo source",
|
|
2574
|
+
hint: "Re-copy apps/hook/loadout-hook.mjs to ~/.claude/loadout-hook/ (the live hook is stale)."
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
} catch (e) {
|
|
2578
|
+
checks.push({
|
|
2579
|
+
id: "hook-drift",
|
|
2580
|
+
status: "warn",
|
|
2581
|
+
message: `Could not hash hook files: ${e.message}`
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
try {
|
|
2586
|
+
const { searched } = discoverLayers({
|
|
2587
|
+
globalDir: paths.index.replace(/[/\\]index\.json$/, "")
|
|
2588
|
+
});
|
|
2589
|
+
const malformed = searched.filter((s) => s.malformed);
|
|
2590
|
+
if (malformed.length > 0) {
|
|
2591
|
+
checks.push({
|
|
2592
|
+
id: "layers-malformed",
|
|
2593
|
+
status: "fail",
|
|
2594
|
+
message: `${malformed.length} layer(s) present but malformed: ${malformed.map((m) => m.name).join(", ")}`,
|
|
2595
|
+
hint: "Fix or remove the corrupt index file(s) listed."
|
|
2596
|
+
});
|
|
2597
|
+
} else {
|
|
2598
|
+
checks.push({
|
|
2599
|
+
id: "layers-malformed",
|
|
2600
|
+
status: "pass",
|
|
2601
|
+
message: "No malformed layers in the resolver search path"
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
} catch (e) {
|
|
2605
|
+
checks.push({
|
|
2606
|
+
id: "layers-malformed",
|
|
2607
|
+
status: "warn",
|
|
2608
|
+
message: `Could not discover layers: ${e.message}`
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
if (index) {
|
|
2612
|
+
const coreCount = index.entries.filter((e) => e.priority === "core").length;
|
|
2613
|
+
if (coreCount === 0) {
|
|
2614
|
+
checks.push({
|
|
2615
|
+
id: "core-entries",
|
|
2616
|
+
status: "warn",
|
|
2617
|
+
message: "Index has 0 core entries \u2014 nothing is always-loaded",
|
|
2618
|
+
hint: "Confirm this is intentional; most stores expect at least one core entry."
|
|
2619
|
+
});
|
|
2620
|
+
} else {
|
|
2621
|
+
checks.push({
|
|
2622
|
+
id: "core-entries",
|
|
2623
|
+
status: "pass",
|
|
2624
|
+
message: `${coreCount} core entr${coreCount === 1 ? "y" : "ies"} present`
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
} else {
|
|
2628
|
+
checks.push({
|
|
2629
|
+
id: "core-entries",
|
|
2630
|
+
status: "warn",
|
|
2631
|
+
message: "Skipped core-entries check (no readable index)"
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
if (index) {
|
|
2635
|
+
const observed = index.budget?.avg_task_load_observed ?? null;
|
|
2636
|
+
if (observed === null) {
|
|
2637
|
+
checks.push({
|
|
2638
|
+
id: "observability-loop",
|
|
2639
|
+
status: "warn",
|
|
2640
|
+
message: "Observability loop not wired (budget.avg_task_load_observed is null)",
|
|
2641
|
+
hint: "Run `loadout-os report` once usage.jsonl has data, then fold the observed average back into the index."
|
|
2642
|
+
});
|
|
2643
|
+
} else {
|
|
2644
|
+
checks.push({
|
|
2645
|
+
id: "observability-loop",
|
|
2646
|
+
status: "pass",
|
|
2647
|
+
message: `Observed avg task load recorded: ${observed} tokens`
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
} else {
|
|
2651
|
+
checks.push({
|
|
2652
|
+
id: "observability-loop",
|
|
2653
|
+
status: "warn",
|
|
2654
|
+
message: "Skipped observability check (no readable index)"
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
if (!existsSync9(paths.settings)) {
|
|
2658
|
+
checks.push({
|
|
2659
|
+
id: "hook-wired",
|
|
2660
|
+
status: "warn",
|
|
2661
|
+
message: `settings.json not found: ${paths.settings}`,
|
|
2662
|
+
hint: "Wire the UserPromptSubmit hook in ~/.claude/settings.json."
|
|
2663
|
+
});
|
|
2664
|
+
} else {
|
|
2665
|
+
try {
|
|
2666
|
+
const raw = readFileSync10(paths.settings, "utf-8");
|
|
2667
|
+
if (/loadout-hook/.test(raw)) {
|
|
2668
|
+
checks.push({
|
|
2669
|
+
id: "hook-wired",
|
|
2670
|
+
status: "pass",
|
|
2671
|
+
message: "Hook wired in settings.json (loadout-hook command found)"
|
|
2672
|
+
});
|
|
2673
|
+
} else {
|
|
2674
|
+
checks.push({
|
|
2675
|
+
id: "hook-wired",
|
|
2676
|
+
status: "fail",
|
|
2677
|
+
message: "Hook NOT wired in settings.json (no loadout-hook command)",
|
|
2678
|
+
hint: "Add a UserPromptSubmit hook that runs the loadout-hook entrypoint."
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
} catch (e) {
|
|
2682
|
+
checks.push({
|
|
2683
|
+
id: "hook-wired",
|
|
2684
|
+
status: "warn",
|
|
2685
|
+
message: `Could not read settings.json: ${e.message}`
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
if (!existsSync9(paths.usage)) {
|
|
2690
|
+
checks.push({
|
|
2691
|
+
id: "usage-growing",
|
|
2692
|
+
status: "warn",
|
|
2693
|
+
message: `usage.jsonl not found: ${paths.usage}`,
|
|
2694
|
+
hint: "The hook writes this on first injected prompt; nothing has been recorded yet."
|
|
2695
|
+
});
|
|
2696
|
+
} else {
|
|
2697
|
+
try {
|
|
2698
|
+
const st = statSync3(paths.usage);
|
|
2699
|
+
if (st.size === 0) {
|
|
2700
|
+
checks.push({
|
|
2701
|
+
id: "usage-growing",
|
|
2702
|
+
status: "warn",
|
|
2703
|
+
message: "usage.jsonl exists but is empty",
|
|
2704
|
+
hint: "The hook has not injected any pointers yet."
|
|
2705
|
+
});
|
|
2706
|
+
} else {
|
|
2707
|
+
const ageMs = Date.now() - st.mtimeMs;
|
|
2708
|
+
if (ageMs > RECENT_MS) {
|
|
2709
|
+
const days = Math.round(ageMs / (24 * 60 * 60 * 1e3));
|
|
2710
|
+
checks.push({
|
|
2711
|
+
id: "usage-growing",
|
|
2712
|
+
status: "warn",
|
|
2713
|
+
message: `usage.jsonl is stale (last write ${days} day(s) ago)`,
|
|
2714
|
+
hint: "The hook may not be firing; check that it is wired and not disabled (AI_LOADOUT_HOOK)."
|
|
2715
|
+
});
|
|
2716
|
+
} else {
|
|
2717
|
+
checks.push({
|
|
2718
|
+
id: "usage-growing",
|
|
2719
|
+
status: "pass",
|
|
2720
|
+
message: `usage.jsonl is growing (${st.size.toLocaleString()} bytes, recent)`
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
} catch (e) {
|
|
2725
|
+
checks.push({
|
|
2726
|
+
id: "usage-growing",
|
|
2727
|
+
status: "warn",
|
|
2728
|
+
message: `Could not stat usage.jsonl: ${e.message}`
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
const ok3 = !checks.some((c) => c.status === "fail");
|
|
2733
|
+
return { checks, ok: ok3 };
|
|
2734
|
+
}
|
|
2735
|
+
function printDoctor(result, paths) {
|
|
2736
|
+
log();
|
|
2737
|
+
log(`${BOLD}loadout-os doctor${RESET} ${DIM}\u2014 read-only health screen${RESET}`);
|
|
2738
|
+
log();
|
|
2739
|
+
log(` ${DIM}store: ${paths.store}${RESET}`);
|
|
2740
|
+
log(` ${DIM}index: ${paths.index}${RESET}`);
|
|
2741
|
+
log(` ${DIM}settings: ${paths.settings}${RESET}`);
|
|
2742
|
+
log(` ${DIM}usage: ${paths.usage}${RESET}`);
|
|
2743
|
+
log();
|
|
2744
|
+
for (const c of result.checks) {
|
|
2745
|
+
const glyph = c.status === "pass" ? `${GREEN}\u2713${RESET}` : c.status === "warn" ? `${YELLOW}!${RESET}` : `${RED}\u2717${RESET}`;
|
|
2746
|
+
log(` ${glyph} ${c.message} ${DIM}[${c.id}]${RESET}`);
|
|
2747
|
+
if (c.hint && c.status !== "pass") {
|
|
2748
|
+
log(` ${DIM}${c.hint}${RESET}`);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
const fails = result.checks.filter((c) => c.status === "fail").length;
|
|
2752
|
+
const warns = result.checks.filter((c) => c.status === "warn").length;
|
|
2753
|
+
const passes = result.checks.filter((c) => c.status === "pass").length;
|
|
2754
|
+
log();
|
|
2755
|
+
log(
|
|
2756
|
+
` ${passes} pass, ${warns > 0 ? YELLOW : DIM}${warns} warn${RESET}, ${fails > 0 ? RED : DIM}${fails} fail${RESET} \u2014 ${result.ok ? `${GREEN}healthy${RESET}` : `${RED}needs attention${RESET}`}`
|
|
2757
|
+
);
|
|
2758
|
+
log();
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// src/report.ts
|
|
2762
|
+
import { readFileSync as readFileSync11, existsSync as existsSync10 } from "node:fs";
|
|
2763
|
+
function buildScoreDistribution(events) {
|
|
2764
|
+
const scored = events.filter(
|
|
2765
|
+
(e) => typeof e.score === "number"
|
|
2766
|
+
);
|
|
2767
|
+
if (scored.length === 0) return null;
|
|
2768
|
+
const buckets = [];
|
|
2769
|
+
for (let lo = 0; lo < 10; lo++) {
|
|
2770
|
+
const a = lo / 10;
|
|
2771
|
+
const b = (lo + 1) / 10;
|
|
2772
|
+
buckets.push({ label: `${a.toFixed(1)}\u2013${b.toFixed(1)}`, count: 0 });
|
|
2773
|
+
}
|
|
2774
|
+
for (const e of scored) {
|
|
2775
|
+
let idx = Math.floor(e.score * 10);
|
|
2776
|
+
if (idx < 0) idx = 0;
|
|
2777
|
+
if (idx > 9) idx = 9;
|
|
2778
|
+
buckets[idx].count++;
|
|
2779
|
+
}
|
|
2780
|
+
return buckets;
|
|
2781
|
+
}
|
|
2782
|
+
function loadIndex2(path) {
|
|
2783
|
+
if (!existsSync10(path)) return null;
|
|
2784
|
+
try {
|
|
2785
|
+
const parsed = JSON.parse(readFileSync11(path, "utf-8"));
|
|
2786
|
+
if (!parsed || !Array.isArray(parsed.entries)) return null;
|
|
2787
|
+
return parsed;
|
|
2788
|
+
} catch {
|
|
2789
|
+
return null;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
function buildReport(indexPath, usagePath) {
|
|
2793
|
+
const base = {
|
|
2794
|
+
inputs: { index: indexPath, usage: usagePath }
|
|
2795
|
+
};
|
|
2796
|
+
if (!existsSync10(usagePath)) {
|
|
2797
|
+
return {
|
|
2798
|
+
...base,
|
|
2799
|
+
events: 0,
|
|
2800
|
+
skipped: 0,
|
|
2801
|
+
usage: [],
|
|
2802
|
+
dead: [],
|
|
2803
|
+
budget: emptyBudget(),
|
|
2804
|
+
scoreDistribution: null,
|
|
2805
|
+
ok: false,
|
|
2806
|
+
error: {
|
|
2807
|
+
code: "USAGE_NOT_FOUND",
|
|
2808
|
+
message: `Usage log not found: ${usagePath}`
|
|
2809
|
+
}
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
const index = loadIndex2(indexPath);
|
|
2813
|
+
if (!index) {
|
|
2814
|
+
return {
|
|
2815
|
+
...base,
|
|
2816
|
+
events: 0,
|
|
2817
|
+
skipped: 0,
|
|
2818
|
+
usage: [],
|
|
2819
|
+
dead: [],
|
|
2820
|
+
budget: emptyBudget(),
|
|
2821
|
+
scoreDistribution: null,
|
|
2822
|
+
ok: false,
|
|
2823
|
+
error: {
|
|
2824
|
+
code: "INDEX_NOT_FOUND",
|
|
2825
|
+
message: `Global index missing or unreadable: ${indexPath}`
|
|
2826
|
+
}
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
const { events, skipped } = readUsageWithStats(usagePath);
|
|
2830
|
+
const summaries = summarizeUsage(events);
|
|
2831
|
+
const dead = findDeadEntries(index, events);
|
|
2832
|
+
const budget = analyzeBudget(index, summaries);
|
|
2833
|
+
const scoreDistribution = buildScoreDistribution(events);
|
|
2834
|
+
return {
|
|
2835
|
+
...base,
|
|
2836
|
+
events: events.length,
|
|
2837
|
+
skipped,
|
|
2838
|
+
usage: summaries.map(summaryToJSON),
|
|
2839
|
+
dead: dead.map((d) => ({
|
|
2840
|
+
id: d.entry.id,
|
|
2841
|
+
tokens: d.entry.tokens_est,
|
|
2842
|
+
reason: d.reason
|
|
2843
|
+
})),
|
|
2844
|
+
budget,
|
|
2845
|
+
scoreDistribution,
|
|
2846
|
+
ok: true
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
function emptyBudget() {
|
|
2850
|
+
return {
|
|
2851
|
+
totalTokens: 0,
|
|
2852
|
+
coreTokens: 0,
|
|
2853
|
+
domainTokens: 0,
|
|
2854
|
+
manualTokens: 0,
|
|
2855
|
+
coreEntries: 0,
|
|
2856
|
+
domainEntries: 0,
|
|
2857
|
+
manualEntries: 0,
|
|
2858
|
+
avgDomainSize: 0,
|
|
2859
|
+
largestEntry: null,
|
|
2860
|
+
smallestEntry: null,
|
|
2861
|
+
observedAvg: null
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
function printReport(r) {
|
|
2865
|
+
log();
|
|
2866
|
+
log(`${BOLD}loadout-os report${RESET} ${DIM}\u2014 observability over usage.jsonl${RESET}`);
|
|
2867
|
+
log(` ${DIM}index: ${r.inputs.index}${RESET}`);
|
|
2868
|
+
log(` ${DIM}usage: ${r.inputs.usage}${RESET}`);
|
|
2869
|
+
log();
|
|
2870
|
+
if (r.skipped > 0) {
|
|
2871
|
+
log(` ${YELLOW}!${RESET} skipped ${r.skipped} malformed usage line(s)`);
|
|
2872
|
+
}
|
|
2873
|
+
log(`${BOLD}Loaded entries${RESET} (${r.events} events, ${r.usage.length} distinct)`);
|
|
2874
|
+
if (r.usage.length === 0) {
|
|
2875
|
+
log(` ${DIM}(no usage events recorded)${RESET}`);
|
|
2876
|
+
} else {
|
|
2877
|
+
log(` ${"Entry".padEnd(36)} ${"Loads".padStart(6)} ${"Tokens".padStart(8)}`);
|
|
2878
|
+
for (const s of r.usage.slice(0, 10)) {
|
|
2879
|
+
log(
|
|
2880
|
+
` ${s.entryId.slice(0, 36).padEnd(36)} ${String(s.loadCount).padStart(6)} ${String(s.totalTokens).padStart(8)}`
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
log();
|
|
2885
|
+
log(`${BOLD}Dead entries${RESET} (${r.dead.length} never loaded)`);
|
|
2886
|
+
if (r.dead.length === 0) {
|
|
2887
|
+
log(` ${GREEN}\u2713${RESET} every non-core entry has been loaded at least once`);
|
|
2888
|
+
} else {
|
|
2889
|
+
let wasted = 0;
|
|
2890
|
+
for (const d of r.dead.slice(0, 15)) {
|
|
2891
|
+
log(` ${YELLOW}!${RESET} ${d.id} ${DIM}(${d.tokens} tokens)${RESET}`);
|
|
2892
|
+
wasted += d.tokens;
|
|
2893
|
+
}
|
|
2894
|
+
if (r.dead.length > 15) log(` ${DIM}\u2026 and ${r.dead.length - 15} more${RESET}`);
|
|
2895
|
+
log(` ${RED}${wasted.toLocaleString()} tokens${RESET} in never-loaded entries`);
|
|
2896
|
+
}
|
|
2897
|
+
log();
|
|
2898
|
+
const b = r.budget;
|
|
2899
|
+
log(`${BOLD}Budget${RESET}`);
|
|
2900
|
+
log(` Total: ${b.totalTokens.toLocaleString()} tokens`);
|
|
2901
|
+
log(` Core: ${b.coreTokens.toLocaleString()} (${b.coreEntries}) \xB7 Domain: ${b.domainTokens.toLocaleString()} (${b.domainEntries}) \xB7 Manual: ${b.manualTokens.toLocaleString()} (${b.manualEntries})`);
|
|
2902
|
+
if (b.observedAvg !== null) {
|
|
2903
|
+
log(` ${GREEN}Observed avg load:${RESET} ${b.observedAvg.toLocaleString()} tokens/load`);
|
|
2904
|
+
}
|
|
2905
|
+
log();
|
|
2906
|
+
if (r.scoreDistribution) {
|
|
2907
|
+
log(`${BOLD}Score distribution${RESET} ${DIM}(injected events)${RESET}`);
|
|
2908
|
+
const max = Math.max(1, ...r.scoreDistribution.map((s) => s.count));
|
|
2909
|
+
for (const bkt of r.scoreDistribution) {
|
|
2910
|
+
const barLen = Math.round(bkt.count / max * 30);
|
|
2911
|
+
log(` ${CYAN}${bkt.label}${RESET} ${"\u2588".repeat(barLen)} ${DIM}${bkt.count}${RESET}`);
|
|
2912
|
+
}
|
|
2913
|
+
log();
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
// src/hook.ts
|
|
2918
|
+
import { spawnSync } from "node:child_process";
|
|
2919
|
+
import {
|
|
2920
|
+
existsSync as existsSync11,
|
|
2921
|
+
mkdtempSync as mkdtempSync2,
|
|
2922
|
+
mkdirSync as mkdirSync3,
|
|
2923
|
+
copyFileSync as copyFileSync2,
|
|
2924
|
+
rmSync as rmSync2
|
|
2925
|
+
} from "node:fs";
|
|
2926
|
+
import { join as join7, resolve as resolve9, dirname as dirname6 } from "node:path";
|
|
2927
|
+
import { tmpdir as tmpdir2, homedir as homedir3 } from "node:os";
|
|
2928
|
+
var DEFAULT_PROMPT = "How do I run the dogfood swarm and full-treatment on a repo before npm publish?";
|
|
2929
|
+
function defaultHookPath(repoRoot = process.cwd()) {
|
|
2930
|
+
return resolve9(repoRoot, "apps", "hook", "loadout-hook.mjs");
|
|
2931
|
+
}
|
|
2932
|
+
function runHookTest(opts) {
|
|
2933
|
+
const prompt = opts.prompt || DEFAULT_PROMPT;
|
|
2934
|
+
const hookPath = opts.hookPath;
|
|
2935
|
+
const liveIndex = opts.liveIndex ?? join7(homedir3(), ".ai-loadout", "index.json");
|
|
2936
|
+
if (!existsSync11(hookPath)) {
|
|
2937
|
+
return {
|
|
2938
|
+
ran: false,
|
|
2939
|
+
exitCode: null,
|
|
2940
|
+
stdout: "",
|
|
2941
|
+
stderr: "",
|
|
2942
|
+
injected: false,
|
|
2943
|
+
additionalContext: null,
|
|
2944
|
+
note: `hook test runs from the loadout-os repo; apps/hook not found (${hookPath}). Clone/run from the repo to exercise the runtime hook.`
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
const sandbox = mkdtempSync2(join7(tmpdir2(), "loadout-hook-test-"));
|
|
2948
|
+
try {
|
|
2949
|
+
const sandboxAiLoadout = join7(sandbox, ".ai-loadout");
|
|
2950
|
+
mkdirSync3(sandboxAiLoadout, { recursive: true });
|
|
2951
|
+
let indexCopied = false;
|
|
2952
|
+
if (existsSync11(liveIndex)) {
|
|
2953
|
+
copyFileSync2(liveIndex, join7(sandboxAiLoadout, "index.json"));
|
|
2954
|
+
indexCopied = true;
|
|
2955
|
+
}
|
|
2956
|
+
const payload = JSON.stringify({
|
|
2957
|
+
prompt,
|
|
2958
|
+
session_id: "loadout-os-hook-test"
|
|
2959
|
+
});
|
|
2960
|
+
const env = {
|
|
2961
|
+
...process.env,
|
|
2962
|
+
HOME: sandbox,
|
|
2963
|
+
USERPROFILE: sandbox
|
|
2964
|
+
};
|
|
2965
|
+
const res = spawnSync(process.execPath, [hookPath], {
|
|
2966
|
+
input: payload,
|
|
2967
|
+
env,
|
|
2968
|
+
encoding: "utf-8",
|
|
2969
|
+
cwd: dirname6(hookPath)
|
|
2970
|
+
});
|
|
2971
|
+
const stdout = res.stdout ?? "";
|
|
2972
|
+
const stderr = res.stderr ?? "";
|
|
2973
|
+
let injected = false;
|
|
2974
|
+
let additionalContext = null;
|
|
2975
|
+
if (stdout.trim()) {
|
|
2976
|
+
try {
|
|
2977
|
+
const parsed = JSON.parse(stdout);
|
|
2978
|
+
additionalContext = parsed?.hookSpecificOutput?.additionalContext ?? null;
|
|
2979
|
+
injected = !!additionalContext;
|
|
2980
|
+
} catch {
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
const note = indexCopied ? "Ran against an isolated copy of the live index (usage.jsonl write sandboxed)." : `No live index at ${liveIndex} \u2014 hook ran but had nothing to match (expected silent).`;
|
|
2984
|
+
return {
|
|
2985
|
+
ran: true,
|
|
2986
|
+
exitCode: res.status,
|
|
2987
|
+
stdout,
|
|
2988
|
+
stderr,
|
|
2989
|
+
injected,
|
|
2990
|
+
additionalContext,
|
|
2991
|
+
note
|
|
2992
|
+
};
|
|
2993
|
+
} finally {
|
|
2994
|
+
try {
|
|
2995
|
+
rmSync2(sandbox, { recursive: true, force: true });
|
|
2996
|
+
} catch {
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
function printHookTest(prompt, r) {
|
|
3001
|
+
log();
|
|
3002
|
+
log(`${BOLD}loadout-os hook test${RESET}`);
|
|
3003
|
+
log(` ${DIM}prompt:${RESET} ${CYAN}${prompt}${RESET}`);
|
|
3004
|
+
log();
|
|
3005
|
+
if (!r.ran) {
|
|
3006
|
+
warn(r.note);
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
info(r.note);
|
|
3010
|
+
log(` ${DIM}exit code: ${r.exitCode}${RESET}`);
|
|
3011
|
+
log();
|
|
3012
|
+
if (r.injected && r.additionalContext) {
|
|
3013
|
+
ok("Hook injected pointers:");
|
|
3014
|
+
for (const line of r.additionalContext.split("\n")) {
|
|
3015
|
+
log(` ${line}`);
|
|
3016
|
+
}
|
|
3017
|
+
} else {
|
|
3018
|
+
log(` ${YELLOW}!${RESET} Hook was silent (no pointer met the score floor for this prompt).`);
|
|
3019
|
+
if (r.stderr.trim()) {
|
|
3020
|
+
log(` ${DIM}stderr:${RESET}`);
|
|
3021
|
+
for (const line of r.stderr.split("\n")) {
|
|
3022
|
+
if (line.trim()) log(` ${DIM}${line}${RESET}`);
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
log();
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
// src/refresh.ts
|
|
3030
|
+
import {
|
|
3031
|
+
writeFileSync as writeFileSync3,
|
|
3032
|
+
existsSync as existsSync12,
|
|
3033
|
+
statSync as statSync4,
|
|
3034
|
+
copyFileSync as copyFileSync3,
|
|
3035
|
+
mkdirSync as mkdirSync4
|
|
3036
|
+
} from "node:fs";
|
|
3037
|
+
import { dirname as dirname7, isAbsolute, join as join8, resolve as resolve10 } from "node:path";
|
|
3038
|
+
import { homedir as homedir4 } from "node:os";
|
|
3039
|
+
var DEFAULT_STORE = "C:/Users/mikey/.claude/projects/F--AI/memory";
|
|
3040
|
+
function defaultDest() {
|
|
3041
|
+
return join8(homedir4(), ".ai-loadout", "index.json");
|
|
3042
|
+
}
|
|
3043
|
+
var RefreshError = class extends Error {
|
|
3044
|
+
code;
|
|
3045
|
+
exitCode;
|
|
3046
|
+
issues;
|
|
3047
|
+
constructor(code, message, exitCode, issues) {
|
|
3048
|
+
super(message);
|
|
3049
|
+
this.name = "RefreshError";
|
|
3050
|
+
this.code = code;
|
|
3051
|
+
this.exitCode = exitCode;
|
|
3052
|
+
this.issues = issues;
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
function rewritePathsAbsolute(index, storeRoot, memoryMd) {
|
|
3056
|
+
let pathsRewritten = 0;
|
|
3057
|
+
const entries = index.entries.map((e) => {
|
|
3058
|
+
if (isAbsolute(e.path)) return { ...e };
|
|
3059
|
+
pathsRewritten++;
|
|
3060
|
+
return { ...e, path: resolve10(storeRoot, e.path) };
|
|
3061
|
+
});
|
|
3062
|
+
return {
|
|
3063
|
+
index: { ...index, entries, source: memoryMd },
|
|
3064
|
+
pathsRewritten
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
function runRefresh(opts) {
|
|
3068
|
+
const store = resolve10(opts.store ?? DEFAULT_STORE);
|
|
3069
|
+
const dest = resolve10(opts.dest ?? defaultDest());
|
|
3070
|
+
const dryRun = !!opts.dryRun;
|
|
3071
|
+
const memoryMd = join8(store, "MEMORY.md");
|
|
3072
|
+
if (!existsSync12(store) || !statSync4(store).isDirectory()) {
|
|
3073
|
+
throw new RefreshError(
|
|
3074
|
+
"STORE_NOT_FOUND",
|
|
3075
|
+
`Store directory not found: ${store}`,
|
|
3076
|
+
2
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
if (!existsSync12(memoryMd)) {
|
|
3080
|
+
throw new RefreshError(
|
|
3081
|
+
"MEMORY_MD_NOT_FOUND",
|
|
3082
|
+
`MEMORY.md not found in store: ${memoryMd}`,
|
|
3083
|
+
2
|
|
3084
|
+
);
|
|
3085
|
+
}
|
|
3086
|
+
const analysis = analyzeMemoryMd(memoryMd);
|
|
3087
|
+
const storeIndex = generateIndex(analysis);
|
|
3088
|
+
const storeIndexPath = join8(store, "index.json");
|
|
3089
|
+
const gate = [
|
|
3090
|
+
...validateMemory(analysis),
|
|
3091
|
+
...validateMemoryIndex(storeIndex)
|
|
3092
|
+
];
|
|
3093
|
+
const gateErrors = gate.filter((i) => i.severity === "error");
|
|
3094
|
+
const gateWarnings = gate.filter((i) => i.severity === "warning");
|
|
3095
|
+
if (gateErrors.length > 0) {
|
|
3096
|
+
throw new RefreshError(
|
|
3097
|
+
"VALIDATION_FAILED",
|
|
3098
|
+
`${gateErrors.length} validation error(s) \u2014 halting, nothing written`,
|
|
3099
|
+
1,
|
|
3100
|
+
gateErrors
|
|
3101
|
+
);
|
|
3102
|
+
}
|
|
3103
|
+
const { index: rewritten, pathsRewritten } = rewritePathsAbsolute(
|
|
3104
|
+
storeIndex,
|
|
3105
|
+
store,
|
|
3106
|
+
memoryMd
|
|
3107
|
+
);
|
|
3108
|
+
const destJson = JSON.stringify(rewritten, null, 2) + "\n";
|
|
3109
|
+
const storeJson = JSON.stringify(storeIndex, null, 2) + "\n";
|
|
3110
|
+
const destIssues = validateIndex(rewritten);
|
|
3111
|
+
if (dryRun) {
|
|
3112
|
+
return {
|
|
3113
|
+
store,
|
|
3114
|
+
memoryMd,
|
|
3115
|
+
storeIndexPath,
|
|
3116
|
+
dest,
|
|
3117
|
+
dryRun: true,
|
|
3118
|
+
entryCount: rewritten.entries.length,
|
|
3119
|
+
pathsRewritten,
|
|
3120
|
+
gateErrors,
|
|
3121
|
+
gateWarnings,
|
|
3122
|
+
destIssues,
|
|
3123
|
+
backupPath: null,
|
|
3124
|
+
wrote: false
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
writeFileSync3(storeIndexPath, storeJson, "utf-8");
|
|
3128
|
+
let backupPath = null;
|
|
3129
|
+
if (existsSync12(dest)) {
|
|
3130
|
+
backupPath = `${dest}.bak`;
|
|
3131
|
+
copyFileSync3(dest, backupPath);
|
|
3132
|
+
}
|
|
3133
|
+
try {
|
|
3134
|
+
const destDir = dirname7(dest);
|
|
3135
|
+
if (!existsSync12(destDir)) mkdirSync4(destDir, { recursive: true });
|
|
3136
|
+
writeFileSync3(dest, destJson, "utf-8");
|
|
3137
|
+
} catch (e) {
|
|
3138
|
+
if (backupPath && existsSync12(backupPath)) {
|
|
3139
|
+
try {
|
|
3140
|
+
copyFileSync3(backupPath, dest);
|
|
3141
|
+
} catch {
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
if (e instanceof RefreshError) throw e;
|
|
3145
|
+
throw new RefreshError(
|
|
3146
|
+
"WRITE_FAILED",
|
|
3147
|
+
`Failed to write dest (${dest}): ${e.message}${backupPath ? ` \u2014 restored from ${backupPath}` : ""}`,
|
|
3148
|
+
1
|
|
3149
|
+
);
|
|
3150
|
+
}
|
|
3151
|
+
return {
|
|
3152
|
+
store,
|
|
3153
|
+
memoryMd,
|
|
3154
|
+
storeIndexPath,
|
|
3155
|
+
dest,
|
|
3156
|
+
dryRun: false,
|
|
3157
|
+
entryCount: rewritten.entries.length,
|
|
3158
|
+
pathsRewritten,
|
|
3159
|
+
gateErrors,
|
|
3160
|
+
gateWarnings,
|
|
3161
|
+
destIssues,
|
|
3162
|
+
backupPath,
|
|
3163
|
+
wrote: true
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
function printRefresh(r) {
|
|
3167
|
+
log();
|
|
3168
|
+
log(`${BOLD}loadout-os refresh${RESET} ${DIM}\u2014 Index Freshness Ritual${RESET}`);
|
|
3169
|
+
log(` ${DIM}store: ${r.store}${RESET}`);
|
|
3170
|
+
log(` ${DIM}dest: ${r.dest}${RESET}`);
|
|
3171
|
+
log();
|
|
3172
|
+
if (r.dryRun) {
|
|
3173
|
+
info(`${BOLD}--dry-run${RESET} \u2014 nothing was written.`);
|
|
3174
|
+
log(
|
|
3175
|
+
` ${CYAN}Would write${RESET} ${r.storeIndexPath} and ${r.dest}: ${r.entryCount} entr${r.entryCount === 1 ? "y" : "ies"}, ${r.pathsRewritten} relative path(s) \u2192 absolute.`
|
|
3176
|
+
);
|
|
3177
|
+
if (existsSync12(r.dest)) {
|
|
3178
|
+
log(` ${DIM}A live dest exists; a real run would back it up to ${r.dest}.bak first.${RESET}`);
|
|
3179
|
+
} else {
|
|
3180
|
+
log(` ${DIM}No live dest yet; a real run would create it (no backup needed).${RESET}`);
|
|
3181
|
+
}
|
|
3182
|
+
if (r.gateWarnings.length > 0) warn(`${r.gateWarnings.length} validation warning(s) (non-blocking).`);
|
|
3183
|
+
log();
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
ok(`Wrote store index: ${r.storeIndexPath}`);
|
|
3187
|
+
ok(`Wrote global index: ${r.dest} (${r.entryCount} entries, ${r.pathsRewritten} paths \u2192 absolute)`);
|
|
3188
|
+
if (r.backupPath) {
|
|
3189
|
+
log(` ${DIM}compensator: backed up previous dest \u2192 ${r.backupPath}${RESET}`);
|
|
3190
|
+
log(` ${DIM}undo: copy ${r.backupPath} back over ${r.dest}${RESET}`);
|
|
3191
|
+
} else {
|
|
3192
|
+
log(` ${DIM}compensator: no prior dest existed; nothing to back up (undo = delete ${r.dest})${RESET}`);
|
|
3193
|
+
}
|
|
3194
|
+
if (r.gateWarnings.length > 0) {
|
|
3195
|
+
warn(`${r.gateWarnings.length} validation warning(s) (non-blocking):`);
|
|
3196
|
+
for (const i of r.gateWarnings) log(` ${YELLOW}![${i.code}]${RESET} ${i.message}`);
|
|
3197
|
+
}
|
|
3198
|
+
if (r.destIssues.length > 0) {
|
|
3199
|
+
const errs = r.destIssues.filter((i) => i.severity === "error");
|
|
3200
|
+
const wrns = r.destIssues.filter((i) => i.severity === "warning");
|
|
3201
|
+
if (errs.length > 0) {
|
|
3202
|
+
warn(`dest re-validation found ${errs.length} structural error(s) (reported, not blocking):`);
|
|
3203
|
+
for (const i of errs) log(` ${RED}\u2717[${i.code}]${RESET} ${i.message}`);
|
|
3204
|
+
}
|
|
3205
|
+
if (wrns.length > 0) {
|
|
3206
|
+
for (const i of wrns) log(` ${DIM}![${i.code}] ${i.message}${RESET}`);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
log();
|
|
3210
|
+
log(
|
|
3211
|
+
` ${DIM}Note: this scratch/CLI run wrote the configured --dest. A LIVE run against the canonical store + ~/.ai-loadout is deferred to a coordinator-supervised step.${RESET}`
|
|
3212
|
+
);
|
|
3213
|
+
log();
|
|
3214
|
+
}
|
|
3215
|
+
function printRefreshAndon(issues) {
|
|
3216
|
+
log();
|
|
3217
|
+
log(`${RED}${BOLD}loadout-os refresh \u2014 ANDON HALT${RESET}`);
|
|
3218
|
+
log(` ${DIM}validation failed; nothing was written downstream.${RESET}`);
|
|
3219
|
+
log();
|
|
3220
|
+
for (const i of issues) {
|
|
3221
|
+
log(` ${RED}\u2717 [${i.code}]${RESET} ${i.message}`);
|
|
3222
|
+
if (i.hint) log(` ${DIM}${i.hint}${RESET}`);
|
|
3223
|
+
}
|
|
3224
|
+
log();
|
|
3225
|
+
}
|
|
3226
|
+
function dispatchRefresh(args) {
|
|
3227
|
+
const store = flagValue(args, "store") ?? DEFAULT_STORE;
|
|
3228
|
+
const dest = flagValue(args, "dest") ?? defaultDest();
|
|
3229
|
+
const dryRun = hasFlag(args, "dry-run");
|
|
3230
|
+
try {
|
|
3231
|
+
const result = runRefresh({ store, dest, dryRun });
|
|
3232
|
+
printRefresh(result);
|
|
3233
|
+
} catch (e) {
|
|
3234
|
+
if (e instanceof RefreshError) {
|
|
3235
|
+
if (e.code === "VALIDATION_FAILED" && e.issues) {
|
|
3236
|
+
printRefreshAndon(e.issues);
|
|
3237
|
+
} else {
|
|
3238
|
+
log();
|
|
3239
|
+
log(` ${RED}\u2717 [${e.code}]${RESET} ${e.message}`);
|
|
3240
|
+
log();
|
|
3241
|
+
}
|
|
3242
|
+
fail(e.code, e.message, void 0, e.exitCode);
|
|
3243
|
+
}
|
|
3244
|
+
throw e;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
// src/help.ts
|
|
3249
|
+
function wantsHelp(args) {
|
|
3250
|
+
return hasFlag(args, "help") || args.includes("-h");
|
|
3251
|
+
}
|
|
3252
|
+
var COMMAND_HELP = {
|
|
3253
|
+
// ── memories namespace ───────────────────────────────────────
|
|
3254
|
+
"memories index": {
|
|
3255
|
+
synopsis: "loadout-os memories index <MEMORY.md> [--lazy] [--json]",
|
|
3256
|
+
args: [["<MEMORY.md>", "path to the store's MEMORY.md (required, first)"]],
|
|
3257
|
+
flags: [
|
|
3258
|
+
["--lazy", "generate a lazy-load index (on-demand topic loading)"],
|
|
3259
|
+
["--json", "machine-readable output"]
|
|
3260
|
+
],
|
|
3261
|
+
example: "loadout-os memories index ~/.claude/projects/F--AI/memory/MEMORY.md",
|
|
3262
|
+
exits: [["0", "index generated"], ["1", "MEMORY.md missing / unreadable"]]
|
|
3263
|
+
},
|
|
3264
|
+
"memories validate": {
|
|
3265
|
+
synopsis: "loadout-os memories validate <MEMORY.md> [--json]",
|
|
3266
|
+
args: [["<MEMORY.md>", "path to MEMORY.md to lint (required, first)"]],
|
|
3267
|
+
flags: [["--json", "machine-readable output"]],
|
|
3268
|
+
example: "loadout-os memories validate ./MEMORY.md",
|
|
3269
|
+
exits: [["0", "no errors (warnings allowed)"], ["1", "\u22651 error-severity issue"]]
|
|
3270
|
+
},
|
|
3271
|
+
"memories stats": {
|
|
3272
|
+
synopsis: "loadout-os memories stats <MEMORY.md> [--json]",
|
|
3273
|
+
args: [["<MEMORY.md>", "path to MEMORY.md (required, first)"]],
|
|
3274
|
+
flags: [["--json", "machine-readable output"]],
|
|
3275
|
+
example: "loadout-os memories stats ./MEMORY.md --json",
|
|
3276
|
+
exits: [["0", "stats printed"], ["1", "MEMORY.md missing"]]
|
|
3277
|
+
},
|
|
3278
|
+
"memories health": {
|
|
3279
|
+
synopsis: "loadout-os memories health [path] [--json]",
|
|
3280
|
+
args: [["[path]", "optional MEMORY.md path to probe (else auto-detects)"]],
|
|
3281
|
+
flags: [["--json", "machine-readable output"]],
|
|
3282
|
+
example: "loadout-os memories health",
|
|
3283
|
+
exits: [["0", "Node OK"], ["1", "Node < 20"]]
|
|
3284
|
+
},
|
|
3285
|
+
// ── rules namespace ──────────────────────────────────────────
|
|
3286
|
+
"rules analyze": {
|
|
3287
|
+
synopsis: "loadout-os rules analyze <CLAUDE.md> [--rules-dir <dir>] [--json]",
|
|
3288
|
+
args: [["<CLAUDE.md>", "instruction file to analyze (required, first)"]],
|
|
3289
|
+
flags: [
|
|
3290
|
+
["--rules-dir <dir>", "rules directory (default .claude/rules)"],
|
|
3291
|
+
["--json", "machine-readable output"]
|
|
3292
|
+
],
|
|
3293
|
+
example: "loadout-os rules analyze .claude/CLAUDE.md",
|
|
3294
|
+
exits: [["0", "analysis printed"], ["1", "CLAUDE.md missing"]]
|
|
3295
|
+
},
|
|
3296
|
+
"rules validate": {
|
|
3297
|
+
synopsis: "loadout-os rules validate [--rules-dir <dir>] [--lazy] [--repo-root <dir>] [--json]",
|
|
3298
|
+
flags: [
|
|
3299
|
+
["--rules-dir <dir>", "rules directory (default .claude/rules, or .claude/loadout with --lazy)"],
|
|
3300
|
+
["--lazy", "validate a lazy-load layout"],
|
|
3301
|
+
["--repo-root <dir>", "repo root the rule paths resolve against (default cwd)"],
|
|
3302
|
+
["--json", "machine-readable output"]
|
|
3303
|
+
],
|
|
3304
|
+
example: "loadout-os rules validate --rules-dir .claude/rules",
|
|
3305
|
+
exits: [["0", "no errors"], ["1", "\u22651 error-severity issue"]]
|
|
3306
|
+
},
|
|
3307
|
+
"rules stats": {
|
|
3308
|
+
synopsis: "loadout-os rules stats <CLAUDE.md> [--rules-dir <dir>] [--json]",
|
|
3309
|
+
args: [["<CLAUDE.md>", "instruction file (required, first)"]],
|
|
3310
|
+
flags: [
|
|
3311
|
+
["--rules-dir <dir>", "rules directory (default .claude/rules)"],
|
|
3312
|
+
["--json", "machine-readable output"]
|
|
3313
|
+
],
|
|
3314
|
+
example: "loadout-os rules stats .claude/CLAUDE.md",
|
|
3315
|
+
exits: [["0", "stats printed"], ["1", "CLAUDE.md missing"]]
|
|
3316
|
+
},
|
|
3317
|
+
"rules split": {
|
|
3318
|
+
synopsis: "loadout-os rules split [CLAUDE.md] [--yes] [--dry-run] [...]",
|
|
3319
|
+
args: [["[CLAUDE.md]", "instruction file to split (default .claude/CLAUDE.md)"]],
|
|
3320
|
+
flags: [
|
|
3321
|
+
["--dry-run", "show the proposed split without writing"],
|
|
3322
|
+
["--yes", "extract all proposed sections without prompting"]
|
|
3323
|
+
],
|
|
3324
|
+
example: "loadout-os rules split .claude/CLAUDE.md --dry-run",
|
|
3325
|
+
note: "Interactive: this passes through to the `claude-rules split` bin with inherited stdio so the readline prompt works. All args/flags are forwarded verbatim.",
|
|
3326
|
+
exits: [["0", "split completed (or dry-run printed)"], ["\u22600", "forwarded from the claude-rules bin"]]
|
|
3327
|
+
},
|
|
3328
|
+
// ── flat kernel verbs ────────────────────────────────────────
|
|
3329
|
+
resolve: {
|
|
3330
|
+
synopsis: "loadout-os resolve [--project <dir>] [--global <dir>] [--org <p>] [--session <p>] [--json]",
|
|
3331
|
+
flags: [
|
|
3332
|
+
["--project <dir>", "project root to discover .claude layers under"],
|
|
3333
|
+
["--global <dir>", "global resolver dir (default ~/.ai-loadout)"],
|
|
3334
|
+
["--org <p>", "org-level index path"],
|
|
3335
|
+
["--session <p>", "session-level index path"],
|
|
3336
|
+
["--json", "machine-readable output"]
|
|
3337
|
+
],
|
|
3338
|
+
example: "loadout-os resolve --json",
|
|
3339
|
+
exits: [["0", "layers resolved (even if none found)"]]
|
|
3340
|
+
},
|
|
3341
|
+
explain: {
|
|
3342
|
+
synopsis: "loadout-os explain <entry-id> [--project <dir>] [--global <dir>] [--json]",
|
|
3343
|
+
args: [["<entry-id>", "the entry id to trace across layers (required, first)"]],
|
|
3344
|
+
flags: [["--json", "machine-readable output"]],
|
|
3345
|
+
example: "loadout-os explain shipcheck",
|
|
3346
|
+
exits: [["0", "explanation printed"], ["1", "entry id not found in any layer"]]
|
|
3347
|
+
},
|
|
3348
|
+
usage: {
|
|
3349
|
+
synopsis: "loadout-os usage <jsonl> [--json]",
|
|
3350
|
+
args: [["<jsonl>", "path to a usage.jsonl event log (required, first)"]],
|
|
3351
|
+
flags: [["--json", "machine-readable output"]],
|
|
3352
|
+
example: "loadout-os usage ~/.ai-loadout/usage.jsonl",
|
|
3353
|
+
exits: [["0", "summary printed (empty log allowed)"], ["1", "missing arg"]]
|
|
3354
|
+
},
|
|
3355
|
+
dead: {
|
|
3356
|
+
synopsis: "loadout-os dead <index> <jsonl> [--json]",
|
|
3357
|
+
args: [
|
|
3358
|
+
["<index>", "the loadout index (FIRST positional)"],
|
|
3359
|
+
["<jsonl>", "the usage.jsonl event log (SECOND positional)"]
|
|
3360
|
+
],
|
|
3361
|
+
flags: [["--json", "machine-readable output"]],
|
|
3362
|
+
note: "Order matters: <index> comes BEFORE <jsonl>. Swapping them ('dead usage.jsonl index.json') makes the kernel try to read the index as an event log and the log as an index \u2014 a quiet foot-gun, so the order is fixed and documented here.",
|
|
3363
|
+
example: "loadout-os dead ~/.ai-loadout/index.json ~/.ai-loadout/usage.jsonl",
|
|
3364
|
+
exits: [["0", "dead-entry report printed"], ["1", "missing arg / bad index"]]
|
|
3365
|
+
},
|
|
3366
|
+
overlaps: {
|
|
3367
|
+
synopsis: "loadout-os overlaps <index> [--json]",
|
|
3368
|
+
args: [["<index>", "the loadout index to scan for keyword overlaps (required, first)"]],
|
|
3369
|
+
flags: [["--json", "machine-readable output"]],
|
|
3370
|
+
example: "loadout-os overlaps ~/.ai-loadout/index.json",
|
|
3371
|
+
exits: [["0", "overlaps printed (none = unambiguous routing)"], ["1", "bad index"]]
|
|
3372
|
+
},
|
|
3373
|
+
budget: {
|
|
3374
|
+
synopsis: "loadout-os budget <index> [jsonl] [--json]",
|
|
3375
|
+
args: [
|
|
3376
|
+
["<index>", "the loadout index (required, FIRST)"],
|
|
3377
|
+
["[jsonl]", "optional usage.jsonl to fold in observed averages (SECOND)"]
|
|
3378
|
+
],
|
|
3379
|
+
flags: [["--json", "machine-readable output"]],
|
|
3380
|
+
example: "loadout-os budget ~/.ai-loadout/index.json ~/.ai-loadout/usage.jsonl",
|
|
3381
|
+
exits: [["0", "budget printed"], ["1", "bad index"]]
|
|
3382
|
+
},
|
|
3383
|
+
validate: {
|
|
3384
|
+
synopsis: "loadout-os validate <index> [--json]",
|
|
3385
|
+
args: [["<index>", "the loadout index to STRUCTURE-validate via the kernel (required, first)"]],
|
|
3386
|
+
flags: [["--json", "machine-readable output"]],
|
|
3387
|
+
note: "This is the KERNEL index-structure validator. For linting a MEMORY.md or rules dir use 'memories validate' / 'rules validate' instead \u2014 that flat-vs-namespaced split is how the name collision is resolved.",
|
|
3388
|
+
example: "loadout-os validate ~/.ai-loadout/index.json",
|
|
3389
|
+
exits: [["0", "no structural errors"], ["1", "\u22651 error-severity issue"]]
|
|
3390
|
+
},
|
|
3391
|
+
// ── rituals ──────────────────────────────────────────────────
|
|
3392
|
+
doctor: {
|
|
3393
|
+
synopsis: "loadout-os doctor [--store <dir>] [--index <p>] [--settings <p>] [--usage <p>] [--repo-root <dir>] [--json]",
|
|
3394
|
+
flags: [
|
|
3395
|
+
["--store <dir>", "memory store dir (default canonical store)"],
|
|
3396
|
+
["--index <p>", "global resolver index (default ~/.ai-loadout/index.json)"],
|
|
3397
|
+
["--settings <p>", "Claude Code settings.json (default ~/.claude/settings.json)"],
|
|
3398
|
+
["--usage <p>", "usage.jsonl path (default ~/.ai-loadout/usage.jsonl)"],
|
|
3399
|
+
["--repo-root <dir>", "repo root for the hook-drift check (default cwd)"],
|
|
3400
|
+
["--json", "machine-readable output"]
|
|
3401
|
+
],
|
|
3402
|
+
note: "Read-only: doctor NEVER writes. Every check delegates to a library validator.",
|
|
3403
|
+
example: "loadout-os doctor",
|
|
3404
|
+
exits: [["0", "healthy (no fail checks)"], ["1", "\u22651 fail check"]]
|
|
3405
|
+
},
|
|
3406
|
+
report: {
|
|
3407
|
+
synopsis: "loadout-os report [--index <p>] [--jsonl <p>] [--json]",
|
|
3408
|
+
flags: [
|
|
3409
|
+
["--index <p>", "global index (default ~/.ai-loadout/index.json)"],
|
|
3410
|
+
["--jsonl <p>", "usage log (default ~/.ai-loadout/usage.jsonl)"],
|
|
3411
|
+
["--json", "machine-readable output"]
|
|
3412
|
+
],
|
|
3413
|
+
note: "Read-only observability over usage.jsonl.",
|
|
3414
|
+
example: "loadout-os report --json",
|
|
3415
|
+
exits: [["0", "report printed"], ["2", "a required input (index/usage) is missing"]]
|
|
3416
|
+
},
|
|
3417
|
+
"hook test": {
|
|
3418
|
+
synopsis: 'loadout-os hook test [--prompt "<text>"] [--repo-root <dir>] [--json]',
|
|
3419
|
+
flags: [
|
|
3420
|
+
['--prompt "<text>"', "prompt to drive the hook with (default a sample)"],
|
|
3421
|
+
["--repo-root <dir>", "repo root holding apps/hook/loadout-hook.mjs (default cwd)"],
|
|
3422
|
+
["--json", "machine-readable output"]
|
|
3423
|
+
],
|
|
3424
|
+
note: "Runs in an isolated HOME so the live usage.jsonl is never written.",
|
|
3425
|
+
example: 'loadout-os hook test --prompt "scaffold a new game"',
|
|
3426
|
+
exits: [["0", "hook ran"], ["1", "hook binary not found"]]
|
|
3427
|
+
},
|
|
3428
|
+
refresh: {
|
|
3429
|
+
synopsis: "loadout-os refresh [--store <dir>] [--dest <path>] [--dry-run]",
|
|
3430
|
+
flags: [
|
|
3431
|
+
["--store <dir>", "memory store dir (default canonical store)"],
|
|
3432
|
+
["--dest <path>", "global index to write (default ~/.ai-loadout/index.json)"],
|
|
3433
|
+
["--dry-run", "compute everything, write NOTHING, print what WOULD change"]
|
|
3434
|
+
],
|
|
3435
|
+
note: "Folds the Index Freshness Ritual (index \u2192 validate \u2192 copy) into one command. ANDON HALT: any validation error writes nothing. COMPENSATOR: an existing --dest is backed up to <dest>.bak before overwrite and restored on write failure.",
|
|
3436
|
+
example: "loadout-os refresh --dry-run",
|
|
3437
|
+
exits: [
|
|
3438
|
+
["0", "index written (or dry-run printed)"],
|
|
3439
|
+
["1", "validation error (andon) or write failure"],
|
|
3440
|
+
["2", "store / MEMORY.md missing"]
|
|
3441
|
+
]
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
function renderCommandHelp(key) {
|
|
3445
|
+
const e = COMMAND_HELP[key];
|
|
3446
|
+
if (!e) {
|
|
3447
|
+
return `
|
|
3448
|
+
${BOLD}loadout-os ${key}${RESET}
|
|
3449
|
+
(no per-command help registered)
|
|
3450
|
+
`;
|
|
3451
|
+
}
|
|
3452
|
+
const lines = [];
|
|
3453
|
+
lines.push("");
|
|
3454
|
+
lines.push(`${BOLD}loadout-os ${key}${RESET}`);
|
|
3455
|
+
lines.push("");
|
|
3456
|
+
lines.push(`${BOLD}Synopsis:${RESET}`);
|
|
3457
|
+
lines.push(` ${e.synopsis}`);
|
|
3458
|
+
if (e.args && e.args.length > 0) {
|
|
3459
|
+
lines.push("");
|
|
3460
|
+
lines.push(`${BOLD}Arguments:${RESET}`);
|
|
3461
|
+
for (const [name, role] of e.args) lines.push(` ${name.padEnd(14)} ${DIM}${role}${RESET}`);
|
|
3462
|
+
}
|
|
3463
|
+
if (e.flags && e.flags.length > 0) {
|
|
3464
|
+
lines.push("");
|
|
3465
|
+
lines.push(`${BOLD}Flags:${RESET}`);
|
|
3466
|
+
for (const [flag, role] of e.flags) lines.push(` ${flag.padEnd(22)} ${DIM}${role}${RESET}`);
|
|
3467
|
+
}
|
|
3468
|
+
if (e.note) {
|
|
3469
|
+
lines.push("");
|
|
3470
|
+
lines.push(` ${DIM}${e.note}${RESET}`);
|
|
3471
|
+
}
|
|
3472
|
+
lines.push("");
|
|
3473
|
+
lines.push(`${BOLD}Example:${RESET}`);
|
|
3474
|
+
lines.push(` ${e.example}`);
|
|
3475
|
+
lines.push("");
|
|
3476
|
+
lines.push(`${BOLD}Exit codes:${RESET}`);
|
|
3477
|
+
for (const [code, meaning] of e.exits) lines.push(` ${code.padEnd(4)} ${DIM}${meaning}${RESET}`);
|
|
3478
|
+
lines.push("");
|
|
3479
|
+
return lines.join("\n");
|
|
3480
|
+
}
|
|
3481
|
+
function interceptHelp(key, args) {
|
|
3482
|
+
if (!wantsHelp(args)) return false;
|
|
3483
|
+
if (!(key in COMMAND_HELP)) return false;
|
|
3484
|
+
log(renderCommandHelp(key));
|
|
3485
|
+
return true;
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
// src/cli.ts
|
|
3489
|
+
function getVersion() {
|
|
3490
|
+
try {
|
|
3491
|
+
const here = dirname8(fileURLToPath(import.meta.url));
|
|
3492
|
+
const pkg = JSON.parse(readFileSync12(join9(here, "..", "package.json"), "utf-8"));
|
|
3493
|
+
return pkg.version ?? "unknown";
|
|
3494
|
+
} catch {
|
|
3495
|
+
return "unknown";
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
function topLevelHelp() {
|
|
3499
|
+
return `
|
|
3500
|
+
${BOLD}loadout-os${RESET} v${getVersion()} \u2014 unified Knowledge OS CLI
|
|
3501
|
+
|
|
3502
|
+
${BOLD}Namespaces (wrapped library surfaces):${RESET}
|
|
3503
|
+
loadout-os memories <index|validate|stats|health> <MEMORY.md>
|
|
3504
|
+
loadout-os rules <analyze|validate|stats|split> [CLAUDE.md]
|
|
3505
|
+
|
|
3506
|
+
${BOLD}Flat verbs (knowledge router / kernel):${RESET}
|
|
3507
|
+
loadout-os resolve Resolve layered loadouts (global \u2192 org \u2192 project \u2192 session)
|
|
3508
|
+
loadout-os explain <entry-id> Explain how an entry resolved across layers
|
|
3509
|
+
loadout-os usage <jsonl> Usage summary from the event log
|
|
3510
|
+
loadout-os dead <index> <jsonl> Entries never loaded
|
|
3511
|
+
loadout-os overlaps <index> Keyword routing ambiguities
|
|
3512
|
+
loadout-os budget <index> [jsonl] Token budget breakdown
|
|
3513
|
+
loadout-os validate <index> Validate index STRUCTURE (kernel)
|
|
3514
|
+
|
|
3515
|
+
${BOLD}Rituals:${RESET}
|
|
3516
|
+
loadout-os doctor [--json] Read-only health screen
|
|
3517
|
+
loadout-os report [--index <p>] [--jsonl <p>] Observability over usage.jsonl
|
|
3518
|
+
loadout-os hook test [--prompt "<text>"] Drive the runtime hook on a sample prompt
|
|
3519
|
+
loadout-os refresh [--store <d>] [--dest <p>] [--dry-run] Index Freshness Ritual (index \u2192 validate \u2192 write global)
|
|
3520
|
+
|
|
3521
|
+
${BOLD}Options:${RESET}
|
|
3522
|
+
--json Machine-readable output (doctor/report/most verbs)
|
|
3523
|
+
--help Show this help (or per-namespace: 'loadout-os memories --help')
|
|
3524
|
+
--version Show version
|
|
3525
|
+
|
|
3526
|
+
${BOLD}Examples:${RESET}
|
|
3527
|
+
loadout-os memories validate ~/.claude/projects/F--AI/memory/MEMORY.md
|
|
3528
|
+
loadout-os rules analyze .claude/CLAUDE.md
|
|
3529
|
+
loadout-os validate ~/.ai-loadout/index.json
|
|
3530
|
+
loadout-os doctor
|
|
3531
|
+
loadout-os report --json
|
|
3532
|
+
loadout-os hook test --prompt "scaffold a new game"
|
|
3533
|
+
`;
|
|
3534
|
+
}
|
|
3535
|
+
function memoriesHelp() {
|
|
3536
|
+
return `
|
|
3537
|
+
${BOLD}loadout-os memories${RESET} \u2014 wraps @mcptoolshop/claude-memories
|
|
3538
|
+
|
|
3539
|
+
loadout-os memories index <MEMORY.md> [--lazy] [--json]
|
|
3540
|
+
loadout-os memories validate <MEMORY.md> [--json]
|
|
3541
|
+
loadout-os memories stats <MEMORY.md> [--json]
|
|
3542
|
+
loadout-os memories health [path] [--json]
|
|
3543
|
+
`;
|
|
3544
|
+
}
|
|
3545
|
+
function rulesHelp() {
|
|
3546
|
+
return `
|
|
3547
|
+
${BOLD}loadout-os rules${RESET} \u2014 wraps @mcptoolshop/claude-rules
|
|
3548
|
+
|
|
3549
|
+
loadout-os rules analyze <CLAUDE.md> [--rules-dir <dir>] [--json]
|
|
3550
|
+
loadout-os rules validate [--rules-dir <dir>] [--lazy] [--repo-root <dir>] [--json]
|
|
3551
|
+
loadout-os rules stats <CLAUDE.md> [--rules-dir <dir>] [--json]
|
|
3552
|
+
loadout-os rules split [CLAUDE.md] [--yes] [--dry-run] (interactive \u2014 passes through to claude-rules)
|
|
3553
|
+
`;
|
|
3554
|
+
}
|
|
3555
|
+
function dispatchMemories(args) {
|
|
3556
|
+
const firstIsSub = args.length > 0 && !args[0].startsWith("-");
|
|
3557
|
+
if (args.length === 0 || hasFlag(args, "help") && !firstIsSub) {
|
|
3558
|
+
log(memoriesHelp());
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
const sub = args[0];
|
|
3562
|
+
const rest = args.slice(1);
|
|
3563
|
+
if (interceptHelp(`memories ${sub}`, rest)) return;
|
|
3564
|
+
switch (sub) {
|
|
3565
|
+
case "index":
|
|
3566
|
+
return memoriesIndex(rest);
|
|
3567
|
+
case "validate":
|
|
3568
|
+
return memoriesValidate(rest);
|
|
3569
|
+
case "stats":
|
|
3570
|
+
return memoriesStats(rest);
|
|
3571
|
+
case "health":
|
|
3572
|
+
return memoriesHealth(rest);
|
|
3573
|
+
default:
|
|
3574
|
+
throw new CliError(
|
|
3575
|
+
"UNKNOWN_COMMAND",
|
|
3576
|
+
`Unknown memories subcommand: ${sub}`,
|
|
3577
|
+
"Expected one of: index, validate, stats, health. Run 'loadout-os memories --help'."
|
|
3578
|
+
);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
function dispatchRules(args) {
|
|
3582
|
+
const firstIsSub = args.length > 0 && !args[0].startsWith("-");
|
|
3583
|
+
if (args.length === 0 || hasFlag(args, "help") && !firstIsSub) {
|
|
3584
|
+
log(rulesHelp());
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
const sub = args[0];
|
|
3588
|
+
const rest = args.slice(1);
|
|
3589
|
+
if (interceptHelp(`rules ${sub}`, rest)) return;
|
|
3590
|
+
switch (sub) {
|
|
3591
|
+
case "analyze":
|
|
3592
|
+
return rulesAnalyze(rest);
|
|
3593
|
+
case "validate":
|
|
3594
|
+
return rulesValidate(rest);
|
|
3595
|
+
case "stats":
|
|
3596
|
+
return rulesStats(rest);
|
|
3597
|
+
case "split":
|
|
3598
|
+
return rulesSplit(rest);
|
|
3599
|
+
default:
|
|
3600
|
+
throw new CliError(
|
|
3601
|
+
"UNKNOWN_COMMAND",
|
|
3602
|
+
`Unknown rules subcommand: ${sub}`,
|
|
3603
|
+
"Expected one of: analyze, validate, stats, split. Run 'loadout-os rules --help'."
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
function dispatchDoctor(args) {
|
|
3608
|
+
const repoRoot = flagValue(args, "repo-root") ?? process.cwd();
|
|
3609
|
+
const paths = defaultDoctorPaths(resolve11(repoRoot));
|
|
3610
|
+
const store = flagValue(args, "store");
|
|
3611
|
+
const index = flagValue(args, "index");
|
|
3612
|
+
const settings = flagValue(args, "settings");
|
|
3613
|
+
const usage = flagValue(args, "usage");
|
|
3614
|
+
if (store) paths.store = resolve11(store);
|
|
3615
|
+
if (index) paths.index = resolve11(index);
|
|
3616
|
+
if (settings) paths.settings = resolve11(settings);
|
|
3617
|
+
if (usage) paths.usage = resolve11(usage);
|
|
3618
|
+
const result = runDoctor(paths);
|
|
3619
|
+
if (hasFlag(args, "json")) {
|
|
3620
|
+
log(JSON.stringify(result, null, 2));
|
|
3621
|
+
} else {
|
|
3622
|
+
printDoctor(result, paths);
|
|
3623
|
+
}
|
|
3624
|
+
if (!result.ok) {
|
|
3625
|
+
process.exitCode = 1;
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
function dispatchReport(args) {
|
|
3629
|
+
const home = homedir5();
|
|
3630
|
+
const indexPath = resolve11(flagValue(args, "index") ?? join9(home, ".ai-loadout", "index.json"));
|
|
3631
|
+
const usagePath = resolve11(flagValue(args, "jsonl") ?? join9(home, ".ai-loadout", "usage.jsonl"));
|
|
3632
|
+
const result = buildReport(indexPath, usagePath);
|
|
3633
|
+
if (hasFlag(args, "json")) {
|
|
3634
|
+
log(JSON.stringify(result, null, 2));
|
|
3635
|
+
} else if (!result.ok && result.error) {
|
|
3636
|
+
log();
|
|
3637
|
+
log(` ${RED}\u2717 [${result.error.code}]${RESET} ${result.error.message}`);
|
|
3638
|
+
log();
|
|
3639
|
+
} else {
|
|
3640
|
+
printReport(result);
|
|
3641
|
+
}
|
|
3642
|
+
if (!result.ok) {
|
|
3643
|
+
process.exitCode = 2;
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
function dispatchHook(args) {
|
|
3647
|
+
const sub = args[0];
|
|
3648
|
+
if (interceptHelp("hook test", args)) return;
|
|
3649
|
+
if (sub !== "test") {
|
|
3650
|
+
throw new CliError(
|
|
3651
|
+
"UNKNOWN_COMMAND",
|
|
3652
|
+
`Unknown hook subcommand: ${sub ?? "(none)"}`,
|
|
3653
|
+
"Only 'loadout-os hook test' is available."
|
|
3654
|
+
);
|
|
3655
|
+
}
|
|
3656
|
+
const rest = args.slice(1);
|
|
3657
|
+
const prompt = flagValue(rest, "prompt") ?? "";
|
|
3658
|
+
const repoRoot = flagValue(rest, "repo-root") ?? process.cwd();
|
|
3659
|
+
const result = runHookTest({
|
|
3660
|
+
prompt,
|
|
3661
|
+
hookPath: defaultHookPath(resolve11(repoRoot))
|
|
3662
|
+
});
|
|
3663
|
+
if (hasFlag(rest, "json")) {
|
|
3664
|
+
log(JSON.stringify(result, null, 2));
|
|
3665
|
+
} else {
|
|
3666
|
+
printHookTest(prompt || "(default sample prompt)", result);
|
|
3667
|
+
}
|
|
3668
|
+
if (!result.ran) {
|
|
3669
|
+
process.exitCode = 1;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
var FLAT_KERNEL = /* @__PURE__ */ new Set([
|
|
3673
|
+
"resolve",
|
|
3674
|
+
"explain",
|
|
3675
|
+
"usage",
|
|
3676
|
+
"dead",
|
|
3677
|
+
"overlaps",
|
|
3678
|
+
"budget",
|
|
3679
|
+
"validate"
|
|
3680
|
+
]);
|
|
3681
|
+
function dispatch(args) {
|
|
3682
|
+
if (hasFlag(args, "version") && args[0] !== "memories" && args[0] !== "rules") {
|
|
3683
|
+
log(getVersion());
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
const firstIsCmd = args.length > 0 && !args[0].startsWith("-");
|
|
3687
|
+
if (args.length === 0 || hasFlag(args, "help") && !firstIsCmd) {
|
|
3688
|
+
log(topLevelHelp());
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
const cmd = args[0];
|
|
3692
|
+
const rest = args.slice(1);
|
|
3693
|
+
if (cmd !== "memories" && cmd !== "rules" && cmd !== "hook" && interceptHelp(cmd, rest)) {
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
switch (cmd) {
|
|
3697
|
+
case "memories":
|
|
3698
|
+
return dispatchMemories(rest);
|
|
3699
|
+
case "rules":
|
|
3700
|
+
return dispatchRules(rest);
|
|
3701
|
+
case "doctor":
|
|
3702
|
+
return dispatchDoctor(rest);
|
|
3703
|
+
case "report":
|
|
3704
|
+
return dispatchReport(rest);
|
|
3705
|
+
case "hook":
|
|
3706
|
+
return dispatchHook(rest);
|
|
3707
|
+
case "refresh":
|
|
3708
|
+
return dispatchRefresh(rest);
|
|
3709
|
+
case "resolve":
|
|
3710
|
+
return kernelResolve(rest);
|
|
3711
|
+
case "explain":
|
|
3712
|
+
return kernelExplain(rest);
|
|
3713
|
+
case "usage":
|
|
3714
|
+
return kernelUsage(rest);
|
|
3715
|
+
case "dead":
|
|
3716
|
+
return kernelDead(rest);
|
|
3717
|
+
case "overlaps":
|
|
3718
|
+
return kernelOverlaps(rest);
|
|
3719
|
+
case "budget":
|
|
3720
|
+
return kernelBudget(rest);
|
|
3721
|
+
case "validate":
|
|
3722
|
+
return kernelValidate(rest);
|
|
3723
|
+
default:
|
|
3724
|
+
if (FLAT_KERNEL.has(cmd)) {
|
|
3725
|
+
throw new CliError("INTERNAL", `Unrouted flat verb: ${cmd}`);
|
|
3726
|
+
}
|
|
3727
|
+
throw new CliError(
|
|
3728
|
+
"UNKNOWN_COMMAND",
|
|
3729
|
+
`Unknown command: ${cmd}`,
|
|
3730
|
+
"Run 'loadout-os --help' for the command tree."
|
|
3731
|
+
);
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
function isEntrypoint() {
|
|
3735
|
+
try {
|
|
3736
|
+
return process.argv[1] === fileURLToPath(import.meta.url);
|
|
3737
|
+
} catch {
|
|
3738
|
+
return false;
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
if (isEntrypoint()) {
|
|
3742
|
+
try {
|
|
3743
|
+
dispatch(process.argv.slice(2));
|
|
3744
|
+
} catch (err) {
|
|
3745
|
+
if (err instanceof CliError) {
|
|
3746
|
+
console.error(`${RED}\u2717 [${err.code}]${RESET} ${err.message}`);
|
|
3747
|
+
if (err.hint) console.error(` ${DIM}${err.hint}${RESET}`);
|
|
3748
|
+
process.exit(err.exitCode);
|
|
3749
|
+
}
|
|
3750
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3751
|
+
console.error(`${RED}\u2717 [RUNTIME_FATAL]${RESET} ${message}`);
|
|
3752
|
+
process.exit(1);
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
export {
|
|
3756
|
+
dispatch,
|
|
3757
|
+
getVersion,
|
|
3758
|
+
topLevelHelp
|
|
3759
|
+
};
|