fraim 2.0.340 → 2.0.342
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/registry/scripts/learning/completion-state.js +10 -2
- package/dist/src/cli/setup/ide-invocation-surfaces.js +1 -1
- package/dist/src/cli/utils/agent-adapters.js +6 -6
- package/dist/src/cli/utils/remote-sync.js +5 -0
- package/dist/src/core/handoff-contracts.js +122 -21
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/core/utils/capability-index.js +104 -0
- package/dist/src/core/utils/local-registry-resolver.js +33 -6
- package/dist/src/local-mcp-server/learning-context-builder.js +128 -1
- package/dist/src/local-mcp-server/learning-usage-analysis.js +35 -19
- package/dist/src/local-mcp-server/learning-usage-command.js +8 -4
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +163 -38
- package/dist/src/local-mcp-server/learning-usage-verify.js +3 -1
- package/dist/src/local-mcp-server/stdio-server.js +266 -112
- package/dist/src/local-mcp-server/usage-collector.js +5 -29
- package/dist/src/mcp/tool-schemas.js +2 -2
- package/dist/src/routes/analytics.js +10 -3
- package/dist/src/services/mcp-service.js +73 -13
- package/dist/src/services/usage-analytics-service.js +6 -3
- package/package.json +1 -1
|
@@ -520,33 +520,60 @@ class LocalRegistryResolver {
|
|
|
520
520
|
// publish or sync and resolves at all under `single-machine`).
|
|
521
521
|
const managerLocalContent = this.readManagerLocalOverlayFile(path);
|
|
522
522
|
if (managerLocalContent !== null) {
|
|
523
|
+
// Issue #1867: apply inheritance so an extends-based override merges with its parent.
|
|
524
|
+
let resolvedManagerLocal;
|
|
525
|
+
try {
|
|
526
|
+
resolvedManagerLocal = await this.resolveInheritance(managerLocalContent, path);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
resolvedManagerLocal = { content: managerLocalContent, imports: [] };
|
|
530
|
+
}
|
|
523
531
|
return {
|
|
524
|
-
content:
|
|
532
|
+
content: resolvedManagerLocal.content,
|
|
525
533
|
source: 'local',
|
|
526
534
|
personalized: true,
|
|
527
|
-
inherited:
|
|
535
|
+
inherited: resolvedManagerLocal.imports.length > 0,
|
|
536
|
+
imports: resolvedManagerLocal.imports.length > 0 ? resolvedManagerLocal.imports : undefined,
|
|
528
537
|
scope: 'manager'
|
|
529
538
|
};
|
|
530
539
|
}
|
|
531
540
|
// Issue #869: manager overlay (precedence 3 — the synced cross-machine copy)
|
|
532
541
|
const managerOverlayContent = this.readManagerOverlayFile(path);
|
|
533
542
|
if (managerOverlayContent !== null) {
|
|
543
|
+
// Issue #1867: apply inheritance so an extends-based override merges with its parent.
|
|
544
|
+
let resolvedManagerOverlay;
|
|
545
|
+
try {
|
|
546
|
+
resolvedManagerOverlay = await this.resolveInheritance(managerOverlayContent, path);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
resolvedManagerOverlay = { content: managerOverlayContent, imports: [] };
|
|
550
|
+
}
|
|
534
551
|
return {
|
|
535
|
-
content:
|
|
552
|
+
content: resolvedManagerOverlay.content,
|
|
536
553
|
source: 'local',
|
|
537
554
|
personalized: true,
|
|
538
|
-
inherited:
|
|
555
|
+
inherited: resolvedManagerOverlay.imports.length > 0,
|
|
556
|
+
imports: resolvedManagerOverlay.imports.length > 0 ? resolvedManagerOverlay.imports : undefined,
|
|
539
557
|
scope: 'manager'
|
|
540
558
|
};
|
|
541
559
|
}
|
|
542
560
|
// Issue #869 Phase 2: org overlay (precedence 3 — after manager, before synced baseline)
|
|
543
561
|
const orgOverlayContent = this.readOrgOverlayFile(path);
|
|
544
562
|
if (orgOverlayContent !== null) {
|
|
563
|
+
// Issue #1867: apply inheritance so an extends-based override merges with its parent.
|
|
564
|
+
let resolvedOrgOverlay;
|
|
565
|
+
try {
|
|
566
|
+
resolvedOrgOverlay = await this.resolveInheritance(orgOverlayContent, path);
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
resolvedOrgOverlay = { content: orgOverlayContent, imports: [] };
|
|
570
|
+
}
|
|
545
571
|
return {
|
|
546
|
-
content:
|
|
572
|
+
content: resolvedOrgOverlay.content,
|
|
547
573
|
source: 'local',
|
|
548
574
|
personalized: true,
|
|
549
|
-
inherited:
|
|
575
|
+
inherited: resolvedOrgOverlay.imports.length > 0,
|
|
576
|
+
imports: resolvedOrgOverlay.imports.length > 0 ? resolvedOrgOverlay.imports : undefined,
|
|
550
577
|
scope: 'org'
|
|
551
578
|
};
|
|
552
579
|
}
|
|
@@ -24,6 +24,7 @@ exports.readOrgBrand = readOrgBrand;
|
|
|
24
24
|
exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
|
|
25
25
|
exports.countPreservedLearnings = countPreservedLearnings;
|
|
26
26
|
exports.collectBrainLearningDots = collectBrainLearningDots;
|
|
27
|
+
exports.readRejectedProposals = readRejectedProposals;
|
|
27
28
|
exports.readPreservedLearnings = readPreservedLearnings;
|
|
28
29
|
exports.normalizeLearningDomain = normalizeLearningDomain;
|
|
29
30
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
@@ -579,6 +580,34 @@ function pendingL0SortKey(fileName) {
|
|
|
579
580
|
const timestamp = fileName.match(/\d{4}-\d{2}-\d{2}(?:T\d{2}-\d{2}-\d{2})?/);
|
|
580
581
|
return timestamp?.[0] || fileName;
|
|
581
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* Issue #1857 R11: how many proposals are waiting for the manager's review.
|
|
585
|
+
*
|
|
586
|
+
* This counts directory entries and nothing else. It does not open a proposal, and
|
|
587
|
+
* deliberately has no access to one: R10 is an invariant about influence rather
|
|
588
|
+
* than storage, so the employee learns that a backlog exists and which job clears
|
|
589
|
+
* it, never what any pending claim says.
|
|
590
|
+
*
|
|
591
|
+
* The same shape as the pending-L0 line it sits beside, for the same reason: a
|
|
592
|
+
* count plus the job that clears it is actionable; a list of contents is not.
|
|
593
|
+
*/
|
|
594
|
+
function countPendingProposals(roots, resolvedUserId) {
|
|
595
|
+
let total = 0;
|
|
596
|
+
for (const base of [roots.repoLearningsBase, roots.globalPersonalBase]) {
|
|
597
|
+
if (!base)
|
|
598
|
+
continue;
|
|
599
|
+
const queueDir = (0, path_1.join)(base, 'review-queue', resolvedUserId);
|
|
600
|
+
if (!(0, fs_1.existsSync)(queueDir))
|
|
601
|
+
continue;
|
|
602
|
+
try {
|
|
603
|
+
total += (0, fs_1.readdirSync)(queueDir).filter((name) => name.endsWith('.json')).length;
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// An unreadable queue is not worth failing a job's startup over.
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return total;
|
|
610
|
+
}
|
|
582
611
|
function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
|
|
583
612
|
const sources = [];
|
|
584
613
|
const rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
|
|
@@ -823,7 +852,11 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
823
852
|
const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
|
|
824
853
|
const hasL2 = l2Files.length > 0;
|
|
825
854
|
const hasL1 = l1Files.length > 0;
|
|
826
|
-
|
|
855
|
+
// Issue #1857 R11. Counted before the gate on purpose: a manager with a full
|
|
856
|
+
// review queue and an empty corpus is exactly the case where the backlog most
|
|
857
|
+
// needs saying, and computing it after the early return would silence it there.
|
|
858
|
+
const pendingProposals = countPendingProposals(roots, resolvedUserId);
|
|
859
|
+
const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0 || pendingProposals > 0;
|
|
827
860
|
if (!hasContent)
|
|
828
861
|
return '';
|
|
829
862
|
let section = forJob
|
|
@@ -862,6 +895,13 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
862
895
|
}
|
|
863
896
|
section += '\n';
|
|
864
897
|
}
|
|
898
|
+
// Issue #1857 R11. The count, never the content: an unapproved proposal does
|
|
899
|
+
// not influence this job, and the employee is told only that the manager has a
|
|
900
|
+
// backlog and which job clears it.
|
|
901
|
+
if (pendingProposals > 0) {
|
|
902
|
+
const plural = pendingProposals !== 1;
|
|
903
|
+
section += `${pendingProposals} proposal${plural ? 's are' : ' is'} waiting for your manager's review; run \`sleep-on-learnings\` to review ${plural ? 'them' : 'it'}.\n\n`;
|
|
904
|
+
}
|
|
865
905
|
const coachPresent = resolved.coachPresent;
|
|
866
906
|
if (forJob) {
|
|
867
907
|
if (hasL2 || hasL1) {
|
|
@@ -1825,6 +1865,93 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1825
1865
|
flush();
|
|
1826
1866
|
return out;
|
|
1827
1867
|
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Read the rejection history a sleep run writes on completion.
|
|
1870
|
+
*
|
|
1871
|
+
* The file is a sequence of `<!-- sleep-rejection:run:action:revision -->` blocks,
|
|
1872
|
+
* each holding a heading, the manager's reason, an optional revisit condition and
|
|
1873
|
+
* the rejected proposal as JSON. `run-application.ts` writes it; this reads it
|
|
1874
|
+
* back without changing a byte, because the file is also a synthesis input and a
|
|
1875
|
+
* reader that rewrote it would corrupt the next run's history.
|
|
1876
|
+
*
|
|
1877
|
+
* A malformed block is skipped rather than failing the whole read: the manager
|
|
1878
|
+
* losing the view of every rejection because one block is damaged is worse than
|
|
1879
|
+
* the view missing that one.
|
|
1880
|
+
*/
|
|
1881
|
+
function readRejectedProposals(workspaceRoot, userId) {
|
|
1882
|
+
const roots = getLearningRoots(workspaceRoot);
|
|
1883
|
+
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
1884
|
+
if (!resolvedUserId)
|
|
1885
|
+
return [];
|
|
1886
|
+
const out = [];
|
|
1887
|
+
const seen = new Set();
|
|
1888
|
+
const headingRe = /^## (Rejected|Superseded): (.+)$/m;
|
|
1889
|
+
const reasonRe = /^Reason: (.+)$/m;
|
|
1890
|
+
const revisitRe = /^Revisit when: (.+)$/m;
|
|
1891
|
+
const jsonRe = /```json\r?\n([\s\S]*?)\r?\n```/;
|
|
1892
|
+
for (const base of [roots.repoLearningsBase, roots.globalPersonalBase, roots.managerCacheBase]) {
|
|
1893
|
+
if (!base)
|
|
1894
|
+
continue;
|
|
1895
|
+
const file = (0, path_1.join)(base, `${resolvedUserId}-rejected-proposals.md`);
|
|
1896
|
+
if (!(0, fs_1.existsSync)(file))
|
|
1897
|
+
continue;
|
|
1898
|
+
let body;
|
|
1899
|
+
try {
|
|
1900
|
+
body = (0, fs_1.readFileSync)(file, 'utf8');
|
|
1901
|
+
}
|
|
1902
|
+
catch {
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
const displayPath = file.split('\\').join('/');
|
|
1906
|
+
for (const raw of body.split('<!-- sleep-rejection:').slice(1)) {
|
|
1907
|
+
const end = raw.indexOf('<!-- /sleep-rejection -->');
|
|
1908
|
+
const chunk = end >= 0 ? raw.slice(0, end) : raw;
|
|
1909
|
+
const marker = chunk.slice(0, Math.max(0, chunk.indexOf('-->')));
|
|
1910
|
+
const parts = marker.trim().split(':');
|
|
1911
|
+
const heading = headingRe.exec(chunk);
|
|
1912
|
+
const reason = reasonRe.exec(chunk);
|
|
1913
|
+
const revisit = revisitRe.exec(chunk);
|
|
1914
|
+
const json = jsonRe.exec(chunk);
|
|
1915
|
+
if (!heading || !reason)
|
|
1916
|
+
continue;
|
|
1917
|
+
let proposal = {};
|
|
1918
|
+
if (json) {
|
|
1919
|
+
try {
|
|
1920
|
+
proposal = JSON.parse(json[1]);
|
|
1921
|
+
}
|
|
1922
|
+
catch {
|
|
1923
|
+
// Keep the human-readable half; the JSON only enriches it.
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
const decision = (proposal.decision || {});
|
|
1927
|
+
const content = (proposal.content || {});
|
|
1928
|
+
const trace = (content.trace || {});
|
|
1929
|
+
const claimKey = typeof proposal.claimKey === 'string' && proposal.claimKey
|
|
1930
|
+
? proposal.claimKey
|
|
1931
|
+
: parts[1] || heading[2];
|
|
1932
|
+
// The same claim can appear in more than one configured base. The first
|
|
1933
|
+
// base that carries it wins, matching how the tier merge resolves.
|
|
1934
|
+
const key = `${claimKey}:${String(decision.at || '')}`;
|
|
1935
|
+
if (seen.has(key))
|
|
1936
|
+
continue;
|
|
1937
|
+
seen.add(key);
|
|
1938
|
+
out.push({
|
|
1939
|
+
claimKey,
|
|
1940
|
+
title: heading[2].trim(),
|
|
1941
|
+
reason: reason[1].trim(),
|
|
1942
|
+
...(revisit ? { revisitCondition: revisit[1].trim() } : {}),
|
|
1943
|
+
// The heading and the stored decision agree; the heading is what a
|
|
1944
|
+
// manager reading the file sees, so it is the one that governs here.
|
|
1945
|
+
kind: heading[1] === 'Superseded' || decision.kind === 'superseded' ? 'superseded' : 'refused',
|
|
1946
|
+
rejectedAt: typeof decision.at === 'string' ? decision.at : '',
|
|
1947
|
+
...(typeof trace.recurrences === 'number' ? { recurrencesAtRejection: trace.recurrences } : {}),
|
|
1948
|
+
displayPath,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
// Newest first: the manager is most often checking what they just said no to.
|
|
1953
|
+
return out.sort((a, b) => String(b.rejectedAt).localeCompare(String(a.rejectedAt)));
|
|
1954
|
+
}
|
|
1828
1955
|
function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
|
|
1829
1956
|
const roots = getLearningRoots(workspaceRoot);
|
|
1830
1957
|
const threshold = getScoreThreshold(workspaceRoot);
|
|
@@ -27,6 +27,7 @@ const fs_1 = require("fs");
|
|
|
27
27
|
const path_1 = require("path");
|
|
28
28
|
const learning_usage_store_1 = require("./learning-usage-store");
|
|
29
29
|
const learning_usage_projection_1 = require("./learning-usage-projection");
|
|
30
|
+
const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
|
|
30
31
|
function daysBetween(from, to) {
|
|
31
32
|
const a = new Date(from).getTime();
|
|
32
33
|
const b = new Date(to).getTime();
|
|
@@ -39,6 +40,7 @@ function firedCount(record) {
|
|
|
39
40
|
}
|
|
40
41
|
function classifyRecurrence(store, options) {
|
|
41
42
|
const record = store.entries[options.key];
|
|
43
|
+
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
42
44
|
const base = {
|
|
43
45
|
key: options.key,
|
|
44
46
|
title: record?.titles[record.titles.length - 1] ?? null,
|
|
@@ -51,6 +53,14 @@ function classifyRecurrence(store, options) {
|
|
|
51
53
|
firedTotal: record ? firedCount(record) : 0,
|
|
52
54
|
},
|
|
53
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
|
+
}
|
|
54
64
|
if (!record) {
|
|
55
65
|
return {
|
|
56
66
|
...base,
|
|
@@ -94,7 +104,7 @@ function classifyRecurrence(store, options) {
|
|
|
94
104
|
// per entry per session and a plain newest-N bound evicted the job-labelled
|
|
95
105
|
// ones within about a day. Asking about a job the store has never retained an
|
|
96
106
|
// 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));
|
|
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));
|
|
98
108
|
if (matchingOffers.length > 0) {
|
|
99
109
|
return {
|
|
100
110
|
...base,
|
|
@@ -231,7 +241,7 @@ function firingsUnderModel(record, model) {
|
|
|
231
241
|
* unrecorded.
|
|
232
242
|
*/
|
|
233
243
|
function modelAttributionUnknown(record) {
|
|
234
|
-
return
|
|
244
|
+
return (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record);
|
|
235
245
|
}
|
|
236
246
|
function earlierModelFirings(record, currentModel) {
|
|
237
247
|
return Object.entries(record.fired.byModel)
|
|
@@ -261,24 +271,22 @@ function findRetirementCandidates(store, options) {
|
|
|
261
271
|
}
|
|
262
272
|
const candidates = [];
|
|
263
273
|
const notCandidates = [];
|
|
274
|
+
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
264
275
|
for (const record of Object.values(store.entries)) {
|
|
265
276
|
if (record.family !== 'learning')
|
|
266
277
|
continue;
|
|
267
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
|
+
}
|
|
268
284
|
const offered = offersUnderModel(record, currentModel);
|
|
269
285
|
const fired = firingsUnderModel(record, currentModel);
|
|
270
286
|
if (fired > 0) {
|
|
271
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.` });
|
|
272
288
|
continue;
|
|
273
289
|
}
|
|
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
|
-
}
|
|
282
290
|
if (offered < threshold) {
|
|
283
291
|
notCandidates.push({
|
|
284
292
|
key: record.key,
|
|
@@ -326,6 +334,10 @@ function deriveStanding(record, options) {
|
|
|
326
334
|
const offered = record.offered.aboveThreshold;
|
|
327
335
|
const title = latestTitle(record);
|
|
328
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
|
+
}
|
|
329
341
|
let standing;
|
|
330
342
|
let reason;
|
|
331
343
|
if (fired > 0 && daysSince(record.fired.lastFiredAt, now) <= STANDING_RECENT_DAYS) {
|
|
@@ -356,10 +368,11 @@ function deriveStanding(record, options) {
|
|
|
356
368
|
*/
|
|
357
369
|
function assessStanding(store, options) {
|
|
358
370
|
const now = options.now ?? new Date();
|
|
371
|
+
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
359
372
|
const assessments = Object.values(store.entries)
|
|
360
|
-
.filter((r) => r.family === 'learning')
|
|
361
|
-
.map((r) => deriveStanding(r, { ...options, now }))
|
|
362
|
-
.sort((a, b) => b.fired - a.fired || b.offered - a.offered);
|
|
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));
|
|
363
376
|
return {
|
|
364
377
|
generatedAt: now.toISOString(),
|
|
365
378
|
assessments,
|
|
@@ -374,7 +387,7 @@ function toReportEntry(record) {
|
|
|
374
387
|
fired: firedCount(record),
|
|
375
388
|
ignored: record.fired.byOutcome.ignored,
|
|
376
389
|
lastFired: record.fired.lastFiredAt,
|
|
377
|
-
firedUnder: Object.keys(record.fired.byModel),
|
|
390
|
+
firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
|
|
378
391
|
recentLog: record.fired.log,
|
|
379
392
|
};
|
|
380
393
|
}
|
|
@@ -388,7 +401,10 @@ function toReportEntry(record) {
|
|
|
388
401
|
function buildUsageReport(store, options) {
|
|
389
402
|
const now = options.now ?? new Date();
|
|
390
403
|
const limit = options.limit ?? 10;
|
|
391
|
-
const
|
|
404
|
+
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');
|
|
392
408
|
const offered = learningRecords.filter((r) => r.offered.total > 0);
|
|
393
409
|
const fired = learningRecords.filter((r) => firedCount(r) > 0);
|
|
394
410
|
const neverFired = offered.filter((r) => firedCount(r) === 0);
|
|
@@ -397,11 +413,11 @@ function buildUsageReport(store, options) {
|
|
|
397
413
|
generatedAt: now.toISOString(),
|
|
398
414
|
windowDays: options.windowDays,
|
|
399
415
|
currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
|
|
400
|
-
integrityFlagged:
|
|
416
|
+
integrityFlagged: excluded.size,
|
|
401
417
|
totals: {
|
|
402
|
-
entriesOffered: offered.length,
|
|
403
|
-
entriesWithFirings: fired.length,
|
|
404
|
-
entriesNeverFired: neverFired.length,
|
|
418
|
+
entriesOffered: complete ? offered.length : null,
|
|
419
|
+
entriesWithFirings: complete ? fired.length : null,
|
|
420
|
+
entriesNeverFired: complete ? neverFired.length : null,
|
|
405
421
|
},
|
|
406
422
|
workingHardest: fired
|
|
407
423
|
.sort((a, b) => {
|
|
@@ -277,7 +277,8 @@ const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('r
|
|
|
277
277
|
});
|
|
278
278
|
emit(report, options.json, () => {
|
|
279
279
|
const lines = [
|
|
280
|
-
|
|
280
|
+
report.totals.entriesOffered === null ? 'Corpus totals are unavailable; listed entries have independent usable evidence.'
|
|
281
|
+
: `${report.totals.entriesOffered} entries were offered. ${report.totals.entriesWithFirings} fired. ${report.totals.entriesNeverFired} have never fired.`,
|
|
281
282
|
];
|
|
282
283
|
if (report.workingHardest.length) {
|
|
283
284
|
lines.push('', 'Working hardest');
|
|
@@ -295,7 +296,7 @@ const reportCommand = addModelOption(addCommonOptions(new commander_1.Command('r
|
|
|
295
296
|
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.`));
|
|
296
297
|
}
|
|
297
298
|
if (report.integrityFlagged > 0) {
|
|
298
|
-
lines.push('', `${report.integrityFlagged}
|
|
299
|
+
lines.push('', `${report.integrityFlagged} entries were excluded from usage conclusions. Run \`learning-usage verify\` for diagnostic details.`);
|
|
299
300
|
}
|
|
300
301
|
lines.push('', 'Nothing is retired from this view. Run sleep-on-learnings to decide.');
|
|
301
302
|
return lines.join('\n');
|
|
@@ -450,7 +451,8 @@ const pruneCommand = addCommonOptions(new commander_1.Command('prune'))
|
|
|
450
451
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
451
452
|
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
452
453
|
const result = (0, learning_usage_store_1.pruneUsageStore)(limits);
|
|
453
|
-
emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () =>
|
|
454
|
+
emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () => result.maintenanceStatus === 'deferred' ? `Maintenance deferred; history unchanged. ${result.evidence?.observations.join(', ') ?? ''}`
|
|
455
|
+
: `Pruned ${result.prunedOfferRecords} offer records and ${result.prunedFiringRecords} firing records across ${result.entries} entries. Aggregates unchanged.`);
|
|
454
456
|
});
|
|
455
457
|
function mb(bytes) {
|
|
456
458
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -472,9 +474,11 @@ const verifyCommand = addModelOption(addCommonOptions(new commander_1.Command('v
|
|
|
472
474
|
'',
|
|
473
475
|
];
|
|
474
476
|
const finding = (count, singular, plural, remedy) => count === 0 ? ` none: ${singular}` : ` ${count} ${count === 1 ? singular : plural}. ${remedy}`;
|
|
477
|
+
if (report.evidence)
|
|
478
|
+
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.`] : []), '');
|
|
475
479
|
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
|
|
476
480
|
? ` ${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.`
|
|
477
|
-
: ' 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', '
|
|
481
|
+
: ' 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');
|
|
478
482
|
if (report.storeCopies.length === 0) {
|
|
479
483
|
lines.push(' one store file, no unmerged copies');
|
|
480
484
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.modelAttributionIncomplete = modelAttributionIncomplete;
|
|
4
|
+
exports.countEvidenceProblem = countEvidenceProblem;
|
|
5
|
+
exports.usageEvidenceExclusions = usageEvidenceExclusions;
|
|
6
|
+
const learning_usage_store_1 = require("./learning-usage-store");
|
|
7
|
+
const validCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
8
|
+
/** Missing attribution is unknown, including a known bucket beside unattributed history. */
|
|
9
|
+
function modelAttributionIncomplete(record) {
|
|
10
|
+
const buckets = [record.offered.byModel, record.fired.byModel];
|
|
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
|
+
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;
|
|
16
|
+
}
|
|
17
|
+
/** 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),
|
|
21
|
+
...Object.values(record.fired.byOutcome)];
|
|
22
|
+
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
|
+
|| Object.values(record.fired.byOutcome).reduce((a, b) => a + b, 0) !== record.fired.total) {
|
|
25
|
+
return 'Usage counters are inconsistent.';
|
|
26
|
+
}
|
|
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;
|
|
32
|
+
}
|
|
33
|
+
/** Read-time exclusions follow the actual record, never ambient UI or model state. */
|
|
34
|
+
function usageEvidenceExclusions(store) {
|
|
35
|
+
const excluded = new Map();
|
|
36
|
+
for (const key of store.evidence?.excludedKeys ?? [])
|
|
37
|
+
excluded.set(key, 'Unreadable usage record.');
|
|
38
|
+
const byTitle = new Map();
|
|
39
|
+
for (const record of Object.values(store.entries)) {
|
|
40
|
+
const problem = countEvidenceProblem(record, store.offersEraStart);
|
|
41
|
+
if (store.evidence?.status === 'unavailable')
|
|
42
|
+
excluded.set(record.key, 'Usage history is unavailable.');
|
|
43
|
+
else if (problem)
|
|
44
|
+
excluded.set(record.key, problem);
|
|
45
|
+
if (!record.fileType)
|
|
46
|
+
continue;
|
|
47
|
+
for (const title of record.titles) {
|
|
48
|
+
const titleKey = (0, learning_usage_store_1.titleUsageKey)(record.fileType, title);
|
|
49
|
+
byTitle.set(titleKey, [...new Set([...(byTitle.get(titleKey) ?? []), record.key])]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const keys of byTitle.values()) {
|
|
53
|
+
if (keys.length > 1 && keys.some(key => key.startsWith('T:'))) {
|
|
54
|
+
for (const key of keys)
|
|
55
|
+
excluded.set(key, 'Ambiguous duplicate usage identity.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return excluded;
|
|
59
|
+
}
|
|
@@ -15,6 +15,7 @@ exports.resolveCurrentModel = resolveCurrentModel;
|
|
|
15
15
|
* Nothing here writes.
|
|
16
16
|
*/
|
|
17
17
|
const learning_usage_store_1 = require("./learning-usage-store");
|
|
18
|
+
const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
|
|
18
19
|
function toLookupValue(record) {
|
|
19
20
|
return {
|
|
20
21
|
key: record.key,
|
|
@@ -24,7 +25,7 @@ function toLookupValue(record) {
|
|
|
24
25
|
firedTotalAttestations: record.fired.total,
|
|
25
26
|
lastFired: record.fired.lastFiredAt,
|
|
26
27
|
lastOffered: record.offered.lastOfferedAt,
|
|
27
|
-
firedUnder: Object.keys(record.fired.byModel),
|
|
28
|
+
firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
|
|
28
29
|
byOutcome: record.fired.byOutcome,
|
|
29
30
|
log: record.fired.log,
|
|
30
31
|
};
|
|
@@ -36,7 +37,10 @@ function toLookupValue(record) {
|
|
|
36
37
|
*/
|
|
37
38
|
function buildUsageLookup(store) {
|
|
38
39
|
const lookup = new Map();
|
|
40
|
+
const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
|
|
39
41
|
for (const record of Object.values(store.entries)) {
|
|
42
|
+
if (excluded.has(record.key))
|
|
43
|
+
continue;
|
|
40
44
|
const value = toLookupValue(record);
|
|
41
45
|
lookup.set(record.key, value);
|
|
42
46
|
if (record.fileType) {
|