fraim 2.0.338 → 2.0.339
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/dist/src/local-mcp-server/learning-context-builder.js +49 -8
- package/dist/src/local-mcp-server/learning-usage-analysis.js +79 -2
- package/dist/src/local-mcp-server/learning-usage-attestation.js +9 -2
- package/dist/src/local-mcp-server/learning-usage-command.js +132 -22
- package/dist/src/local-mcp-server/learning-usage-events.js +261 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +589 -82
- package/dist/src/local-mcp-server/learning-usage-verify.js +222 -0
- package/dist/src/local-mcp-server/stdio-server.js +8 -3
- package/package.json +1 -1
|
@@ -890,8 +890,9 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
890
890
|
out.push({
|
|
891
891
|
// The store owns key construction. Building the composite key by hand here
|
|
892
892
|
// would be a second definition of what an entry's identity is, and the two
|
|
893
|
-
// would drift the first time normalization changed.
|
|
894
|
-
|
|
893
|
+
// would drift the first time normalization changed. Issue #1752 A1 made
|
|
894
|
+
// that one function rather than four copies of the same expression.
|
|
895
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: current.id, fileType: file.fileType, title: current.title }).key,
|
|
895
896
|
family: 'learning',
|
|
896
897
|
id: current.id,
|
|
897
898
|
title: current.title,
|
|
@@ -939,9 +940,27 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
939
940
|
* listed. `aboveThreshold` is what separates active guidance from the dormant
|
|
940
941
|
* rollup. Nothing here depends on the agent reading anything.
|
|
941
942
|
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
943
|
+
* ## Issue #1752 A2 — one lesson, one offer, stamped or not
|
|
944
|
+
*
|
|
945
|
+
* This deduped by usage key, which sounds like the same thing as the context
|
|
946
|
+
* reader's rule and is not. `resolveOrgLearningFileTiers` deliberately returns
|
|
947
|
+
* both the org pack copy and the repo-local copy of an org family, because #1002
|
|
948
|
+
* R4 requires that a promoted entry not hide the rest of its family. The context
|
|
949
|
+
* reader then merges those tiers with `mergeLearningEntriesAcrossTiers`, keyed on
|
|
950
|
+
* the normalized title, so the agent sees one lesson. Keying the offer record on
|
|
951
|
+
* `id ?? titleUsageKey(...)` instead meant that a lesson carrying an `**Id**` line
|
|
952
|
+
* in one tier and not the other produced two keys, and both were recorded.
|
|
953
|
+
*
|
|
954
|
+
* That is defect 4 in #1752, and it is why `standing --changes-only` could
|
|
955
|
+
* recommend promoting and demoting the same lesson in one response: the
|
|
956
|
+
* title-keyed twin collected the offers while the id-keyed one collected the
|
|
957
|
+
* firings (the firing path never split, because `buildEntryIndex` dedups by
|
|
958
|
+
* identity first-wins). Measured live: five titles emitting two keys each in a
|
|
959
|
+
* single derived offer batch.
|
|
960
|
+
*
|
|
961
|
+
* So group by the same normalized title the context reader groups by, resolve one
|
|
962
|
+
* identity per group preferring whichever tier carries an id, and let the later
|
|
963
|
+
* tier win on content exactly as before.
|
|
945
964
|
*/
|
|
946
965
|
function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
947
966
|
const roots = getLearningRoots(workspaceRoot);
|
|
@@ -950,13 +969,35 @@ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
|
950
969
|
const usageClock = resolveUsageClock(workspaceRoot);
|
|
951
970
|
const now = new Date();
|
|
952
971
|
const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
|
|
953
|
-
|
|
972
|
+
// Grouped by family plus normalized title, which is what `titleUsageKey`
|
|
973
|
+
// already computes. Two families holding the same title are two lessons, and
|
|
974
|
+
// that is the existing rule rather than a new one.
|
|
975
|
+
const byLesson = new Map();
|
|
954
976
|
for (const file of [...resolved.l2, ...resolved.l1]) {
|
|
955
977
|
for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
|
|
956
|
-
|
|
978
|
+
const lessonKey = (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
|
|
979
|
+
const group = byLesson.get(lessonKey);
|
|
980
|
+
if (group)
|
|
981
|
+
group.push(entry);
|
|
982
|
+
else
|
|
983
|
+
byLesson.set(lessonKey, [entry]);
|
|
957
984
|
}
|
|
958
985
|
}
|
|
959
|
-
|
|
986
|
+
const out = [];
|
|
987
|
+
for (const group of byLesson.values()) {
|
|
988
|
+
// Later tier wins for content, unchanged precedence.
|
|
989
|
+
const winner = group[group.length - 1];
|
|
990
|
+
// The id comes from whichever tier carries one, because `stamp-ids` has not
|
|
991
|
+
// reached the org and manager pack homes for every corpus yet. Latest wins
|
|
992
|
+
// when more than one tier is stamped.
|
|
993
|
+
const id = group.reduce((found, entry) => entry.id ?? found, null);
|
|
994
|
+
out.push({
|
|
995
|
+
...winner,
|
|
996
|
+
id,
|
|
997
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType: winner.fileType, title: winner.title }).key,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return out;
|
|
960
1001
|
}
|
|
961
1002
|
/**
|
|
962
1003
|
* Issue #1103 R3: the auto-loaded Team Context rule files.
|
|
@@ -81,6 +81,20 @@ function classifyRecurrence(store, options) {
|
|
|
81
81
|
: null;
|
|
82
82
|
const historyComplete = record.offered.log.length === record.offered.total
|
|
83
83
|
|| (oldestRetained !== null && new Date(oldestRetained).getTime() <= windowStartMs);
|
|
84
|
+
// #1752 D2, the issue's suggested fix 4. `report` selects an entry into
|
|
85
|
+
// `ignoredWhileInContext` on `byOutcome.ignored > 0`, which asserts the entry
|
|
86
|
+
// was in context; `classify` was answering `not-offered` for the same entry,
|
|
87
|
+
// which asserts the opposite, with no hedge. The #1740 postmortem states in
|
|
88
|
+
// first-hand prose that L-abc958f241 "was above threshold and in context".
|
|
89
|
+
// When the two halves of one record disagree, neither is evidence.
|
|
90
|
+
const ignoredInWindow = record.fired.log.filter((firing) => firing.outcome === 'ignored' && daysBetween(firing.date, options.date) <= options.windowDays);
|
|
91
|
+
// #1752 D1. Absence of an offer for this job is only evidence when the record
|
|
92
|
+
// can resolve this job at all. Measured: 2067 of 2354 retained offer records
|
|
93
|
+
// carry `session` or `__freeform__`, because `fraim_connect` writes one offer
|
|
94
|
+
// per entry per session and a plain newest-N bound evicted the job-labelled
|
|
95
|
+
// ones within about a day. Asking about a job the store has never retained an
|
|
96
|
+
// offer for is a gap in the record, not a delivery failure.
|
|
97
|
+
const jobCoverage = Object.values(store.entries).some((entry) => entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
|
|
84
98
|
if (matchingOffers.length > 0) {
|
|
85
99
|
return {
|
|
86
100
|
...base,
|
|
@@ -91,6 +105,26 @@ function classifyRecurrence(store, options) {
|
|
|
91
105
|
targetOfFix: 'entry',
|
|
92
106
|
};
|
|
93
107
|
}
|
|
108
|
+
if (ignoredInWindow.length > 0) {
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
classification: 'undetermined',
|
|
112
|
+
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.`,
|
|
113
|
+
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.',
|
|
114
|
+
recurrenceBump: true,
|
|
115
|
+
targetOfFix: 'none',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (!jobCoverage) {
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
classification: 'undetermined',
|
|
122
|
+
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.`,
|
|
123
|
+
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.',
|
|
124
|
+
recurrenceBump: true,
|
|
125
|
+
targetOfFix: 'none',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
94
128
|
if (!historyComplete) {
|
|
95
129
|
return {
|
|
96
130
|
...base,
|
|
@@ -179,6 +213,26 @@ function firingsUnderModel(record, model) {
|
|
|
179
213
|
return 0;
|
|
180
214
|
return record.fired.byModel[model] ?? 0;
|
|
181
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* #1752 C4 - is this entry's per-model attribution unknown, as opposed to zero?
|
|
218
|
+
*
|
|
219
|
+
* All 781 retained firing records in the live store carry `agent: null,
|
|
220
|
+
* model: null`, because `attest-learning-firings.md` invoked `record-firings`
|
|
221
|
+
* with no `--agent` or `--model`. `firedUnderCurrentModel` was therefore
|
|
222
|
+
* structurally 0 for every entry, which is why `candidates` proposed 31
|
|
223
|
+
* retirements: every entry clearing the offer threshold became one, however hard
|
|
224
|
+
* it was working. Fixing the `currentModel` resolution alone would not have
|
|
225
|
+
* changed a single one of them.
|
|
226
|
+
*
|
|
227
|
+
* The predicate is deliberately narrower than "fired at all". An entry whose only
|
|
228
|
+
* outcomes are `ignored` or `not-applicable` has an empty `byModel` because those
|
|
229
|
+
* outcomes correctly do not bucket by model; that is information, not a gap. Only
|
|
230
|
+
* a clock-advancing firing that produced no bucket means the model went
|
|
231
|
+
* unrecorded.
|
|
232
|
+
*/
|
|
233
|
+
function modelAttributionUnknown(record) {
|
|
234
|
+
return firedCount(record) > 0 && Object.keys(record.fired.byModel).length === 0;
|
|
235
|
+
}
|
|
182
236
|
function earlierModelFirings(record, currentModel) {
|
|
183
237
|
return Object.entries(record.fired.byModel)
|
|
184
238
|
.filter(([model]) => model !== currentModel)
|
|
@@ -189,9 +243,22 @@ function latestTitle(record) {
|
|
|
189
243
|
return record.titles[record.titles.length - 1] ?? record.key;
|
|
190
244
|
}
|
|
191
245
|
function findRetirementCandidates(store, options) {
|
|
192
|
-
const currentModel =
|
|
246
|
+
const currentModel = (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel);
|
|
193
247
|
const threshold = options.limits.retirementOfferThreshold;
|
|
194
248
|
const probe = options.structuralProbe ?? resolveStructuralProbe(options.structuralSignalPath);
|
|
249
|
+
// #1752 C1: retirement candidacy is computed per model, so with no model there
|
|
250
|
+
// is no question to answer. An empty list with the reason stated is the honest
|
|
251
|
+
// answer; computing against a null bucket is how defect 3 produced 31
|
|
252
|
+
// proposals nobody could act on.
|
|
253
|
+
if (!currentModel) {
|
|
254
|
+
return {
|
|
255
|
+
currentModel: null,
|
|
256
|
+
offerThreshold: threshold,
|
|
257
|
+
candidates: [],
|
|
258
|
+
notCandidates: [],
|
|
259
|
+
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.',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
195
262
|
const candidates = [];
|
|
196
263
|
const notCandidates = [];
|
|
197
264
|
for (const record of Object.values(store.entries)) {
|
|
@@ -204,6 +271,14 @@ function findRetirementCandidates(store, options) {
|
|
|
204
271
|
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.` });
|
|
205
272
|
continue;
|
|
206
273
|
}
|
|
274
|
+
if (modelAttributionUnknown(record)) {
|
|
275
|
+
notCandidates.push({
|
|
276
|
+
key: record.key,
|
|
277
|
+
title,
|
|
278
|
+
reason: `Per-model attribution is unknown for this entry: it fired ${firedCount(record)} time${firedCount(record) === 1 ? '' : 's'} and no firing carries a model, so how hard it is working under ${currentModel} cannot be measured. Held back rather than counted as zero.`,
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
207
282
|
if (offered < threshold) {
|
|
208
283
|
notCandidates.push({
|
|
209
284
|
key: record.key,
|
|
@@ -297,6 +372,7 @@ function toReportEntry(record) {
|
|
|
297
372
|
title: latestTitle(record),
|
|
298
373
|
offered: record.offered.total,
|
|
299
374
|
fired: firedCount(record),
|
|
375
|
+
ignored: record.fired.byOutcome.ignored,
|
|
300
376
|
lastFired: record.fired.lastFiredAt,
|
|
301
377
|
firedUnder: Object.keys(record.fired.byModel),
|
|
302
378
|
recentLog: record.fired.log,
|
|
@@ -320,7 +396,8 @@ function buildUsageReport(store, options) {
|
|
|
320
396
|
return {
|
|
321
397
|
generatedAt: now.toISOString(),
|
|
322
398
|
windowDays: options.windowDays,
|
|
323
|
-
currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(
|
|
399
|
+
currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
|
|
400
|
+
integrityFlagged: learningRecords.filter((r) => Boolean(r.integrity)).length,
|
|
324
401
|
totals: {
|
|
325
402
|
entriesOffered: offered.length,
|
|
326
403
|
entriesWithFirings: fired.length,
|
|
@@ -48,7 +48,9 @@ function buildEntryIndex(workspaceRoot, userId) {
|
|
|
48
48
|
for (const entry of entries) {
|
|
49
49
|
const fileType = fileTypeOf(entry);
|
|
50
50
|
const attestable = {
|
|
51
|
-
|
|
51
|
+
// #1752 A1: one definition of what an entry is, shared with the offer
|
|
52
|
+
// path. These two disagreeing is defect 4.
|
|
53
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: entry.id, fileType, title: entry.title }).key,
|
|
52
54
|
family: 'learning',
|
|
53
55
|
title: entry.title,
|
|
54
56
|
fileType,
|
|
@@ -142,7 +144,9 @@ function recordAttestationsFromRetrospective(workspaceRoot, userId, options) {
|
|
|
142
144
|
let applied = 0;
|
|
143
145
|
let alreadyRecorded = 0;
|
|
144
146
|
if (inputs.length > 0) {
|
|
145
|
-
const result = (0, learning_usage_store_1.recordFirings)(inputs, {
|
|
147
|
+
const result = (0, learning_usage_store_1.recordFirings)(inputs, {
|
|
148
|
+
limits, now: options.now, storePath: options.storePath, integrityMode: options.integrityMode,
|
|
149
|
+
});
|
|
146
150
|
applied = result.recorded;
|
|
147
151
|
alreadyRecorded = result.alreadyRecorded;
|
|
148
152
|
}
|
|
@@ -208,6 +212,9 @@ function runBackfill(workspaceRoot, userId, options) {
|
|
|
208
212
|
limits,
|
|
209
213
|
now: options.now,
|
|
210
214
|
storePath: options.storePath,
|
|
215
|
+
// A backfill reconciles hundreds of retrospectives at once, so a single
|
|
216
|
+
// stranded-offer entry must not refuse the whole run.
|
|
217
|
+
integrityMode: 'flag',
|
|
211
218
|
});
|
|
212
219
|
if (result.sectionState !== 'absent')
|
|
213
220
|
report.filesWithSection += 1;
|
|
@@ -18,6 +18,18 @@ const learning_context_builder_1 = require("./learning-context-builder");
|
|
|
18
18
|
const learning_usage_analysis_1 = require("./learning-usage-analysis");
|
|
19
19
|
const learning_usage_store_1 = require("./learning-usage-store");
|
|
20
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
|
+
* The repair for a usage record that has gone wrong.
|
|
25
|
+
*
|
|
26
|
+
* The record is derived (#1103 R17), so the fix is to rebuild it rather than to
|
|
27
|
+
* reconcile it in place. Measured on a 733-file retrospective corpus: a rebuild
|
|
28
|
+
* lands with zero split identities and one model bucket, because the identity
|
|
29
|
+
* resolution and the model normalization fix the causes and a rebuilt store
|
|
30
|
+
* carries no legacy shape.
|
|
31
|
+
*/
|
|
32
|
+
const REBUILD_NOTE = 'Delete the `usage/` directory and run `learning-usage backfill` to rebuild the record from the retrospectives. The record is derived: every firing is also written in the retrospective that attested it, so a rebuild restores one identity per lesson and one bucket per model. It costs the offer history, which rebuilds itself from live traffic.';
|
|
21
33
|
function resolveWorkspaceRoot(options) {
|
|
22
34
|
return node_path_1.default.resolve(options.workspaceRoot || options.root || process.cwd());
|
|
23
35
|
}
|
|
@@ -38,6 +50,23 @@ function addCommonOptions(command) {
|
|
|
38
50
|
.option('--user <email>', 'Active user email')
|
|
39
51
|
.option('--json', 'Print JSON');
|
|
40
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Issue #1752 C1: every command that draws a per-model conclusion takes the model
|
|
55
|
+
* from its caller. It is never inferred from the record, because the record is
|
|
56
|
+
* shared by concurrent sessions from more than one agent on more than one
|
|
57
|
+
* machine, so inference reports whichever wrote last.
|
|
58
|
+
*/
|
|
59
|
+
function addModelOption(command) {
|
|
60
|
+
return command.option('--model <model>', 'The model you are running under. Falls back to FRAIM_ACTIVE_MODEL, then reports unknown');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Issue #1752 B: the offer and firing log bounds are applied when the event log is
|
|
64
|
+
* folded, so a read states the bounds it wants rather than inheriting whatever the
|
|
65
|
+
* last writer used.
|
|
66
|
+
*/
|
|
67
|
+
function readStore(workspaceRoot) {
|
|
68
|
+
return (0, learning_usage_store_1.readUsageStore)(undefined, { limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot) });
|
|
69
|
+
}
|
|
41
70
|
// ── offers ───────────────────────────────────────────────────────────────────
|
|
42
71
|
const offersCommand = addCommonOptions(new commander_1.Command('offers'))
|
|
43
72
|
.description('List the learning entries and rule files the runtime delivered, so an attestation is written against the record rather than memory')
|
|
@@ -52,7 +81,7 @@ const offersCommand = addCommonOptions(new commander_1.Command('offers'))
|
|
|
52
81
|
// actually delivered. Re-deriving from the files on disk answers a subtly
|
|
53
82
|
// different question — what would be delivered now — and the two diverge if a
|
|
54
83
|
// learning file changed during the job.
|
|
55
|
-
const store = (
|
|
84
|
+
const store = readStore(workspaceRoot);
|
|
56
85
|
const recorded = Object.values(store.entries)
|
|
57
86
|
.filter((entry) => entry.offered.log.some((offer) => offer.job === options.job))
|
|
58
87
|
.map((entry) => {
|
|
@@ -102,6 +131,7 @@ const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-fi
|
|
|
102
131
|
.option('--agent <name>', 'Agent that attested')
|
|
103
132
|
.option('--model <model>', 'Model that attested')
|
|
104
133
|
.option('--legacy', 'Also accept the pre-#1103 prose form')
|
|
134
|
+
.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')
|
|
105
135
|
.action((options) => {
|
|
106
136
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
107
137
|
const user = resolveUser(options);
|
|
@@ -118,6 +148,7 @@ const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-fi
|
|
|
118
148
|
agent: options.agent ?? null,
|
|
119
149
|
model: options.model ?? null,
|
|
120
150
|
legacy: Boolean(options.legacy),
|
|
151
|
+
integrityMode: options.force ? 'flag' : 'throw',
|
|
121
152
|
});
|
|
122
153
|
emit(result, options.json, () => {
|
|
123
154
|
const lines = [
|
|
@@ -151,17 +182,17 @@ const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
|
|
|
151
182
|
// character, so an agent assembling `T:<family>:<title>` into a shell command
|
|
152
183
|
// would be interpolating untrusted text into a command line. Passing the title
|
|
153
184
|
// as its own argument keeps it a single argv entry.
|
|
154
|
-
|
|
155
|
-
if (options.entry) {
|
|
156
|
-
key = String(options.entry);
|
|
157
|
-
}
|
|
158
|
-
else if (options.title && options.family) {
|
|
159
|
-
key = (0, learning_usage_store_1.titleUsageKey)(String(options.family), String(options.title));
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
185
|
+
if (!options.entry && !(options.title && options.family)) {
|
|
162
186
|
throw new Error('Pass --entry <id>, or --title <title> together with --family <family>.');
|
|
163
187
|
}
|
|
164
|
-
|
|
188
|
+
// #1752 A1: the same identity function the offer and firing paths use, so
|
|
189
|
+
// the CLI cannot ask about a key neither of them would have written.
|
|
190
|
+
const key = (0, learning_usage_store_1.resolveEntryIdentity)({
|
|
191
|
+
id: options.entry ? String(options.entry) : null,
|
|
192
|
+
fileType: options.family ? String(options.family) : null,
|
|
193
|
+
title: options.title ? String(options.title) : null,
|
|
194
|
+
}).key;
|
|
195
|
+
const store = readStore(workspaceRoot);
|
|
165
196
|
const diagnosis = (0, learning_usage_analysis_1.classifyRecurrence)(store, {
|
|
166
197
|
key,
|
|
167
198
|
job: options.job,
|
|
@@ -177,14 +208,13 @@ const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
|
|
|
177
208
|
].join('\n'));
|
|
178
209
|
});
|
|
179
210
|
// ── candidates ───────────────────────────────────────────────────────────────
|
|
180
|
-
const candidatesCommand = addCommonOptions(new commander_1.Command('candidates'))
|
|
211
|
+
const candidatesCommand = addModelOption(addCommonOptions(new commander_1.Command('candidates')))
|
|
181
212
|
.description('List retirement candidates: offered often under the current model with no firing under it, paired with a structural signal')
|
|
182
|
-
.option('--model <model>', 'Override the current model (defaults to the most recently offered-to model)')
|
|
183
213
|
.option('--detector <path>', 'Path to the corpus hygiene detector that provides the structural signal')
|
|
184
214
|
.action((options) => {
|
|
185
215
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
186
216
|
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
187
|
-
const result = (0, learning_usage_analysis_1.findRetirementCandidates)((
|
|
217
|
+
const result = (0, learning_usage_analysis_1.findRetirementCandidates)(readStore(workspaceRoot), {
|
|
188
218
|
workspaceRoot,
|
|
189
219
|
limits,
|
|
190
220
|
currentModel: options.model ?? undefined,
|
|
@@ -192,7 +222,14 @@ const candidatesCommand = addCommonOptions(new commander_1.Command('candidates')
|
|
|
192
222
|
});
|
|
193
223
|
emit(result, options.json, () => {
|
|
194
224
|
if (result.candidates.length === 0) {
|
|
195
|
-
|
|
225
|
+
const lines = [`No retirement candidates. Current model: ${result.currentModel ?? 'unknown'}; threshold ${result.offerThreshold} offers.`];
|
|
226
|
+
if (result.reason)
|
|
227
|
+
lines.push('', result.reason);
|
|
228
|
+
const unknownAttribution = result.notCandidates.filter((c) => /attribution is unknown/i.test(c.reason));
|
|
229
|
+
if (unknownAttribution.length > 0) {
|
|
230
|
+
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.`);
|
|
231
|
+
}
|
|
232
|
+
return lines.join('\n');
|
|
196
233
|
}
|
|
197
234
|
const lines = [`Current model: ${result.currentModel ?? 'unknown'}. Threshold: ${result.offerThreshold} offers.`, ''];
|
|
198
235
|
for (const c of result.candidates) {
|
|
@@ -203,13 +240,13 @@ const candidatesCommand = addCommonOptions(new commander_1.Command('candidates')
|
|
|
203
240
|
});
|
|
204
241
|
});
|
|
205
242
|
// ── standing ─────────────────────────────────────────────────────────────────
|
|
206
|
-
const standingCommand = addCommonOptions(new commander_1.Command('standing'))
|
|
243
|
+
const standingCommand = addModelOption(addCommonOptions(new commander_1.Command('standing')))
|
|
207
244
|
.description('Assess each entry as promoted, standard or demoted from its usage record, and list the ones whose standing should change')
|
|
208
245
|
.option('--changes-only', 'Print only the entries whose standing should change')
|
|
209
246
|
.action((options) => {
|
|
210
247
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
211
248
|
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
212
|
-
const report = (0, learning_usage_analysis_1.assessStanding)((
|
|
249
|
+
const report = (0, learning_usage_analysis_1.assessStanding)(readStore(workspaceRoot), { limits });
|
|
213
250
|
const payload = options.changesOnly ? { ...report, assessments: report.changes } : report;
|
|
214
251
|
emit(payload, options.json, () => {
|
|
215
252
|
const rows = (options.changesOnly ? report.changes : report.assessments);
|
|
@@ -231,16 +268,17 @@ const standingCommand = addCommonOptions(new commander_1.Command('standing'))
|
|
|
231
268
|
});
|
|
232
269
|
});
|
|
233
270
|
// ── report ───────────────────────────────────────────────────────────────────
|
|
234
|
-
const reportCommand = addCommonOptions(new commander_1.Command('report'))
|
|
271
|
+
const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('report')))
|
|
235
272
|
.description('Answer which learnings are still earning their place, from the record rather than from memory')
|
|
236
273
|
.option('--window <days>', 'Reporting window', '90')
|
|
237
274
|
.action((options) => {
|
|
238
275
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
239
276
|
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
240
|
-
const report = (0, learning_usage_analysis_1.buildUsageReport)((
|
|
277
|
+
const report = (0, learning_usage_analysis_1.buildUsageReport)(readStore(workspaceRoot), {
|
|
241
278
|
workspaceRoot,
|
|
242
279
|
limits,
|
|
243
280
|
windowDays: Number.parseInt(options.window, 10) || 90,
|
|
281
|
+
currentModel: options.model ?? undefined,
|
|
244
282
|
});
|
|
245
283
|
emit(report, options.json, () => {
|
|
246
284
|
const lines = [
|
|
@@ -256,7 +294,13 @@ const reportCommand = addCommonOptions(new commander_1.Command('report'))
|
|
|
256
294
|
}
|
|
257
295
|
if (report.ignoredWhileInContext.length) {
|
|
258
296
|
lines.push('', 'Ignored while in context');
|
|
259
|
-
|
|
297
|
+
// #1752 defect 8: this section selects on the `ignored` count, so it
|
|
298
|
+
// prints that count. Printing only the clock-advancing one made the
|
|
299
|
+
// section contradict its own evidence column.
|
|
300
|
+
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.`));
|
|
301
|
+
}
|
|
302
|
+
if (report.integrityFlagged > 0) {
|
|
303
|
+
lines.push('', `${report.integrityFlagged} entr${report.integrityFlagged === 1 ? 'y has' : 'ies have'} an integrity flag: more attested firings than recorded offers. Run \`learning-usage verify\` before acting on anything below.`);
|
|
260
304
|
}
|
|
261
305
|
lines.push('', 'Nothing is retired from this view. Run sleep-on-learnings to decide.');
|
|
262
306
|
return lines.join('\n');
|
|
@@ -343,6 +387,19 @@ function stampFile(filePath, fileType, apply) {
|
|
|
343
387
|
return { file: filePath, entriesStamped: stamped, entriesAlreadyStamped: alreadyStamped };
|
|
344
388
|
}
|
|
345
389
|
const FILE_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
|
|
390
|
+
function defaultStampDirs(workspaceRoot) {
|
|
391
|
+
const dirs = [node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings')];
|
|
392
|
+
for (const level of ['org', 'manager']) {
|
|
393
|
+
try {
|
|
394
|
+
dirs.push(node_path_1.default.join((0, pack_home_1.resolvePackHome)(level).contentRoot, 'learnings'));
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// A pack home that does not resolve is simply not stamped. Reported by the
|
|
398
|
+
// filesScanned count rather than failing the command.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return [...new Set(dirs)];
|
|
402
|
+
}
|
|
346
403
|
function fileTypeFromName(fileName) {
|
|
347
404
|
for (const type of FILE_TYPES) {
|
|
348
405
|
if (fileName.includes(type))
|
|
@@ -356,9 +413,14 @@ const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
|
|
|
356
413
|
.option('--apply', 'Write the changes. Without this flag the command reports what it would do')
|
|
357
414
|
.action((options) => {
|
|
358
415
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
416
|
+
// #1752 A3: the default set is every tier the context reader merges, not
|
|
417
|
+
// the repo alone. `stamp-ids` never reaching the org and manager pack homes
|
|
418
|
+
// is half of defect 4: one lesson stamped in one tier and unstamped in the
|
|
419
|
+
// other resolved to two usage keys. Still opt-in behind --apply, and still a
|
|
420
|
+
// format migration that inserts one identifier line and touches no lesson text.
|
|
359
421
|
const dirs = options.dir.length
|
|
360
422
|
? options.dir.map((d) => node_path_1.default.resolve(d))
|
|
361
|
-
:
|
|
423
|
+
: defaultStampDirs(workspaceRoot);
|
|
362
424
|
const results = [];
|
|
363
425
|
for (const dir of dirs) {
|
|
364
426
|
if (!node_fs_1.default.existsSync(dir))
|
|
@@ -380,7 +442,8 @@ const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
|
|
|
380
442
|
files: results,
|
|
381
443
|
};
|
|
382
444
|
emit(payload, options.json, () => [
|
|
383
|
-
`${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files
|
|
445
|
+
`${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files in ${dirs.length} director${dirs.length === 1 ? 'y' : 'ies'}:`,
|
|
446
|
+
...dirs.map((d) => ` ${d}`),
|
|
384
447
|
`${payload.entriesAlreadyStamped} entries already carry an id.`,
|
|
385
448
|
payload.apply ? '' : 'Re-run with --apply to write the changes.',
|
|
386
449
|
].filter(Boolean).join('\n'));
|
|
@@ -394,6 +457,52 @@ const pruneCommand = addCommonOptions(new commander_1.Command('prune'))
|
|
|
394
457
|
const result = (0, learning_usage_store_1.pruneUsageStore)(limits);
|
|
395
458
|
emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () => `Pruned ${result.prunedOfferRecords} offer records and ${result.prunedFiringRecords} firing records across ${result.entries} entries. Aggregates unchanged.`);
|
|
396
459
|
});
|
|
460
|
+
function mb(bytes) {
|
|
461
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
462
|
+
}
|
|
463
|
+
// ── verify ───────────────────────────────────────────────────────────────────
|
|
464
|
+
const verifyCommand = addModelOption(addCommonOptions(new commander_1.Command('verify')))
|
|
465
|
+
.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')
|
|
466
|
+
.action((options) => {
|
|
467
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
468
|
+
const report = (0, learning_usage_verify_1.buildVerifyReport)({
|
|
469
|
+
snapshotPath: (0, learning_usage_store_1.resolveUsageStorePath)(),
|
|
470
|
+
limits: (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot),
|
|
471
|
+
currentModel: options.model ?? undefined,
|
|
472
|
+
});
|
|
473
|
+
emit(report, options.json, () => {
|
|
474
|
+
const lines = [
|
|
475
|
+
`Usage record: ${report.entries} entries at ${report.snapshotPath}`,
|
|
476
|
+
`Current model: ${report.currentModel ?? 'unknown'}`,
|
|
477
|
+
'',
|
|
478
|
+
];
|
|
479
|
+
const finding = (count, singular, plural, remedy) => count === 0 ? ` none: ${singular}` : ` ${count} ${count === 1 ? singular : plural}. ${remedy}`;
|
|
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', 'An entry cannot fire without being offered, so one of the two counters is wrong. History stranded in a store copy nothing reads is the usual 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 fired with no model recorded', 'entries fired with no model recorded', 'Per-model retirement cannot be answered for these, and they are held back rather than counted as zero. These were attested before record-firings carried --agent and --model, so the model was never recorded against them and a rebuild cannot invent it; attestations recorded from now on carry both.'), '', '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. Their history is recoverable only through a rebuild.');
|
|
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 compact it to the configured retention window.`
|
|
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
|
+
});
|
|
397
506
|
// ── root ─────────────────────────────────────────────────────────────────────
|
|
398
507
|
exports.learningUsageCommand = new commander_1.Command('learning-usage')
|
|
399
508
|
.description('Read and analyse the learning and rule usage record (issue #1103)')
|
|
@@ -405,4 +514,5 @@ exports.learningUsageCommand = new commander_1.Command('learning-usage')
|
|
|
405
514
|
.addCommand(reportCommand)
|
|
406
515
|
.addCommand(backfillCommand)
|
|
407
516
|
.addCommand(stampIdsCommand)
|
|
408
|
-
.addCommand(pruneCommand)
|
|
517
|
+
.addCommand(pruneCommand)
|
|
518
|
+
.addCommand(verifyCommand);
|