fraim 2.0.346 → 2.0.348

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 (32) 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/local-mcp-server/learning-context-builder.js +91 -377
  22. package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
  23. package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
  24. package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
  25. package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
  26. package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
  27. package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
  28. package/dist/src/local-mcp-server/stdio-server.js +0 -48
  29. package/package.json +9 -1
  30. package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
  31. package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
  32. package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEARNING_ENTRY_ID_LINE = exports.LEARNING_ENTRY_ID_REGEX = exports.LEARNING_PRIORITIES = void 0;
4
+ exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
5
+ exports.learningEntryIdLineRegex = learningEntryIdLineRegex;
6
+ exports.generateLearningEntryId = generateLearningEntryId;
7
+ /** Stable learning IDs and headings shared by corpus readers and explicit stamping. */
8
+ const node_crypto_1 = require("node:crypto");
9
+ exports.LEARNING_PRIORITIES = ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'];
10
+ function learningEntryHeadingRegex(flags = '') {
11
+ return new RegExp(`^#{2,}\\s+\\[(${exports.LEARNING_PRIORITIES.join('|')})\\]\\s*(.*)$`, flags);
12
+ }
13
+ /** Generated IDs are deterministic on first stamping; persisted IDs survive title edits. */
14
+ exports.LEARNING_ENTRY_ID_REGEX = /^L-[0-9a-f]{10}$/;
15
+ /** Readers honor previously handwritten IDs as well as generated IDs. */
16
+ exports.LEARNING_ENTRY_ID_LINE = /^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/;
17
+ function learningEntryIdLineRegex() {
18
+ return new RegExp(exports.LEARNING_ENTRY_ID_LINE.source);
19
+ }
20
+ function generateLearningEntryId(fileType, title) {
21
+ const digest = (0, node_crypto_1.createHash)('sha1')
22
+ .update(`${fileType}|${title.trim().replace(/\s+/g, ' ').toLowerCase()}`)
23
+ .digest('hex');
24
+ return `L-${digest.slice(0, 10)}`;
25
+ }
@@ -1,389 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.classifyRecurrence = classifyRecurrence;
4
- exports.resolveStructuralProbe = resolveStructuralProbe;
5
- exports.findRetirementCandidates = findRetirementCandidates;
6
- exports.deriveStanding = deriveStanding;
7
- exports.assessStanding = assessStanding;
8
3
  exports.buildUsageReport = buildUsageReport;
9
- /**
10
- * Issue #1103 — what the usage record lets you conclude.
11
- *
12
- * Three questions, each with an answer the record can prove:
13
- *
14
- * R7 Why did this recur? `ignored` means the entry was in context and did not
15
- * change the outcome, so the fix is the entry. `not-offered` means it never
16
- * reached the agent, so the fix is the delivery. Today a recurrence cannot
17
- * tell those apart, which is why the same lesson gets rewritten when the
18
- * real bug is in the loader.
19
- * R9 Which entries have stopped earning their place under the current model?
20
- * R20 And is there a second, independent signal agreeing before anything is
21
- * recommended for retirement?
22
- *
23
- * Everything here is read-only. R10 and R19: usage data produces recommendations,
24
- * and every change to an entry still passes the existing review gate.
25
- */
26
- const fs_1 = require("fs");
27
- const path_1 = require("path");
4
+ /** Optional historical outcome reporting. */
28
5
  const learning_usage_store_1 = require("./learning-usage-store");
29
- const learning_usage_projection_1 = require("./learning-usage-projection");
30
6
  const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
31
- function daysBetween(from, to) {
32
- const a = new Date(from).getTime();
33
- const b = new Date(to).getTime();
34
- if (Number.isNaN(a) || Number.isNaN(b))
35
- return Number.POSITIVE_INFINITY;
36
- return Math.abs(b - a) / 86_400_000;
37
- }
7
+ function latestTitle(record) { return record.titles.at(-1) ?? record.key; }
38
8
  function firedCount(record) {
39
9
  return learning_usage_store_1.CLOCK_ADVANCING_OUTCOMES.reduce((sum, outcome) => sum + record.fired.byOutcome[outcome], 0);
40
10
  }
