fraim 2.0.346 → 2.0.349

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/registry/scripts/learning/completion-state.js +80 -4
  2. package/dist/registry/scripts/learning/corpus-audit.js +7 -7
  3. package/dist/registry/scripts/learning/entry-authoring.js +181 -0
  4. package/dist/registry/scripts/learning/entry-format.js +10 -1
  5. package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
  6. package/dist/registry/scripts/learning/outcome-types.js +92 -0
  7. package/dist/registry/scripts/learning/owner-identity.js +11 -0
  8. package/dist/registry/scripts/learning/proposal-queue.js +81 -0
  9. package/dist/registry/scripts/learning/run-application.js +1003 -0
  10. package/dist/registry/scripts/learning/run-publication.js +364 -0
  11. package/dist/registry/scripts/learning/run-record.js +895 -0
  12. package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
  13. package/dist/src/cli/commands/learning.js +64 -0
  14. package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
  15. package/dist/src/cli/fraim.js +2 -0
  16. package/dist/src/cli/learning/inventory.js +285 -0
  17. package/dist/src/cli/learning/queue-operations.js +199 -0
  18. package/dist/src/cli/learning/runtime.js +82 -0
  19. package/dist/src/cli/learning/usage.js +277 -0
  20. package/dist/src/core/fraim-config-schema.generated.js +0 -4
  21. package/dist/src/core/utils/local-registry-resolver.js +13 -3
  22. package/dist/src/local-mcp-server/learning-context-builder.js +109 -379
  23. package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
  24. package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
  25. package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
  26. package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
  27. package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
  28. package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
  29. package/dist/src/local-mcp-server/stdio-server.js +50 -114
  30. package/dist/src/mcp/tool-schemas.js +2 -1
  31. package/package.json +9 -1
  32. package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
  33. package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
  34. package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
