fraim 2.0.339 → 2.0.341
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 +161 -0
- package/dist/src/cli/commands/cleanup-artifacts.js +2 -2
- package/dist/src/core/ai-mentor.js +1 -1
- package/dist/src/core/handoff-contracts.js +165 -37
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +224 -20
- package/dist/src/local-mcp-server/learning-usage-analysis.js +35 -19
- package/dist/src/local-mcp-server/learning-usage-command.js +13 -14
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +235 -79
- package/dist/src/local-mcp-server/learning-usage-verify.js +13 -26
- package/dist/src/local-mcp-server/stdio-server.js +295 -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 +71 -11
- package/dist/src/services/usage-analytics-service.js +6 -3
- package/package.json +2 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.safeRunStateKey = safeRunStateKey;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
/** UUIDs retain their documented filenames; other IDs cannot become paths or device names. */
|
|
6
|
+
function safeRunStateKey(id) {
|
|
7
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
|
8
|
+
? id : `id-${(0, crypto_1.createHash)('sha256').update(id).digest('hex')}`;
|
|
9
|
+
}
|
|
@@ -28,9 +28,14 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
|
|
|
28
28
|
'learning_usage',
|
|
29
29
|
'hub_event_logs',
|
|
30
30
|
];
|
|
31
|
+
function contentRoot(workspaceRoot) {
|
|
32
|
+
const packContent = node_path_1.default.join(workspaceRoot, 'personalized-employee');
|
|
33
|
+
return node_fs_1.default.existsSync(packContent) ? packContent : node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee');
|
|
34
|
+
}
|
|
31
35
|
function readWorkspaceConfig(workspaceRoot) {
|
|
32
36
|
try {
|
|
33
|
-
const configPath = (
|
|
37
|
+
const configPath = contentRoot(workspaceRoot) === node_path_1.default.join(workspaceRoot, 'personalized-employee')
|
|
38
|
+
? node_path_1.default.join(workspaceRoot, 'config.json') : (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
|
|
34
39
|
if (!node_fs_1.default.existsSync(configPath))
|
|
35
40
|
return {};
|
|
36
41
|
return JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
|
|
@@ -143,7 +148,7 @@ function loadGitLastChangeDates(workspaceRoot, relativeDir) {
|
|
|
143
148
|
return dates;
|
|
144
149
|
}
|
|
145
150
|
function readFrontmatter(content) {
|
|
146
|
-
const match = content.match(
|
|
151
|
+
const match = content.match(/^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/);
|
|
147
152
|
if (!match)
|
|
148
153
|
return {};
|
|
149
154
|
const values = {};
|
|
@@ -167,8 +172,20 @@ function synthesizedDate(absolutePath) {
|
|
|
167
172
|
return undefined;
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
|
-
function userScoped(
|
|
171
|
-
|
|
175
|
+
function userScoped(filePath, activeUserEmail) {
|
|
176
|
+
try {
|
|
177
|
+
const body = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
178
|
+
const author = readFrontmatter(body).author;
|
|
179
|
+
if (author !== undefined) {
|
|
180
|
+
const value = author.replace(/^(?:"([^"]*)"|'([^']*)')$/, (_match, double, single) => double ?? single);
|
|
181
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value === activeUserEmail;
|
|
182
|
+
}
|
|
183
|
+
// Older artifacts used the author in their filename.
|
|
184
|
+
return node_path_1.default.basename(filePath).startsWith(`${activeUserEmail}-`);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
172
189
|
}
|
|
173
190
|
function evidenceEligibilityDate(workspaceRoot, absolutePath, gitDates) {
|
|
174
191
|
const relativePath = normalizeRelative(workspaceRoot, absolutePath);
|
|
@@ -187,17 +204,22 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
187
204
|
eligibilityDate,
|
|
188
205
|
});
|
|
189
206
|
};
|
|
190
|
-
const
|
|
207
|
+
const content = contentRoot(workspaceRoot);
|
|
208
|
+
const archiveDir = node_path_1.default.join(content, 'learnings', 'archive');
|
|
191
209
|
for (const filePath of listFiles(archiveDir)) {
|
|
192
210
|
const fileName = node_path_1.default.basename(filePath);
|
|
193
|
-
if (!fileName.endsWith('.md') || !userScoped(
|
|
211
|
+
if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
|
|
194
212
|
continue;
|
|
195
213
|
add('learning_archive', filePath, true, 'Archived learning source for active user.', fileMtime(filePath));
|
|
196
214
|
}
|
|
197
|
-
const
|
|
198
|
-
|
|
215
|
+
const retrospectives = new Set([
|
|
216
|
+
node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives'),
|
|
217
|
+
node_path_1.default.join(workspaceRoot, 'retrospectives'),
|
|
218
|
+
node_path_1.default.join(content, 'retrospectives'),
|
|
219
|
+
].flatMap(listFiles));
|
|
220
|
+
for (const filePath of retrospectives) {
|
|
199
221
|
const fileName = node_path_1.default.basename(filePath);
|
|
200
|
-
if (!fileName.endsWith('.md') || !userScoped(
|
|
222
|
+
if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
|
|
201
223
|
continue;
|
|
202
224
|
const date = synthesizedDate(filePath);
|
|
203
225
|
if (date)
|
|
@@ -209,6 +231,11 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
209
231
|
const evidenceGitDates = loadGitLastChangeDates(workspaceRoot, 'docs/evidence');
|
|
210
232
|
for (const filePath of listFiles(evidenceDir)) {
|
|
211
233
|
const fileName = node_path_1.default.basename(filePath);
|
|
234
|
+
// A surviving run journal is unfinished recovery; completion removes it.
|
|
235
|
+
if (fileName.endsWith('.queue.jsonl')) {
|
|
236
|
+
add('evidence', filePath, false, 'Unfinished Sleep run journal; removed by run completion.');
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
212
239
|
const isMarkdown = fileName.endsWith('.md');
|
|
213
240
|
const isJson = fileName.endsWith('.json');
|
|
214
241
|
const eligibilityDate = evidenceEligibilityDate(workspaceRoot, filePath, evidenceGitDates);
|
|
@@ -24,7 +24,9 @@ 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;
|
|
29
|
+
exports.normalizeLearningDomain = normalizeLearningDomain;
|
|
28
30
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
29
31
|
exports.isTruthyFlag = isTruthyFlag;
|
|
30
32
|
const fs_1 = require("fs");
|
|
@@ -496,6 +498,15 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
|
|
|
496
498
|
* behaviour instead of merely equivalent.
|
|
497
499
|
*/
|
|
498
500
|
function resolveUsageClock(workspaceRoot) {
|
|
501
|
+
return resolveUsageViews(workspaceRoot).clock;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Issue #1755: one store read, two views of it: the clock the scorer needs and
|
|
505
|
+
* the counters a surface renders. They are resolved together because reading the
|
|
506
|
+
* store twice for the same pass would let the two disagree, and because the
|
|
507
|
+
* identity resolution that finds a record is the projection's, not each caller's.
|
|
508
|
+
*/
|
|
509
|
+
function resolveUsageViews(workspaceRoot) {
|
|
499
510
|
try {
|
|
500
511
|
// Required lazily: the store imports the retention config, which imports the
|
|
501
512
|
// workspace path helpers, and a cycle through this module would be fragile.
|
|
@@ -503,14 +514,31 @@ function resolveUsageClock(workspaceRoot) {
|
|
|
503
514
|
const projection = require('./learning-usage-projection');
|
|
504
515
|
const loaded = store.readUsageStore();
|
|
505
516
|
if (Object.keys(loaded.entries).length === 0)
|
|
506
|
-
return
|
|
517
|
+
return {};
|
|
507
518
|
const lookup = projection.buildUsageLookup(loaded);
|
|
508
|
-
return
|
|
519
|
+
return {
|
|
520
|
+
clock: (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null,
|
|
521
|
+
stats: (id, fileType, title) => {
|
|
522
|
+
const found = projection.lookupUsage(lookup, id, fileType, title);
|
|
523
|
+
if (!found)
|
|
524
|
+
return null;
|
|
525
|
+
return {
|
|
526
|
+
offered: found.offered,
|
|
527
|
+
offeredAboveThreshold: found.offeredAboveThreshold,
|
|
528
|
+
applied: found.byOutcome.applied || 0,
|
|
529
|
+
prevented: found.byOutcome.prevented || 0,
|
|
530
|
+
ignored: found.byOutcome.ignored || 0,
|
|
531
|
+
notApplicable: found.byOutcome['not-applicable'] || 0,
|
|
532
|
+
lastOfferedAt: found.lastOffered,
|
|
533
|
+
lastFiredAt: found.lastFired,
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
};
|
|
509
537
|
}
|
|
510
538
|
catch {
|
|
511
539
|
// Usage data is derived and optional. A failure here must never change what
|
|
512
540
|
// the agent is given.
|
|
513
|
-
return
|
|
541
|
+
return {};
|
|
514
542
|
}
|
|
515
543
|
}
|
|
516
544
|
function readFrontmatter(content) {
|
|
@@ -552,6 +580,34 @@ function pendingL0SortKey(fileName) {
|
|
|
552
580
|
const timestamp = fileName.match(/\d{4}-\d{2}-\d{2}(?:T\d{2}-\d{2}-\d{2})?/);
|
|
553
581
|
return timestamp?.[0] || fileName;
|
|
554
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
|
+
}
|
|
555
611
|
function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
|
|
556
612
|
const sources = [];
|
|
557
613
|
const rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
|
|
@@ -796,7 +852,11 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
796
852
|
const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
|
|
797
853
|
const hasL2 = l2Files.length > 0;
|
|
798
854
|
const hasL1 = l1Files.length > 0;
|
|
799
|
-
|
|
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;
|
|
800
860
|
if (!hasContent)
|
|
801
861
|
return '';
|
|
802
862
|
let section = forJob
|
|
@@ -835,6 +895,13 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
835
895
|
}
|
|
836
896
|
section += '\n';
|
|
837
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
|
+
}
|
|
838
905
|
const coachPresent = resolved.coachPresent;
|
|
839
906
|
if (forJob) {
|
|
840
907
|
if (hasL2 || hasL1) {
|
|
@@ -1216,7 +1283,7 @@ function isTeamContextKey(value) {
|
|
|
1216
1283
|
* user-level `~/.fraim/…` path (where org onboarding writes).
|
|
1217
1284
|
* - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
|
|
1218
1285
|
*/
|
|
1219
|
-
function resolveTeamContextFile(workspaceRoot, key) {
|
|
1286
|
+
function resolveTeamContextFile(workspaceRoot, key, options = {}) {
|
|
1220
1287
|
const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
|
|
1221
1288
|
const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
|
|
1222
1289
|
const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
|
|
@@ -1245,7 +1312,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1245
1312
|
// destination (R5.1), so writePath stays empty and the caller must route
|
|
1246
1313
|
// edits through the org backend's propose-and-approve flow.
|
|
1247
1314
|
if (key === 'org' || key === 'orgRules') {
|
|
1248
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
|
|
1315
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', options).contentRoot, relativePath);
|
|
1249
1316
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
1250
1317
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
1251
1318
|
const orgDisplayPath = cachePath.startsWith(fraimDir)
|
|
@@ -1262,7 +1329,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1262
1329
|
}
|
|
1263
1330
|
}
|
|
1264
1331
|
if (key === 'manager' || key === 'managerRules') {
|
|
1265
|
-
const managerHome = (0, pack_home_1.resolvePackHome)('manager');
|
|
1332
|
+
const managerHome = (0, pack_home_1.resolvePackHome)('manager', options);
|
|
1266
1333
|
const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
|
|
1267
1334
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
1268
1335
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
@@ -1697,7 +1764,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
|
|
|
1697
1764
|
// threshold: entries with score >= threshold (or in a non-gated category) are
|
|
1698
1765
|
// marked injected:true. Callers that don't need scoring pass Infinity so
|
|
1699
1766
|
// all entries are injected.
|
|
1700
|
-
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock) {
|
|
1767
|
+
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock, usageStats) {
|
|
1701
1768
|
if (!(0, fs_1.existsSync)(filePath))
|
|
1702
1769
|
return [];
|
|
1703
1770
|
let content;
|
|
@@ -1719,6 +1786,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1719
1786
|
const flush = () => {
|
|
1720
1787
|
if (current) {
|
|
1721
1788
|
current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
1789
|
+
// Issue #1755: resolve usage once per entry, before scoring, so the
|
|
1790
|
+
// counters a surface renders and the clock the score uses come from
|
|
1791
|
+
// the same record rather than two independent lookups.
|
|
1792
|
+
current.usage = usageStats ? usageStats(current.id, fileType, current.title) : null;
|
|
1722
1793
|
if (isScoreGated) {
|
|
1723
1794
|
if (curLastSeen !== null) {
|
|
1724
1795
|
const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
|
|
@@ -1753,7 +1824,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1753
1824
|
title: header[2].trim(),
|
|
1754
1825
|
id: null,
|
|
1755
1826
|
body: '', source: displayPath, category, level, domain,
|
|
1756
|
-
score: null, injected: true,
|
|
1827
|
+
score: null, injected: true, usage: null,
|
|
1757
1828
|
};
|
|
1758
1829
|
if (tier !== undefined)
|
|
1759
1830
|
entry.tier = tier;
|
|
@@ -1794,18 +1865,106 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1794
1865
|
flush();
|
|
1795
1866
|
return out;
|
|
1796
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
|
+
}
|
|
1797
1955
|
function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
|
|
1798
1956
|
const roots = getLearningRoots(workspaceRoot);
|
|
1799
1957
|
const threshold = getScoreThreshold(workspaceRoot);
|
|
1800
1958
|
// Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
|
|
1801
1959
|
// read the same clock. One store read serves the whole call.
|
|
1802
|
-
|
|
1960
|
+
// Issue #1755: the same read also yields the counters the Hub renders.
|
|
1961
|
+
const { clock: usageClock, stats: usageStats } = resolveUsageViews(workspaceRoot);
|
|
1803
1962
|
const out = [];
|
|
1804
1963
|
// Helper: parse + merge a list of tier paths for one file family.
|
|
1805
1964
|
// ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
|
|
1806
1965
|
const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
|
|
1807
1966
|
const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
|
|
1808
|
-
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock),
|
|
1967
|
+
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock, usageStats),
|
|
1809
1968
|
})));
|
|
1810
1969
|
if (scope === 'org') {
|
|
1811
1970
|
for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
|
|
@@ -1826,7 +1985,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1826
1985
|
seenDomainFile.add(fileName);
|
|
1827
1986
|
const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
|
|
1828
1987
|
if (domainTiers.length === 0) {
|
|
1829
|
-
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold));
|
|
1988
|
+
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageClock, usageStats));
|
|
1830
1989
|
}
|
|
1831
1990
|
else {
|
|
1832
1991
|
out.push(...parseTiers(domainTiers, cat, undefined, domain));
|
|
@@ -1843,13 +2002,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1843
2002
|
for (const cat of cats) {
|
|
1844
2003
|
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
1845
2004
|
const f = `${resolvedUserId}-${fileType}.md`;
|
|
1846
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold));
|
|
2005
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
|
|
1847
2006
|
const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
|
|
1848
2007
|
if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
|
|
1849
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold));
|
|
2008
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
|
|
1850
2009
|
}
|
|
1851
2010
|
for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
|
|
1852
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold));
|
|
2011
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
|
|
1853
2012
|
}
|
|
1854
2013
|
}
|
|
1855
2014
|
return out;
|
|
@@ -1880,6 +2039,25 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1880
2039
|
}
|
|
1881
2040
|
return out;
|
|
1882
2041
|
}
|
|
2042
|
+
/** True for a domain value that means "the undecorated global file". */
|
|
2043
|
+
function isGlobalDomain(domain) {
|
|
2044
|
+
return !domain || domain === 'global';
|
|
2045
|
+
}
|
|
2046
|
+
/** Normalize a caller-supplied domain to one of the known domains, or 'global'. */
|
|
2047
|
+
function normalizeLearningDomain(domain) {
|
|
2048
|
+
if (isGlobalDomain(domain))
|
|
2049
|
+
return 'global';
|
|
2050
|
+
const found = learning_domains_1.LEARNING_DOMAINS.find((d) => d === domain);
|
|
2051
|
+
return found ?? 'global';
|
|
2052
|
+
}
|
|
2053
|
+
function learningFileName(userId, fileType, domain) {
|
|
2054
|
+
// Issue #1755 / #754: normalize here rather than trusting the caller. The HTTP
|
|
2055
|
+
// layer already allowlists the domain, but this function builds a path and a
|
|
2056
|
+
// direct caller passing `../../escape` would otherwise write outside the
|
|
2057
|
+
// learnings root. Anything not in the known set resolves to the global file.
|
|
2058
|
+
const safe = normalizeLearningDomain(domain);
|
|
2059
|
+
return safe === 'global' ? `${userId}-${fileType}.md` : `${userId}-${safe}-${fileType}.md`;
|
|
2060
|
+
}
|
|
1883
2061
|
function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
1884
2062
|
const roots = getLearningRoots(workspaceRoot);
|
|
1885
2063
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
@@ -1887,12 +2065,12 @@ function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
|
1887
2065
|
// Write org entries to the org pack home — same location share-with-others uses.
|
|
1888
2066
|
// The Hub read path already merges pack home + repo-local, so edits land where
|
|
1889
2067
|
// the agent reads them without needing a repo commit.
|
|
1890
|
-
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings',
|
|
2068
|
+
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
|
|
1891
2069
|
}
|
|
1892
2070
|
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
1893
2071
|
// Issue #1070: for the 'machine' level use the configured manager home.
|
|
1894
2072
|
const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
|
|
1895
|
-
return (0, path_1.join)(base,
|
|
2073
|
+
return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain));
|
|
1896
2074
|
}
|
|
1897
2075
|
function titleCaseFromFileType(fileType) {
|
|
1898
2076
|
return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
@@ -1916,9 +2094,10 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1916
2094
|
const body = String(payload.body || '').trim();
|
|
1917
2095
|
const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
|
|
1918
2096
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
2097
|
+
const addDomain = normalizeLearningDomain(ref.domain);
|
|
1919
2098
|
const preamble = existing.trim()
|
|
1920
2099
|
? existing.replace(/\s*$/, '')
|
|
1921
|
-
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
|
|
2100
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}${addDomain === 'global' ? '' : ` (${addDomain})`}`;
|
|
1922
2101
|
write(preamble + nl + nl + block);
|
|
1923
2102
|
return { ok: true, path: filePath };
|
|
1924
2103
|
}
|
|
@@ -1935,8 +2114,12 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1935
2114
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1936
2115
|
return { ok: true, path: filePath };
|
|
1937
2116
|
}
|
|
1938
|
-
// edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
|
|
2117
|
+
// edit: keep the entry's metadata lines (Id/Score/Last seen/Recurrences/...) intact,
|
|
1939
2118
|
// replace only the heading (priority + title) and the prose body.
|
|
2119
|
+
//
|
|
2120
|
+
// Issue #1755: `Id` is in this list. It used not to be, so every Hub edit dropped
|
|
2121
|
+
// the entry's stable identifier and the usage record keyed to it (#1752) was
|
|
2122
|
+
// orphaned: silently, and permanently once the title also changed.
|
|
1940
2123
|
const headerMatch = lines[start].match(headingRe);
|
|
1941
2124
|
const sev = (payload.severity || headerMatch[1]);
|
|
1942
2125
|
const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
|
|
@@ -1945,11 +2128,32 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1945
2128
|
const t = l.trim();
|
|
1946
2129
|
if (t === '---')
|
|
1947
2130
|
continue;
|
|
1948
|
-
if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
2131
|
+
if (/^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
1949
2132
|
meta.push(l);
|
|
1950
2133
|
}
|
|
1951
2134
|
const body = String(payload.body !== undefined ? payload.body : '').trim();
|
|
1952
2135
|
const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
|
|
2136
|
+
// Issue #1755 R2/R4: a domain change is a move between files, because the domain
|
|
2137
|
+
// IS the filename. Cut the rebuilt block from the source file and append it to the
|
|
2138
|
+
// destination, so the entry keeps its id, score and history rather than being
|
|
2139
|
+
// re-created as a new entry with no past.
|
|
2140
|
+
const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain);
|
|
2141
|
+
if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain)) {
|
|
2142
|
+
const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain });
|
|
2143
|
+
const destExisting = (0, fs_1.existsSync)(destPath) ? (0, fs_1.readFileSync)(destPath, 'utf8') : '';
|
|
2144
|
+
const destNl = destExisting.includes('\r\n') ? '\r\n' : nl;
|
|
2145
|
+
const fileTypeForHeader = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
2146
|
+
const destPreamble = destExisting.trim()
|
|
2147
|
+
? destExisting.replace(/\s*$/, '')
|
|
2148
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileTypeForHeader)}${targetDomain === 'global' ? '' : ` (${targetDomain})`}`;
|
|
2149
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(destPath), { recursive: true });
|
|
2150
|
+
(0, fs_1.writeFileSync)(destPath, (destPreamble + destNl + destNl + rebuilt.join(destNl)).replace(/\s*$/, '') + destNl, 'utf8');
|
|
2151
|
+
// Remove from the source only after the destination write succeeded, so a
|
|
2152
|
+
// failure mid-move duplicates rather than loses the entry.
|
|
2153
|
+
const without = [...lines.slice(0, start), ...lines.slice(end)];
|
|
2154
|
+
write(without.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
2155
|
+
return { ok: true, path: destPath };
|
|
2156
|
+
}
|
|
1953
2157
|
const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
|
|
1954
2158
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1955
2159
|
return { ok: true, path: filePath };
|
|
@@ -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) => {
|