41
- function classifyRecurrence(store, options) {
42
- const record = store.entries[options.key];
43
- const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
44
- const base = {
45
- key: options.key,
46
- title: record?.titles[record.titles.length - 1] ?? null,
47
- evidence: {
48
- recurrenceDate: options.date,
49
- recurrenceJob: options.job,
50
- windowDays: options.windowDays,
51
- matchingOffers: [],
52
- offeredTotal: record?.offered.total ?? 0,
53
- firedTotal: record ? firedCount(record) : 0,
54
- },
55
- };
56
- if (record && excluded.has(record.key)) {
57
- return {
58
- ...base, classification: 'undetermined',
59
- diagnosis: 'The available usage evidence cannot establish this recurrence.',
60
- recommendation: 'Review independent source evidence without attributing this recurrence to usage.',
61
- recurrenceBump: false, targetOfFix: 'none',
62
- };
63
- }
64
- if (!record) {
65
- return {
66
- ...base,
67
- classification: 'undetermined',
68
- diagnosis: 'There is no usage record for this entry, so whether it was in context for that job cannot be determined.',
69
- recommendation: 'No usage record exists for this entry yet. Treat this recurrence the way you would have before usage attribution, and re-check after the entry has been offered a few times.',
70
- recurrenceBump: true,
71
- targetOfFix: 'none',
72
- };
73
- }
74
- // "In context for that job" means an offer for the same job inside the window.
75
- // A different job's offer is not evidence: domain filtering is exactly the
76
- // mechanism that can deliver an entry to one job and withhold it from another.
77
- const matchingOffers = record.offered.log.filter((offer) => offer.job === options.job
78
- && offer.aboveThreshold
79
- && daysBetween(offer.date, options.date) <= options.windowDays);
80
- base.evidence.matchingOffers = matchingOffers;
81
- // Absence of a matching offer record is only evidence of absence when the offer
82
- // log actually reaches back over the window. The log is bounded (R16a), so an
83
- // entry offered many times can have had every relevant record trimmed away.
84
- // Reporting `not-offered` there would tell the manager to fix the loader for an
85
- // entry that was in fact delivered, which is the wrong-fix failure R7 exists to
86
- // prevent. The history is complete when nothing has been trimmed, or when the
87
- // oldest retained record predates the window.
88
- const windowStartMs = new Date(options.date).getTime() - options.windowDays * 86_400_000;
89
- const oldestRetained = record.offered.log.length
90
- ? record.offered.log.reduce((min, o) => (o.date < min ? o.date : min), record.offered.log[0].date)
91
- : null;
92
- const historyComplete = record.offered.log.length === record.offered.total
93
- || (oldestRetained !== null && new Date(oldestRetained).getTime() <= windowStartMs);
94
- // #1752 D2, the issue's suggested fix 4. `report` selects an entry into
95
- // `ignoredWhileInContext` on `byOutcome.ignored > 0`, which asserts the entry
96
- // was in context; `classify` was answering `not-offered` for the same entry,
97
- // which asserts the opposite, with no hedge. The #1740 postmortem states in
98
- // first-hand prose that L-abc958f241 "was above threshold and in context".
99
- // When the two halves of one record disagree, neither is evidence.
100
- const ignoredInWindow = record.fired.log.filter((firing) => firing.outcome === 'ignored' && daysBetween(firing.date, options.date) <= options.windowDays);
101
- // #1752 D1. Absence of an offer for this job is only evidence when the record
102
- // can resolve this job at all. Measured: 2067 of 2354 retained offer records
103
- // carry `session` or `__freeform__`, because `fraim_connect` writes one offer
104
- // per entry per session and a plain newest-N bound evicted the job-labelled
105
- // ones within about a day. Asking about a job the store has never retained an
106
- // offer for is a gap in the record, not a delivery failure.
107
- const jobCoverage = Object.values(store.entries).some((entry) => !excluded.has(entry.key) && entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
108
- if (matchingOffers.length > 0) {
109
- return {
110
- ...base,
111
- classification: 'ignored',
112
- diagnosis: `This entry was in context for ${options.job} when the mistake recurred, and being in context did not change what happened.`,
113
- recommendation: 'The entry reached the agent and did not change the outcome, so the entry itself is what needs work. Fix its wording so the required action is unambiguous, or fix its placement so it is read at the moment the decision is made. Do not record another recurrence as if the lesson were simply not learned yet.',
114
- recurrenceBump: true,
115
- targetOfFix: 'entry',
116
- };
117
- }
118
- if (ignoredInWindow.length > 0) {
119
- return {
120
- ...base,
121
- classification: 'undetermined',
122
- diagnosis: `The two halves of this entry's record disagree: it carries ${ignoredInWindow.length} attested \`ignored\` outcome${ignoredInWindow.length === 1 ? '' : 's'} inside the window, which says it was in context and the work went against it, while the offer log holds no matching offer for ${options.job}, which says it was never delivered. Both cannot be true.`,
123
- recommendation: 'Do not act on either reading. An `ignored` attestation and an empty offer log are contradictory evidence about the same event, so the record cannot say whether the entry or the delivery is at fault. Run `learning-usage verify` to see whether this entry carries an integrity flag, and treat the recurrence on its own terms until the record agrees with itself.',
124
- recurrenceBump: true,
125
- targetOfFix: 'none',
126
- };
127
- }
128
- if (!jobCoverage) {
129
- return {
130
- ...base,
131
- classification: 'undetermined',
132
- diagnosis: `No offer anywhere in the record carries the job label ${options.job} inside this window, so the per-job offer record has no resolution for that job and cannot say whether this entry was in context for it.`,
133
- recommendation: 'The offer record cannot answer this question for this job, so absence of a match is not evidence of absence. Do not conclude a delivery problem. Re-ask once the job has run with offer recording in place, or widen the window to one the record covers.',
134
- recurrenceBump: true,
135
- targetOfFix: 'none',
136
- };
137
- }
138
- if (!historyComplete) {
139
- return {
140
- ...base,
141
- classification: 'undetermined',
142
- diagnosis: `Whether this entry was in context for ${options.job} cannot be determined: it has been offered ${record.offered.total} times, but the retained offer log no longer reaches back over the window being asked about.`,
143
- recommendation: 'The offer history for this window has been pruned, so absence of a record is not evidence that the entry was not delivered. Do not conclude a delivery problem from this. Either widen the retained offer log for this corpus, or ask again about a more recent recurrence.',
144
- recurrenceBump: true,
145
- targetOfFix: 'none',
146
- };
147
- }
148
- return {
149
- ...base,
150
- classification: 'not-offered',
151
- diagnosis: `This entry was never in context for ${options.job}, so it had no opportunity to change what happened.`,
152
- recommendation: 'The entry never reached the agent, so the delivery is what is wrong and the entry text is fine. Check the entry\'s domain and its threshold in the loader: widen the domain if the lesson applies beyond the jobs it is currently scoped to. Do not raise the recurrence count, because the count measures how often the lesson failed to stick and this is not an instance of that.',
153
- recurrenceBump: false,
154
- targetOfFix: 'delivery',
155
- };
156
- }
157
- const UNDETERMINED = {
158
- signal: 'undetermined',
159
- detail: 'The structural signal could not be determined: the corpus hygiene detector is not available in this workspace, so whether the thing this entry guards still exists is unknown.',
160
- };
161
- /**
162
- * R20's structural half comes from issue #1100's inert and orphan detection, which
163
- * answers a different question from usage: is the thing this entry guards still
164
- * there? The two specs meet at exactly this point, and #1103 reads that detector
165
- * rather than re-deriving it.
166
- *
167
- * When the detector is absent the probe says so plainly. That is not a fallback
168
- * for convenience: R20's third acceptance criterion requires the undetermined case
169
- * to be stated rather than presented as if both signals agreed.
170
- */
171
- /**
172
- * The only module this integration loads. Naming it exactly, rather than accepting
173
- * any path, keeps `require` of a caller-supplied string from becoming a general
174
- * arbitrary-module loader: a mistyped or hostile `--detector` value fails closed to
175
- * the undetermined signal instead of executing.
176
- */
177
- const STRUCTURAL_DETECTOR_FILENAME = 'instruction-hygiene.js';
178
- function resolveStructuralProbe(detectorPath) {
179
- if (!detectorPath || !(0, fs_1.existsSync)(detectorPath)) {
180
- return () => UNDETERMINED;
181
- }
182
- if ((0, path_1.basename)(detectorPath) !== STRUCTURAL_DETECTOR_FILENAME) {
183
- return () => ({
184
- signal: 'undetermined',
185
- detail: `The structural signal could not be determined: the detector path must end in ${STRUCTURAL_DETECTOR_FILENAME}, and this one does not.`,
186
- });
187
- }
188
- let detector;
189
- try {
190
- // eslint-disable-next-line @typescript-eslint/no-var-requires
191
- detector = require(detectorPath);
192
- }
193
- catch {
194
- return () => UNDETERMINED;
195
- }
196
- if (!detector || typeof detector.analyzeInstructionHygiene !== 'function') {
197
- return () => UNDETERMINED;
198
- }
199
- return (key, title) => {
200
- try {
201
- const report = detector.analyzeInstructionHygiene({ root: process.cwd() });
202
- const findings = report?.findings ?? [];
203
- const needle = (title ?? key).toLowerCase();
204
- const inert = findings.find((f) => (f.type === 'inert' || f.type === 'orphan')
205
- && `${f.file ?? ''} ${f.message ?? ''}`.toLowerCase().includes(needle));
206
- if (inert) {
207
- return { signal: 'guard-absent', detail: `The corpus hygiene detector reports this as ${inert.type}: ${inert.message ?? 'no consumer found'}.` };
208
- }
209
- return { signal: 'still-referenced', detail: 'The corpus hygiene detector finds the surface this entry guards is still referenced.' };
210
- }
211
- catch {
212
- return UNDETERMINED;
213
- }
214
- };
215
- }
216
- function offersUnderModel(record, model) {
217
- if (!model)
218
- return 0;
219
- return record.offered.byModel[model] ?? 0;
220
- }
221
- function firingsUnderModel(record, model) {
222
- if (!model)
223
- return 0;
224
- return record.fired.byModel[model] ?? 0;
225
- }
226
- /**
227
- * #1752 C4 - is this entry's per-model attribution unknown, as opposed to zero?
228
- *
229
- * All 781 retained firing records in the live store carry `agent: null,
230
- * model: null`, because `attest-learning-firings.md` invoked `record-firings`
231
- * with no `--agent` or `--model`. `firedUnderCurrentModel` was therefore
232
- * structurally 0 for every entry, which is why `candidates` proposed 31
233
- * retirements: every entry clearing the offer threshold became one, however hard
234
- * it was working. Fixing the `currentModel` resolution alone would not have
235
- * changed a single one of them.
236
- *
237
- * The predicate is deliberately narrower than "fired at all". An entry whose only
238
- * outcomes are `ignored` or `not-applicable` has an empty `byModel` because those
239
- * outcomes correctly do not bucket by model; that is information, not a gap. Only
240
- * a clock-advancing firing that produced no bucket means the model went
241
- * unrecorded.
242
- */
243
- function modelAttributionUnknown(record) {
244
- return (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record);
245
- }
246
- function earlierModelFirings(record, currentModel) {
247
- return Object.entries(record.fired.byModel)
248
- .filter(([model]) => model !== currentModel)
249
- .map(([model, count]) => ({ model, count }))
250
- .sort((a, b) => b.count - a.count);
251
- }
252
- function latestTitle(record) {
253
- return record.titles[record.titles.length - 1] ?? record.key;
254
- }
255
- function findRetirementCandidates(store, options) {
256
- const currentModel = (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel);
257
- const threshold = options.limits.retirementOfferThreshold;
258
- const probe = options.structuralProbe ?? resolveStructuralProbe(options.structuralSignalPath);
259
- // #1752 C1: retirement candidacy is computed per model, so with no model there
260
- // is no question to answer. An empty list with the reason stated is the honest
261
- // answer; computing against a null bucket is how defect 3 produced 31
262
- // proposals nobody could act on.
263
- if (!currentModel) {
264
- return {
265
- currentModel: null,
266
- offerThreshold: threshold,
267
- candidates: [],
268
- notCandidates: [],
269
- reason: 'The current model did not resolve, so no per-model conclusion can be drawn. Pass --model <model>, or set FRAIM_ACTIVE_MODEL. The current model is never inferred from the usage record, because the record is shared by concurrent sessions from more than one agent and would report whichever wrote last.',
270
- };
271
- }
272
- const candidates = [];
273
- const notCandidates = [];
274
- const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
275
- for (const record of Object.values(store.entries)) {
276
- if (record.family !== 'learning')
277
- continue;
278
- const title = latestTitle(record);
279
- if (excluded.has(record.key) || modelAttributionUnknown(record)) {
280
- notCandidates.push({ key: record.key, title,
281
- reason: excluded.get(record.key) ?? 'Model attribution is incomplete; unknown use is not zero use.' });
282
- continue;
283
- }
284
- const offered = offersUnderModel(record, currentModel);
285
- const fired = firingsUnderModel(record, currentModel);
286
- if (fired > 0) {
287
- notCandidates.push({ key: record.key, title, reason: `It fired ${fired} time${fired === 1 ? '' : 's'} under the current model, so it is still earning its place.` });
288
- continue;
289
- }
290
- if (offered < threshold) {
291
- notCandidates.push({
292
- key: record.key,
293
- title,
294
- reason: `Only ${offered} offer${offered === 1 ? '' : 's'} under the current model, which is below the threshold of ${threshold}. That is more likely to mean the work that triggers it has not come up than that it is no longer needed.`,
295
- });
296
- continue;
297
- }
298
- const structural = probe(record.key, title);
299
- // R20: two independent signals before recommending removal. Usage alone says
300
- // "unused"; only usage plus an absent guard says "obsolete".
301
- const recommendation = structural.signal === 'guard-absent' ? 'retire' : 'keep-and-reword';
302
- candidates.push({
303
- key: record.key,
304
- title,
305
- offeredUnderCurrentModel: offered,
306
- firedUnderCurrentModel: fired,
307
- firedUnderEarlierModels: earlierModelFirings(record, currentModel),
308
- currentModel,
309
- structural: structural.signal,
310
- structuralDetail: structural.detail,
311
- recommendation,
312
- });
313
- }
314
- candidates.sort((a, b) => b.offeredUnderCurrentModel - a.offeredUnderCurrentModel);
315
- return { currentModel, offerThreshold: threshold, candidates, notCandidates };
316
- }
317
- /**
318
- * The window in which a firing still counts as recent for standing purposes.
319
- * Longer than the Brain's 30-day "fired recently" glance signal, because standing
320
- * is a slower-moving decision than a dot colour.
321
- */
322
- const STANDING_RECENT_DAYS = 90;
323
- function daysSince(date, now) {
324
- if (!date)
325
- return Number.POSITIVE_INFINITY;
326
- const parsed = new Date(date).getTime();
327
- if (Number.isNaN(parsed))
328
- return Number.POSITIVE_INFINITY;
329
- return (now.getTime() - parsed) / 86_400_000;
330
- }
331
- function deriveStanding(record, options) {
332
- const now = options.now ?? new Date();
333
- const fired = firedCount(record);
334
- const offered = record.offered.aboveThreshold;
335
- const title = latestTitle(record);
336
- const threshold = options.limits.retirementOfferThreshold;
337
- if ((0, learning_usage_eligibility_1.countEvidenceProblem)(record, options.offersEraStart)) {
338
- return { key: record.key, title, standing: options.currentStanding?.(record.key) ?? 'standard',
339
- reason: 'No usage-based change is supported.', offered: null, fired: null, recommendation: null };
340
- }
341
- let standing;
342
- let reason;
343
- if (fired > 0 && daysSince(record.fired.lastFiredAt, now) <= STANDING_RECENT_DAYS) {
344
- standing = 'promoted';
345
- reason = `Fired ${fired} time${fired === 1 ? '' : 's'}, most recently ${record.fired.lastFiredAt}. It is changing outcomes, so it stays fully in context.`;
346
- }
347
- else if (offered >= threshold && fired === 0) {
348
- standing = 'demoted';
349
- reason = `Offered ${offered} times as active guidance and never changed an outcome. Demoted rather than removed: it stays readable and can earn its way back the first time it fires.`;
350
- }
351
- else if (fired > 0) {
352
- standing = 'standard';
353
- reason = `Fired ${fired} time${fired === 1 ? '' : 's'}, but not within the last ${STANDING_RECENT_DAYS} days. Held at standard while it is neither proving itself nor failing to.`;
354
- }
355
- else {
356
- standing = 'standard';
357
- reason = offered === 0
358
- ? 'No usage record yet. An entry FRAIM has not measured is held at standard rather than judged.'
359
- : `Offered ${offered} times, which is below the ${threshold} needed to conclude anything from silence.`;
360
- }
361
- const current = options.currentStanding ? options.currentStanding(record.key) : 'standard';
362
- const recommendation = standing === current ? null : standing === 'promoted' ? 'promote' : standing === 'demoted' ? 'demote' : null;
363
- return { key: record.key, title, standing, reason, offered, fired, recommendation };
364
- }
365
- /**
366
- * Assess every entry with a usage record. Read-only: this recommends, and
367
- * `sleep-on-learnings` applies under the existing approval gate (R10, R19).
368
- */
369
- function assessStanding(store, options) {
370
- const now = options.now ?? new Date();
371
- const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
372
- const assessments = Object.values(store.entries)
373
- .filter((r) => r.family === 'learning' && !excluded.has(r.key))
374
- .map((r) => deriveStanding(r, { ...options, now, offersEraStart: store.offersEraStart }))
375
- .sort((a, b) => (b.fired ?? 0) - (a.fired ?? 0) || (b.offered ?? 0) - (a.offered ?? 0));
376
- return {
377
- generatedAt: now.toISOString(),
378
- assessments,
379
- changes: assessments.filter((a) => a.recommendation !== null),
380
- };
381
- }
382
11
  function toReportEntry(record) {
383
12
  return {
384
13
  key: record.key,
385
14
  title: latestTitle(record),
386
- offered: record.offered.total,
387
15
  fired: firedCount(record),
388
16
  ignored: record.fired.byOutcome.ignored,
389
17
  lastFired: record.fired.lastFiredAt,
@@ -392,49 +20,23 @@ function toReportEntry(record) {
392
20
  };
393
21
  }
394
22
  /**
395
- * The answer to "which of my learnings are still earning their place?".
23
+ * Historical outcome observations without fresh effectiveness or rank judgments.
396
24
  *
397
25
  * Only entries with a usage record appear. An entry FRAIM has never measured is
398
26
  * absent rather than listed with zeroes, because a fabricated zero reads as
399
27
  * evidence of disuse when it is only evidence of no data.
400
28
  */
401
- function buildUsageReport(store, options) {
29
+ function buildUsageReport(store, options = {}) {
402
30
  const now = options.now ?? new Date();
403
- const limit = options.limit ?? 10;
404
31
  const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
405
- const allRecords = Object.values(store.entries).filter((r) => r.family === 'learning');
406
- const learningRecords = allRecords.filter(r => !excluded.has(r.key));
407
- const complete = excluded.size === 0 && (!store.evidence || store.evidence.status === 'reliable');
408
- const offered = learningRecords.filter((r) => r.offered.total > 0);
409
- const fired = learningRecords.filter((r) => firedCount(r) > 0);
410
- const neverFired = offered.filter((r) => firedCount(r) === 0);
411
- const ignored = learningRecords.filter((r) => r.fired.byOutcome.ignored > 0);
32
+ const records = Object.values(store.entries).filter(record => record.family === 'learning' && !excluded.has(record.key));
33
+ const historicalOutcomes = records.filter(record => record.fired.total > 0)
34
+ .sort((a, b) => (b.fired.lastAttestedAt ?? '').localeCompare(a.fired.lastAttestedAt ?? ''))
35
+ .slice(0, options.limit ?? 10).map(toReportEntry);
412
36
  return {
413
- generatedAt: now.toISOString(),
414
- windowDays: options.windowDays,
415
- currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
416
- integrityFlagged: excluded.size,
417
- totals: {
418
- entriesOffered: complete ? offered.length : null,
419
- entriesWithFirings: complete ? fired.length : null,
420
- entriesNeverFired: complete ? neverFired.length : null,
421
- },
422
- workingHardest: fired
423
- .sort((a, b) => {
424
- const byCount = firedCount(b) - firedCount(a);
425
- if (byCount !== 0)
426
- return byCount;
427
- return (b.fired.lastFiredAt ?? '').localeCompare(a.fired.lastFiredAt ?? '');
428
- })
429
- .slice(0, limit)
430
- .map(toReportEntry),
431
- neverFired: neverFired
432
- .sort((a, b) => b.offered.total - a.offered.total)
433
- .slice(0, limit)
434
- .map(toReportEntry),
435
- ignoredWhileInContext: ignored
436
- .sort((a, b) => b.fired.byOutcome.ignored - a.fired.byOutcome.ignored)
437
- .slice(0, limit)
438
- .map(toReportEntry),
37
+ generatedAt: now.toISOString(), historical: true,
38
+ lastObservedAt: records.map(record => record.fired.lastAttestedAt)
39
+ .filter((date) => Boolean(date) && Number.isFinite(Date.parse(date))).sort().at(-1) ?? null,
40
+ excludedCount: excluded.size, historicalOutcomes,
439
41
  };
440
42
  }
@@ -7,28 +7,22 @@ const learning_usage_store_1 = require("./learning-usage-store");
7
7
  const validCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
8
8
  /** Missing attribution is unknown, including a known bucket beside unattributed history. */
9
9
  function modelAttributionIncomplete(record) {
10
- const buckets = [record.offered.byModel, record.fired.byModel];
10
+ const buckets = [record.fired.byModel];
11
11
  if (buckets.some(map => !map || Object.entries(map).some(([name, n]) => !validCount(n) || !(0, learning_usage_store_1.normalizeModelName)(name) || (0, learning_usage_store_1.normalizeModelName)(name) !== name)))
12
12
  return true;
13
- return Object.values(record.offered.byModel).reduce((a, b) => a + b, 0) !== record.offered.total
14
- || Object.values(record.fired.byModel).reduce((a, b) => a + b, 0)
15
- !== record.fired.byOutcome.applied + record.fired.byOutcome.prevented;
13
+ return Object.values(record.fired.byModel).reduce((a, b) => a + b, 0)
14
+ !== record.fired.byOutcome.applied + record.fired.byOutcome.prevented;
16
15
  }
17
16
  /** Validate counts independently of a cached integrity flag. Never alter the record. */
18
- function countEvidenceProblem(record, offersEraStart) {
19
- const counts = [record.offered.total, record.offered.aboveThreshold, record.fired.total,
20
- ...Object.values(record.offered.byModel), ...Object.values(record.fired.byModel),
17
+ function countEvidenceProblem(record) {
18
+ const counts = [record.fired.total,
19
+ ...Object.values(record.fired.byModel),
21
20
  ...Object.values(record.fired.byOutcome)];
22
21
  if (counts.some(n => !validCount(n)) || learning_usage_store_1.FIRING_OUTCOMES.some(outcome => !validCount(record.fired.byOutcome[outcome]))
23
- || record.offered.aboveThreshold > record.offered.total
24
22
  || Object.values(record.fired.byOutcome).reduce((a, b) => a + b, 0) !== record.fired.total) {
25
23
  return 'Usage counters are inconsistent.';
26
24
  }
27
- const violation = record.integrity ?? (0, learning_usage_store_1.integrityViolation)(record, offersEraStart);
28
- if (!offersEraStart && record.offered.total > 0 && record.fired.total > record.offered.total) {
29
- return 'Excess uses lack provenance establishing the offer-recording era.';
30
- }
31
- return violation ? violation.detail : null;
25
+ return null;
32
26
  }
33
27
  /** Read-time exclusions follow the actual record, never ambient UI or model state. */
34
28
  function usageEvidenceExclusions(store) {
@@ -37,7 +31,7 @@ function usageEvidenceExclusions(store) {
37
31
  excluded.set(key, 'Unreadable usage record.');
38
32
  const byTitle = new Map();
39
33
  for (const record of Object.values(store.entries)) {
40
- const problem = countEvidenceProblem(record, store.offersEraStart);
34
+ const problem = countEvidenceProblem(record);
41
35
  if (store.evidence?.status === 'unavailable')
42
36
  excluded.set(record.key, 'Usage history is unavailable.');
43
37
  else if (problem)
@@ -7,7 +7,7 @@ exports.resolveCurrentModel = resolveCurrentModel;
7
7
  * Issue #1103 — projecting the usage record into the shapes its readers need.
8
8
  *
9
9
  * The store owns writing and persistence; this owns turning what is stored into
10
- * what a scorer, a surface, or a report asks for. Split out because those are two
10
+ * what a human surface or report asks for. Split out because those are two
11
11
  * jobs with different reasons to change: the record's schema changes when the
12
12
  * privacy or retention contract changes, and these projections change when a
13
13
  * consumer needs a different view.
@@ -18,13 +18,11 @@ const learning_usage_store_1 = require("./learning-usage-store");
18
18
  const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
19
19
  function toLookupValue(record) {
20
20
  return {
21
+ historical: true, countingStatus: 'deferred', lastObservedAt: record.fired.lastAttestedAt && Number.isFinite(Date.parse(record.fired.lastAttestedAt)) ? record.fired.lastAttestedAt : null,
21
22
  key: record.key,
22
- offered: record.offered.total,
23
- offeredAboveThreshold: record.offered.aboveThreshold,
24
23
  fired: record.fired.byOutcome.prevented + record.fired.byOutcome.applied,
25
24
  firedTotalAttestations: record.fired.total,
26
25
  lastFired: record.fired.lastFiredAt,
27
- lastOffered: record.offered.lastOfferedAt,
28
26
  firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
29
27
  byOutcome: record.fired.byOutcome,
30
28
  log: record.fired.log,
@@ -32,14 +30,14 @@ function toLookupValue(record) {
32
30
  }
33
31
  /**
34
32
  * A lookup keyed by every identifier that resolves to a record: the entry id, and
35
- * every title the entry has been known by. That is what lets a scorer look up an
33
+ * every title the entry has been known by. That lets a historical surface look up an
36
34
  * entry by whichever handle it has.
37
35
  */
38
36
  function buildUsageLookup(store) {
39
37
  const lookup = new Map();
40
38
  const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
41
39
  for (const record of Object.values(store.entries)) {
42
- if (excluded.has(record.key))
40
+ if (excluded.has(record.key) || record.fired.total === 0)
43
41
  continue;
44
42
  const value = toLookupValue(record);
45
43
  lookup.set(record.key, value);
@@ -66,22 +64,7 @@ function lookupUsage(lookup, id, fileType, title) {
66
64
  return resolved;
67
65
  return identity.source === 'id' ? lookup.get((0, learning_usage_store_1.titleUsageKey)(fileType, title)) : undefined;
68
66
  }
69
- /**
70
- * Issue #1752 C1 — "the current model" comes from the caller, never from the store.
71
- *
72
- * This used to return the model of the globally most-recent offer. The store is
73
- * shared by concurrent sessions from two machines and two agents, so that reports
74
- * whichever agent wrote last. On the sleep-on-learnings run of 2026-09-18 it
75
- * answered `gpt-5` while the session was Claude Opus 5, and since retirement
76
- * candidacy is computed per model, every per-model conclusion in that run was
77
- * computed against a model that was not running.
78
- *
79
- * Resolution order is the explicit flag, then `FRAIM_ACTIVE_MODEL`, then null.
80
- * That follows the existing `FRAIM_ACTIVE_USER_EMAIL` convention, which is
81
- * read-only and operator-set, so it introduces no new configuration mechanism.
82
- * Null is reported as `unknown` and suppresses every per-model conclusion rather
83
- * than being computed against a null bucket.
84
- */
67
+ /** Model identity comes from the caller or explicit environment, never historical observations. */
85
68
  function resolveCurrentModel(explicit) {
86
69
  return (0, learning_usage_store_1.normalizeModelName)(explicit) ?? (0, learning_usage_store_1.normalizeModelName)(process.env.FRAIM_ACTIVE_MODEL);
87
70
  }