@@ -1,518 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.learningUsageCommand = void 0;
7
- /**
8
- * Issue #1103 - command surface used by the learning-usage script.
9
- *
10
- * The read and analysis side of the usage record. The write side of an offer is the
11
- * proxy (no agent involvement, R2); the write side of a firing is an attestation in
12
- * a retrospective, which `record-firings` reads back.
13
- */
14
- const commander_1 = require("commander");
15
- const node_fs_1 = __importDefault(require("node:fs"));
16
- const node_path_1 = __importDefault(require("node:path"));
17
- const learning_context_builder_1 = require("./learning-context-builder");
18
- const learning_usage_analysis_1 = require("./learning-usage-analysis");
19
- const learning_usage_store_1 = require("./learning-usage-store");
20
- const learning_usage_attestation_1 = require("./learning-usage-attestation");
21
- const learning_usage_verify_1 = require("./learning-usage-verify");
22
- const pack_home_1 = require("../cli/utils/pack-home");
23
- /**
24
- * Preserve checkpointed history and distinguish backup recovery from attestation backfill.
25
- * Backfill supplements older firings; it cannot reconstruct missing offers.
26
- */
27
- const REBUILD_NOTE = "Keep `learning-usage.json`, its `.checkpoint` backup, `events/` and any conflict copies. After consolidation, the snapshot and checkpoint backup preserve lifetime counters and already-counted source markers; the remaining journal cannot rebuild that history. A missing or torn snapshot is read from its checkpoint backup automatically. Restore matching checkpoint and journal files from backup if both copies are damaged. Inspect split identities and conflict copies before repairing them; do not delete the snapshot to clear a warning. Separately, `learning-usage backfill` can recover older firings from retrospectives; raw learnings and retrospectives cannot restore runtime offers.";
28
- function resolveWorkspaceRoot(options) {
29
- return node_path_1.default.resolve(options.workspaceRoot || options.root || process.cwd());
30
- }
31
- function resolveUser(options) {
32
- const email = options.user || process.env.FRAIM_ACTIVE_USER_EMAIL || '';
33
- if (!email.trim()) {
34
- throw new Error('Missing active user email. Pass --user <email> or set FRAIM_ACTIVE_USER_EMAIL.');
35
- }
36
- return email.trim();
37
- }
38
- function emit(payload, json, text) {
39
- process.stdout.write(json ? `${JSON.stringify(payload, null, 2)}\n` : `${text()}\n`);
40
- }
41
- function addCommonOptions(command) {
42
- return command
43
- .option('--workspace-root <path>', 'Workspace root containing fraim/config.json')
44
- .option('--root <path>', 'Alias for --workspace-root')
45
- .option('--user <email>', 'Active user email')
46
- .option('--json', 'Print JSON');
47
- }
48
- /**
49
- * Issue #1752 C1: every command that draws a per-model conclusion takes the model
50
- * from its caller. It is never inferred from the record, because the record is
51
- * shared by concurrent sessions from more than one agent on more than one
52
- * machine, so inference reports whichever wrote last.
53
- */
54
- function addModelOption(command) {
55
- return command.option('--model <model>', 'The model you are running under. Falls back to FRAIM_ACTIVE_MODEL, then reports unknown');
56
- }
57
- /**
58
- * Issue #1752 B: the offer and firing log bounds are applied when the event log is
59
- * folded, so a read states the bounds it wants rather than inheriting whatever the
60
- * last writer used.
61
- */
62
- function readStore(workspaceRoot) {
63
- return (0, learning_usage_store_1.readUsageStore)(undefined, { limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot) });
64
- }
65
- // ── offers ───────────────────────────────────────────────────────────────────
66
- const offersCommand = addCommonOptions(new commander_1.Command('offers'))
67
- .description('List the learning entries and rule files the runtime delivered, so an attestation is written against the record rather than memory')
68
- .option('--job <name>', 'The job whose context was loaded', 'unknown')
69
- .option('--domain <domain>', 'Learning domain the job resolved to')
70
- .option('--session', 'Use the session frame rather than the job frame')
71
- .action((options) => {
72
- const workspaceRoot = resolveWorkspaceRoot(options);
73
- const user = resolveUser(options);
74
- const forJob = !options.session;
75
- // Prefer what the runtime recorded for this job, because that is what was
76
- // actually delivered. Re-deriving from the files on disk answers a subtly
77
- // different question — what would be delivered now — and the two diverge if a
78
- // learning file changed during the job.
79
- const store = readStore(workspaceRoot);
80
- const recorded = Object.values(store.entries)
81
- .filter((entry) => entry.offered.log.some((offer) => offer.job === options.job))
82
- .map((entry) => {
83
- const last = [...entry.offered.log].reverse().find((offer) => offer.job === options.job);
84
- return {
85
- key: entry.key,
86
- family: entry.family,
87
- id: entry.key.startsWith('L-') ? entry.key : null,
88
- title: entry.titles[entry.titles.length - 1] ?? entry.key,
89
- fileType: entry.fileType ?? 'unknown',
90
- level: entry.level ?? 'unknown',
91
- displayPath: '',
92
- severity: null,
93
- aboveThreshold: last.aboveThreshold,
94
- };
95
- });
96
- const source = recorded.length > 0 ? 'offer-record' : 'derived';
97
- const offered = recorded.length > 0
98
- ? recorded
99
- : [
100
- ...(0, learning_context_builder_1.collectOfferedLearningEntries)(workspaceRoot, user, forJob, options.domain ?? null),
101
- ...(0, learning_context_builder_1.collectOfferedRuleFiles)(workspaceRoot, forJob),
102
- ];
103
- const payload = { job: options.job, forJob, source, offered };
104
- emit(payload, options.json, () => {
105
- if (payload.offered.length === 0) {
106
- return `No offer record and nothing resolves for job ${options.job}. Do not attest any firing for this job.`;
107
- }
108
- const rows = payload.offered.map((o) => `| ${o.id ?? '(no id)'} | ${o.title} | ${o.fileType} | ${o.aboveThreshold ? 'yes' : 'no'} |`);
109
- const header = source === 'offer-record'
110
- ? `Entries offered for job ${options.job}, from the offer record:`
111
- : `No offer record exists for job ${options.job} yet, so this is what would be delivered now. Say so when you attest against it.`;
112
- return [
113
- header,
114
- '',
115
- '| Id | Title | Family | Above threshold |',
116
- '|---|---|---|---|',
117
- ...rows,
118
- ].join('\n');
119
- });
120
- });
121
- // ── record-firings ───────────────────────────────────────────────────────────
122
- const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-firings'))
123
- .description('Read the firing section of a retrospective and write the attested firings into the usage record')
124
- .requiredOption('--retrospective <path>', 'Retrospective file to read')
125
- .option('--job <name>', 'The job that produced the retrospective')
126
- .option('--agent <name>', 'Agent that attested')
127
- .option('--model <model>', 'Model that attested')
128
- .option('--legacy', 'Also accept the pre-#1103 prose form')
129
- .option('--force', 'Record the firings even when they violate the scoped integrity invariant, flagging the entry instead of refusing. Use when the offers are known to be stranded in an unmerged store copy or an unsynced machine log')
130
- .action((options) => {
131
- const workspaceRoot = resolveWorkspaceRoot(options);
132
- const user = resolveUser(options);
133
- const filePath = node_path_1.default.resolve(options.retrospective);
134
- if (!node_fs_1.default.existsSync(filePath)) {
135
- throw new Error(`Retrospective not found: ${filePath}`);
136
- }
137
- const { content, record } = (0, learning_usage_attestation_1.readRetrospectiveForAttestation)(workspaceRoot, user, filePath);
138
- const source = record.usageIdentity;
139
- const result = (0, learning_usage_attestation_1.recordAttestationsFromRetrospective)(workspaceRoot, user, {
140
- content,
141
- source,
142
- job: record.job,
143
- date: record.date,
144
- agent: options.agent ?? null,
145
- model: options.model ?? null,
146
- legacy: Boolean(options.legacy),
147
- integrityMode: options.force ? 'flag' : 'throw',
148
- });
149
- emit(result, options.json, () => {
150
- const lines = [
151
- `Section state: ${result.sectionState}`,
152
- `Items read: ${result.itemsRead}`,
153
- `Recorded: ${result.applied}`,
154
- `Already recorded: ${result.alreadyRecorded}`,
155
- `Unmatched: ${result.unmatched.length}`,
156
- `Rejected: ${result.rejected.length}`,
157
- ];
158
- for (const item of result.unmatched)
159
- lines.push(` unmatched: ${item.title} — ${item.reason}`);
160
- for (const item of result.rejected)
161
- lines.push(` rejected: ${item.reason}`);
162
- return lines.join('\n');
163
- });
164
- });
165
- // ── classify ─────────────────────────────────────────────────────────────────
166
- const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
167
- .description('Say whether a recurrence was ignored (the entry was in context) or not-offered (it never reached the agent), because the two need different fixes')
168
- .option('--entry <id>', 'The entry id, for an entry that carries one')
169
- .option('--title <title>', 'The exact entry title, for an entry with no id yet. Use with --family')
170
- .option('--family <family>', 'The entry family: mistake-patterns, preferences, validated-patterns, or manager-coaching')
171
- .requiredOption('--job <name>', 'The job the recurrence happened in')
172
- .requiredOption('--date <YYYY-MM-DD>', 'The date the mistake recurred')
173
- .option('--window <days>', 'How far back an offer still counts as in-context', '30')
174
- .action((options) => {
175
- const workspaceRoot = resolveWorkspaceRoot(options);
176
- // `--title` plus `--family` exists so a caller never has to build the composite
177
- // title key by hand. An entry title is corpus content and can contain any
178
- // character, so an agent assembling `T:<family>:<title>` into a shell command
179
- // would be interpolating untrusted text into a command line. Passing the title
180
- // as its own argument keeps it a single argv entry.
181
- if (!options.entry && !(options.title && options.family)) {
182
- throw new Error('Pass --entry <id>, or --title <title> together with --family <family>.');
183
- }
184
- // #1752 A1: the same identity function the offer and firing paths use, so
185
- // the CLI cannot ask about a key neither of them would have written.
186
- const key = (0, learning_usage_store_1.resolveEntryIdentity)({
187
- id: options.entry ? String(options.entry) : null,
188
- fileType: options.family ? String(options.family) : null,
189
- title: options.title ? String(options.title) : null,
190
- }).key;
191
- const store = readStore(workspaceRoot);
192
- const diagnosis = (0, learning_usage_analysis_1.classifyRecurrence)(store, {
193
- key,
194
- job: options.job,
195
- date: options.date,
196
- windowDays: Number.parseInt(options.window, 10) || 30,
197
- });
198
- void workspaceRoot;
199
- emit(diagnosis, options.json, () => [
200
- `Classification: ${diagnosis.classification}`,
201
- `Diagnosis: ${diagnosis.diagnosis}`,
202
- `Recommendation: ${diagnosis.recommendation}`,
203
- `Raise the recurrence count: ${diagnosis.recurrenceBump ? 'yes' : 'no'}`,
204
- ].join('\n'));
205
- });
206
- // ── candidates ───────────────────────────────────────────────────────────────
207
- const candidatesCommand = addModelOption(addCommonOptions(new commander_1.Command('candidates')))
208
- .description('List retirement candidates: offered often under the current model with no firing under it, paired with a structural signal')
209
- .option('--detector <path>', 'Path to the corpus hygiene detector that provides the structural signal')
210
- .action((options) => {
211
- const workspaceRoot = resolveWorkspaceRoot(options);
212
- const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
213
- const result = (0, learning_usage_analysis_1.findRetirementCandidates)(readStore(workspaceRoot), {
214
- workspaceRoot,
215
- limits,
216
- currentModel: options.model ?? undefined,
217
- structuralSignalPath: options.detector,
218
- });
219
- emit(result, options.json, () => {
220
- if (result.candidates.length === 0) {
221
- const lines = [`No retirement candidates. Current model: ${result.currentModel ?? 'unknown'}; threshold ${result.offerThreshold} offers.`];
222
- if (result.reason)
223
- lines.push('', result.reason);
224
- const unknownAttribution = result.notCandidates.filter((c) => /attribution is unknown/i.test(c.reason));
225
- if (unknownAttribution.length > 0) {
226
- lines.push('', `${unknownAttribution.length} entr${unknownAttribution.length === 1 ? 'y was' : 'ies were'} held back because their firings carry no model, so how hard they are working under this model cannot be measured.`);
227
- }
228
- return lines.join('\n');
229
- }
230
- const lines = [`Current model: ${result.currentModel ?? 'unknown'}. Threshold: ${result.offerThreshold} offers.`, ''];
231
- for (const c of result.candidates) {
232
- const earlier = c.firedUnderEarlierModels.map((m) => `${m.count} under ${m.model}`).join(', ') || 'none under any earlier model';
233
- lines.push(`- ${c.title}`, ` Offered ${c.offeredUnderCurrentModel} times under the current model, fired ${c.firedUnderCurrentModel}. Earlier: ${earlier}.`, ` Structural signal: ${c.structural}. ${c.structuralDetail}`, ` Recommendation: ${c.recommendation}`);
234
- }
235
- return lines.join('\n');
236
- });
237
- });
238
- // ── standing ─────────────────────────────────────────────────────────────────
239
- const standingCommand = addModelOption(addCommonOptions(new commander_1.Command('standing')))
240
- .description('Assess each entry as promoted, standard or demoted from its usage record, and list the ones whose standing should change')
241
- .option('--changes-only', 'Print only the entries whose standing should change')
242
- .action((options) => {
243
- const workspaceRoot = resolveWorkspaceRoot(options);
244
- const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
245
- const report = (0, learning_usage_analysis_1.assessStanding)(readStore(workspaceRoot), { limits });
246
- const payload = options.changesOnly ? { ...report, assessments: report.changes } : report;
247
- emit(payload, options.json, () => {
248
- const rows = (options.changesOnly ? report.changes : report.assessments);
249
- if (rows.length === 0) {
250
- return options.changesOnly
251
- ? 'No standing changes. Every measured entry is already where the record says it belongs.'
252
- : 'No entry has a usage record yet, so there is nothing to assess.';
253
- }
254
- const lines = [];
255
- if (report.changes.length > 0) {
256
- lines.push(`${report.changes.length} entr${report.changes.length === 1 ? 'y' : 'ies'} should change standing.`, '');
257
- }
258
- for (const a of rows) {
259
- lines.push(`- ${a.title}`);
260
- lines.push(` ${a.standing}${a.recommendation ? ` (recommend: ${a.recommendation})` : ''}. ${a.reason}`);
261
- }
262
- lines.push('', 'Nothing is applied here. Run sleep-on-learnings to decide.');
263
- return lines.join('\n');
264
- });
265
- });
266
- // ── report ───────────────────────────────────────────────────────────────────
267
- const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('report')))
268
- .description('Answer which learnings are still earning their place, from the record rather than from memory')
269
- .option('--window <days>', 'Reporting window', '90')
270
- .action((options) => {
271
- const workspaceRoot = resolveWorkspaceRoot(options);
272
- const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
273
- const report = (0, learning_usage_analysis_1.buildUsageReport)(readStore(workspaceRoot), {
274
- workspaceRoot,
275
- limits,
276
- windowDays: Number.parseInt(options.window, 10) || 90,
277
- currentModel: options.model ?? undefined,
278
- });
279
- emit(report, options.json, () => {
280
- const lines = [
281
- report.totals.entriesOffered === null ? 'Corpus totals are unavailable; listed entries have independent usable evidence.'
282
- : `${report.totals.entriesOffered} entries were offered. ${report.totals.entriesWithFirings} fired. ${report.totals.entriesNeverFired} have never fired.`,
283
- ];
284
- if (report.workingHardest.length) {
285
- lines.push('', 'Working hardest');
286
- report.workingHardest.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. Offered ${e.offered} times, fired ${e.fired}. Last fired ${e.lastFired ?? 'never'}.`));
287
- }
288
- if (report.neverFired.length) {
289
- lines.push('', 'Never fired');
290
- report.neverFired.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. Offered ${e.offered} times, never fired.`));
291
- }
292
- if (report.ignoredWhileInContext.length) {
293
- lines.push('', 'Ignored while in context');
294
- // #1752 defect 8: this section selects on the `ignored` count, so it
295
- // prints that count. Printing only the clock-advancing one made the
296
- // section contradict its own evidence column.
297
- report.ignoredWhileInContext.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. Attested ignored ${e.ignored} time${e.ignored === 1 ? '' : 's'} (fired ${e.fired}). In context and the work went against it, so the entry needs work rather than another recurrence.`));
298
- }
299
- if (report.integrityFlagged > 0) {
300
- lines.push('', `${report.integrityFlagged} entries were excluded from usage conclusions. Run \`learning-usage verify\` for diagnostic details.`);
301
- }
302
- lines.push('', 'Nothing is retired from this view. Run sleep-on-learnings to decide.');
303
- return lines.join('\n');
304
- });
305
- });
306
- // ── backfill ─────────────────────────────────────────────────────────────────
307
- const backfillCommand = addCommonOptions(new commander_1.Command('backfill'))
308
- .description('Read the firings already attested in existing retrospectives, matching only on an id or an exact title, and report what could not be matched')
309
- .option('--agent <name>', 'Agent to attribute the backfilled records to', 'backfill')
310
- .option('--model <model>', 'Model to attribute the backfilled records to')
311
- .action((options) => {
312
- const workspaceRoot = resolveWorkspaceRoot(options);
313
- const user = resolveUser(options);
314
- const report = (0, learning_usage_attestation_1.runBackfill)(workspaceRoot, user, {
315
- agent: options.agent ?? 'backfill',
316
- model: options.model ?? null,
317
- });
318
- emit(report, options.json, () => [
319
- `Files scanned: ${report.filesScanned} (${report.filesWithSection} carry the section, ${report.synthesizedFilesRead} already synthesized)`,
320
- `Items read: ${report.itemsRead}`,
321
- `Applied: ${report.applied}`,
322
- `Already recorded: ${report.alreadyRecorded}`,
323
- `Unmatched: ${report.unmatched.length}`,
324
- `Rejected: ${report.rejected.length}`,
325
- '',
326
- 'Only an id or an exact title resolves. A low match rate is expected on retrospectives written before entries carried ids, and is reported rather than resolved by similarity matching.',
327
- ].join('\n'));
328
- });
329
- /**
330
- * Add the `**Id**` line to entries that do not have one.
331
- *
332
- * This is a format migration, not a change to any lesson: it inserts one
333
- * identifier line per entry and touches nothing else. R19 forbids a new write path
334
- * into the corpus for usage data, and this is not one — it writes no usage data,
335
- * and it is run explicitly by the manager rather than by any automatic path.
336
- */
337
- function stampFile(filePath, fileType, apply) {
338
- const content = node_fs_1.default.readFileSync(filePath, 'utf8');
339
- const eol = content.includes('\r\n') ? '\r\n' : '\n';
340
- const lines = content.split(/\r?\n/);
341
- const headingRe = (0, learning_context_builder_1.learningEntryHeadingRegex)();
342
- const idRe = (0, learning_context_builder_1.learningEntryIdLineRegex)();
343
- const out = [];
344
- let stamped = 0;
345
- let alreadyStamped = 0;
346
- for (let i = 0; i < lines.length; i++) {
347
- const line = lines[i];
348
- out.push(line);
349
- const header = line.match(headingRe);
350
- if (!header)
351
- continue;
352
- // Look ahead to the next entry heading for an existing id line.
353
- let hasId = false;
354
- for (let j = i + 1; j < lines.length; j++) {
355
- if (headingRe.test(lines[j]))
356
- break;
357
- if (idRe.test(lines[j].trim())) {
358
- hasId = true;
359
- break;
360
- }
361
- }
362
- if (hasId) {
363
- alreadyStamped++;
364
- continue;
365
- }
366
- const title = header[2].trim();
367
- if (!title)
368
- continue;
369
- // Insert immediately after the heading, keeping the blank line that usually
370
- // follows it so the file's shape is unchanged.
371
- if (lines[i + 1] !== undefined && lines[i + 1].trim() === '') {
372
- out.push('');
373
- out.push(`**Id**: ${(0, learning_context_builder_1.generateLearningEntryId)(fileType, title)}`);
374
- i++;
375
- }
376
- else {
377
- out.push(`**Id**: ${(0, learning_context_builder_1.generateLearningEntryId)(fileType, title)}`);
378
- }
379
- stamped++;
380
- }
381
- if (apply && stamped > 0) {
382
- node_fs_1.default.writeFileSync(filePath, out.join(eol), 'utf8');
383
- }
384
- return { file: filePath, entriesStamped: stamped, entriesAlreadyStamped: alreadyStamped };
385
- }
386
- const FILE_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
387
- function defaultStampDirs(workspaceRoot) {
388
- const dirs = [node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings')];
389
- for (const level of ['org', 'manager']) {
390
- try {
391
- dirs.push(node_path_1.default.join((0, pack_home_1.resolvePackHome)(level, { migrateLegacy: false }).contentRoot, 'learnings'));
392
- }
393
- catch {
394
- // A pack home that does not resolve is simply not stamped. Reported by the
395
- // filesScanned count rather than failing the command.
396
- }
397
- }
398
- return [...new Set(dirs)];
399
- }
400
- function fileTypeFromName(fileName) {
401
- for (const type of FILE_TYPES) {
402
- if (fileName.includes(type))
403
- return type;
404
- }
405
- return null;
406
- }
407
- const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
408
- .description('Add the stable Id line to learning entries that do not have one. A format migration: it inserts one identifier line per entry and changes no lesson text')
409
- .option('--dir <path>', 'Learning directory to stamp (repeatable)', (value, previous) => [...previous, value], [])
410
- .option('--apply', 'Write the changes. Without this flag the command reports what it would do')
411
- .action((options) => {
412
- const workspaceRoot = resolveWorkspaceRoot(options);
413
- // #1752 A3: the default set is every tier the context reader merges, not
414
- // the repo alone. `stamp-ids` never reaching the org and manager pack homes
415
- // is half of defect 4: one lesson stamped in one tier and unstamped in the
416
- // other resolved to two usage keys. Still opt-in behind --apply, and still a
417
- // format migration that inserts one identifier line and touches no lesson text.
418
- const dirs = options.dir.length
419
- ? options.dir.map((d) => node_path_1.default.resolve(d))
420
- : defaultStampDirs(workspaceRoot);
421
- const results = [];
422
- for (const dir of dirs) {
423
- if (!node_fs_1.default.existsSync(dir))
424
- continue;
425
- for (const fileName of node_fs_1.default.readdirSync(dir)) {
426
- if (!fileName.endsWith('.md'))
427
- continue;
428
- const fileType = fileTypeFromName(fileName);
429
- if (!fileType)
430
- continue;
431
- results.push(stampFile(node_path_1.default.join(dir, fileName), fileType, Boolean(options.apply)));
432
- }
433
- }
434
- const payload = {
435
- apply: Boolean(options.apply),
436
- filesScanned: results.length,
437
- entriesStamped: results.reduce((sum, r) => sum + r.entriesStamped, 0),
438
- entriesAlreadyStamped: results.reduce((sum, r) => sum + r.entriesAlreadyStamped, 0),
439
- files: results,
440
- };
441
- emit(payload, options.json, () => [
442
- `${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files in ${dirs.length} director${dirs.length === 1 ? 'y' : 'ies'}:`,
443
- ...dirs.map((d) => ` ${d}`),
444
- `${payload.entriesAlreadyStamped} entries already carry an id.`,
445
- payload.apply ? '' : 'Re-run with --apply to write the changes.',
446
- ].filter(Boolean).join('\n'));
447
- });
448
- // ── prune ────────────────────────────────────────────────────────────────────
449
- const pruneCommand = addCommonOptions(new commander_1.Command('prune'))
450
- .description('Apply the configured retention window and log bounds to the usage record. Aggregate counters are never pruned')
451
- .action((options) => {
452
- const workspaceRoot = resolveWorkspaceRoot(options);
453
- const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
454
- const result = (0, learning_usage_store_1.pruneUsageStore)(limits);
455
- emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () => result.maintenanceStatus === 'deferred' ? `Maintenance deferred; history unchanged. ${result.evidence?.observations.join(', ') ?? ''}`
456
- : `Pruned ${result.prunedOfferRecords} offer records and ${result.prunedFiringRecords} firing records across ${result.entries} entries. Aggregates unchanged.`);
457
- });
458
- function mb(bytes) {
459
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
460
- }
461
- // ── verify ───────────────────────────────────────────────────────────────────
462
- const verifyCommand = addModelOption(addCommonOptions(new commander_1.Command('verify')))
463
- .description('Report the integrity of the usage record in one output: split identities, impossible counters, split model buckets, unmerged store copies, and how far each snapshot checkpoint is behind its log')
464
- .action((options) => {
465
- const workspaceRoot = resolveWorkspaceRoot(options);
466
- const report = (0, learning_usage_verify_1.buildVerifyReport)({
467
- snapshotPath: (0, learning_usage_store_1.resolveUsageStorePath)(),
468
- limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot),
469
- currentModel: options.model ?? undefined,
470
- });
471
- emit(report, options.json, () => {
472
- const lines = [
473
- `Usage record: ${report.entries} entries at ${report.snapshotPath}`,
474
- `Current model: ${report.currentModel ?? 'unknown'}`,
475
- '',
476
- ];
477
- const finding = (count, singular, plural, remedy) => count === 0 ? ` none: ${singular}` : ` ${count} ${count === 1 ? singular : plural}. ${remedy}`;
478
- if (report.evidence)
479
- lines.push(`Evidence: ${report.evidence.status}. ${report.evidence.observations.join(', ')}.`, ...(report.evidence.recovery ? [`Recovered from ${report.evidence.recovery}; primary replacement was not persisted by this read.`] : []), '');
480
- lines.push('Identity', finding(report.twinPairs.length, 'lesson tracked under two keys', 'lessons tracked under two keys', 'Its offers and its firings are on different records, so every count about it is partial.'), report.unmatchedTitleKeys.length
481
- ? ` ${report.unmatchedTitleKeys.length} title-keyed record${report.unmatchedTitleKeys.length === 1 ? ' has' : 's have'} no id twin. Expected for entries no tier has stamped; run \`stamp-ids\` to reduce it.`
482
- : ' every title-keyed record has been matched or has no twin', '', 'Counters', finding(report.invariantViolations.length, 'entry holds more attested firings than recorded offers', 'entries hold more attested firings than recorded offers', 'The counts are inconsistent. This observation does not establish their cause.'), finding(report.caseVariantModelBuckets.length, 'model bucket differs only by case', 'model buckets differ only by case', 'One model spelled two ways splits its own denominator, so every per-model conclusion about it is computed on a fraction of its traffic.'), finding(report.unknownModelAttribution.length, 'entry has incomplete model attribution', 'entries have incomplete model attribution', 'Affected model conclusions are withheld. The cause is not established; unknown historical attribution cannot be inferred from the current model.'), '', 'Storage');
483
- if (report.storeCopies.length === 0) {
484
- lines.push(' one store file, no unmerged copies');
485
- }
486
- else {
487
- lines.push(` ${report.storeCopies.length} unmerged store cop${report.storeCopies.length === 1 ? 'y' : 'ies'} beside the canonical snapshot:`);
488
- for (const copy of report.storeCopies) {
489
- lines.push(` ${copy.path}: ${copy.readable ? `${copy.entries} entries, ${copy.offers} offers, ${copy.firings} firings` : `unreadable (${copy.error ?? 'unknown error'})`}`);
490
- }
491
- lines.push(' Nothing reads these. Preserve them and the checkpoint backup for inspection; do not delete snapshots to clear this warning.');
492
- }
493
- lines.push(` event logs folded: ${report.eventLogs.length === 0 ? 'none' : report.eventLogs.map((l) => `${l.machineId} (${l.bytes} bytes)`).join(', ')}`);
494
- for (const [machineId, lag] of Object.entries(report.checkpointLag)) {
495
- lines.push(` checkpoint lag for ${machineId}: ${lag} bytes${lag > 0 ? ' behind the log head' : ''}`);
496
- }
497
- lines.push(report.compactionAdvised
498
- ? ` log size ${mb(report.totalLogBytes)} is past the ${mb(report.compactionAdvisoryBytes)} point where a cold fold stops fitting the context-injection budget. Run \`learning-usage prune\` to consolidate processed events into the snapshot and checkpoint backup, then clear them independently of retention.`
499
- : ` log size ${mb(report.totalLogBytes)} of ${mb(report.compactionAdvisoryBytes)} before compaction is worth running.`);
500
- if (report.rebuildAdvised)
501
- lines.push('', REBUILD_NOTE);
502
- lines.push('', 'Nothing here blocks anything. This is what the record can and cannot currently prove.');
503
- return lines.join('\n');
504
- });
505
- });
506
- // ── root ─────────────────────────────────────────────────────────────────────
507
- exports.learningUsageCommand = new commander_1.Command('learning-usage')
508
- .description('Read and analyse the learning and rule usage record (issue #1103)')
509
- .addCommand(offersCommand)
510
- .addCommand(recordFiringsCommand)
511
- .addCommand(classifyCommand)
512
- .addCommand(candidatesCommand)
513
- .addCommand(standingCommand)
514
- .addCommand(reportCommand)
515
- .addCommand(backfillCommand)
516
- .addCommand(stampIdsCommand)
517
- .addCommand(pruneCommand)
518
- .addCommand(verifyCommand);