fraim 2.0.338 → 2.0.340
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 +153 -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 +43 -16
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +145 -27
- 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 +127 -22
- package/dist/src/local-mcp-server/learning-usage-events.js +267 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +626 -88
- package/dist/src/local-mcp-server/learning-usage-verify.js +207 -0
- package/dist/src/local-mcp-server/stdio-server.js +37 -3
- package/package.json +2 -1
|
@@ -25,6 +25,7 @@ exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
|
|
|
25
25
|
exports.countPreservedLearnings = countPreservedLearnings;
|
|
26
26
|
exports.collectBrainLearningDots = collectBrainLearningDots;
|
|
27
27
|
exports.readPreservedLearnings = readPreservedLearnings;
|
|
28
|
+
exports.normalizeLearningDomain = normalizeLearningDomain;
|
|
28
29
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
29
30
|
exports.isTruthyFlag = isTruthyFlag;
|
|
30
31
|
const fs_1 = require("fs");
|
|
@@ -496,6 +497,15 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
|
|
|
496
497
|
* behaviour instead of merely equivalent.
|
|
497
498
|
*/
|
|
498
499
|
function resolveUsageClock(workspaceRoot) {
|
|
500
|
+
return resolveUsageViews(workspaceRoot).clock;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Issue #1755: one store read, two views of it: the clock the scorer needs and
|
|
504
|
+
* the counters a surface renders. They are resolved together because reading the
|
|
505
|
+
* store twice for the same pass would let the two disagree, and because the
|
|
506
|
+
* identity resolution that finds a record is the projection's, not each caller's.
|
|
507
|
+
*/
|
|
508
|
+
function resolveUsageViews(workspaceRoot) {
|
|
499
509
|
try {
|
|
500
510
|
// Required lazily: the store imports the retention config, which imports the
|
|
501
511
|
// workspace path helpers, and a cycle through this module would be fragile.
|
|
@@ -503,14 +513,31 @@ function resolveUsageClock(workspaceRoot) {
|
|
|
503
513
|
const projection = require('./learning-usage-projection');
|
|
504
514
|
const loaded = store.readUsageStore();
|
|
505
515
|
if (Object.keys(loaded.entries).length === 0)
|
|
506
|
-
return
|
|
516
|
+
return {};
|
|
507
517
|
const lookup = projection.buildUsageLookup(loaded);
|
|
508
|
-
return
|
|
518
|
+
return {
|
|
519
|
+
clock: (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null,
|
|
520
|
+
stats: (id, fileType, title) => {
|
|
521
|
+
const found = projection.lookupUsage(lookup, id, fileType, title);
|
|
522
|
+
if (!found)
|
|
523
|
+
return null;
|
|
524
|
+
return {
|
|
525
|
+
offered: found.offered,
|
|
526
|
+
offeredAboveThreshold: found.offeredAboveThreshold,
|
|
527
|
+
applied: found.byOutcome.applied || 0,
|
|
528
|
+
prevented: found.byOutcome.prevented || 0,
|
|
529
|
+
ignored: found.byOutcome.ignored || 0,
|
|
530
|
+
notApplicable: found.byOutcome['not-applicable'] || 0,
|
|
531
|
+
lastOfferedAt: found.lastOffered,
|
|
532
|
+
lastFiredAt: found.lastFired,
|
|
533
|
+
};
|
|
534
|
+
},
|
|
535
|
+
};
|
|
509
536
|
}
|
|
510
537
|
catch {
|
|
511
538
|
// Usage data is derived and optional. A failure here must never change what
|
|
512
539
|
// the agent is given.
|
|
513
|
-
return
|
|
540
|
+
return {};
|
|
514
541
|
}
|
|
515
542
|
}
|
|
516
543
|
function readFrontmatter(content) {
|
|
@@ -890,8 +917,9 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
890
917
|
out.push({
|
|
891
918
|
// The store owns key construction. Building the composite key by hand here
|
|
892
919
|
// would be a second definition of what an entry's identity is, and the two
|
|
893
|
-
// would drift the first time normalization changed.
|
|
894
|
-
|
|
920
|
+
// would drift the first time normalization changed. Issue #1752 A1 made
|
|
921
|
+
// that one function rather than four copies of the same expression.
|
|
922
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: current.id, fileType: file.fileType, title: current.title }).key,
|
|
895
923
|
family: 'learning',
|
|
896
924
|
id: current.id,
|
|
897
925
|
title: current.title,
|
|
@@ -939,9 +967,27 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
939
967
|
* listed. `aboveThreshold` is what separates active guidance from the dormant
|
|
940
968
|
* rollup. Nothing here depends on the agent reading anything.
|
|
941
969
|
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
970
|
+
* ## Issue #1752 A2 — one lesson, one offer, stamped or not
|
|
971
|
+
*
|
|
972
|
+
* This deduped by usage key, which sounds like the same thing as the context
|
|
973
|
+
* reader's rule and is not. `resolveOrgLearningFileTiers` deliberately returns
|
|
974
|
+
* both the org pack copy and the repo-local copy of an org family, because #1002
|
|
975
|
+
* R4 requires that a promoted entry not hide the rest of its family. The context
|
|
976
|
+
* reader then merges those tiers with `mergeLearningEntriesAcrossTiers`, keyed on
|
|
977
|
+
* the normalized title, so the agent sees one lesson. Keying the offer record on
|
|
978
|
+
* `id ?? titleUsageKey(...)` instead meant that a lesson carrying an `**Id**` line
|
|
979
|
+
* in one tier and not the other produced two keys, and both were recorded.
|
|
980
|
+
*
|
|
981
|
+
* That is defect 4 in #1752, and it is why `standing --changes-only` could
|
|
982
|
+
* recommend promoting and demoting the same lesson in one response: the
|
|
983
|
+
* title-keyed twin collected the offers while the id-keyed one collected the
|
|
984
|
+
* firings (the firing path never split, because `buildEntryIndex` dedups by
|
|
985
|
+
* identity first-wins). Measured live: five titles emitting two keys each in a
|
|
986
|
+
* single derived offer batch.
|
|
987
|
+
*
|
|
988
|
+
* So group by the same normalized title the context reader groups by, resolve one
|
|
989
|
+
* identity per group preferring whichever tier carries an id, and let the later
|
|
990
|
+
* tier win on content exactly as before.
|
|
945
991
|
*/
|
|
946
992
|
function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
947
993
|
const roots = getLearningRoots(workspaceRoot);
|
|
@@ -950,13 +996,35 @@ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
|
950
996
|
const usageClock = resolveUsageClock(workspaceRoot);
|
|
951
997
|
const now = new Date();
|
|
952
998
|
const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
|
|
953
|
-
|
|
999
|
+
// Grouped by family plus normalized title, which is what `titleUsageKey`
|
|
1000
|
+
// already computes. Two families holding the same title are two lessons, and
|
|
1001
|
+
// that is the existing rule rather than a new one.
|
|
1002
|
+
const byLesson = new Map();
|
|
954
1003
|
for (const file of [...resolved.l2, ...resolved.l1]) {
|
|
955
1004
|
for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
|
|
956
|
-
|
|
1005
|
+
const lessonKey = (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
|
|
1006
|
+
const group = byLesson.get(lessonKey);
|
|
1007
|
+
if (group)
|
|
1008
|
+
group.push(entry);
|
|
1009
|
+
else
|
|
1010
|
+
byLesson.set(lessonKey, [entry]);
|
|
957
1011
|
}
|
|
958
1012
|
}
|
|
959
|
-
|
|
1013
|
+
const out = [];
|
|
1014
|
+
for (const group of byLesson.values()) {
|
|
1015
|
+
// Later tier wins for content, unchanged precedence.
|
|
1016
|
+
const winner = group[group.length - 1];
|
|
1017
|
+
// The id comes from whichever tier carries one, because `stamp-ids` has not
|
|
1018
|
+
// reached the org and manager pack homes for every corpus yet. Latest wins
|
|
1019
|
+
// when more than one tier is stamped.
|
|
1020
|
+
const id = group.reduce((found, entry) => entry.id ?? found, null);
|
|
1021
|
+
out.push({
|
|
1022
|
+
...winner,
|
|
1023
|
+
id,
|
|
1024
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType: winner.fileType, title: winner.title }).key,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
960
1028
|
}
|
|
961
1029
|
/**
|
|
962
1030
|
* Issue #1103 R3: the auto-loaded Team Context rule files.
|
|
@@ -1175,7 +1243,7 @@ function isTeamContextKey(value) {
|
|
|
1175
1243
|
* user-level `~/.fraim/…` path (where org onboarding writes).
|
|
1176
1244
|
* - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
|
|
1177
1245
|
*/
|
|
1178
|
-
function resolveTeamContextFile(workspaceRoot, key) {
|
|
1246
|
+
function resolveTeamContextFile(workspaceRoot, key, options = {}) {
|
|
1179
1247
|
const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
|
|
1180
1248
|
const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
|
|
1181
1249
|
const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
|
|
@@ -1204,7 +1272,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1204
1272
|
// destination (R5.1), so writePath stays empty and the caller must route
|
|
1205
1273
|
// edits through the org backend's propose-and-approve flow.
|
|
1206
1274
|
if (key === 'org' || key === 'orgRules') {
|
|
1207
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
|
|
1275
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', options).contentRoot, relativePath);
|
|
1208
1276
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
1209
1277
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
1210
1278
|
const orgDisplayPath = cachePath.startsWith(fraimDir)
|
|
@@ -1221,7 +1289,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1221
1289
|
}
|
|
1222
1290
|
}
|
|
1223
1291
|
if (key === 'manager' || key === 'managerRules') {
|
|
1224
|
-
const managerHome = (0, pack_home_1.resolvePackHome)('manager');
|
|
1292
|
+
const managerHome = (0, pack_home_1.resolvePackHome)('manager', options);
|
|
1225
1293
|
const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
|
|
1226
1294
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
1227
1295
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
@@ -1656,7 +1724,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
|
|
|
1656
1724
|
// threshold: entries with score >= threshold (or in a non-gated category) are
|
|
1657
1725
|
// marked injected:true. Callers that don't need scoring pass Infinity so
|
|
1658
1726
|
// all entries are injected.
|
|
1659
|
-
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock) {
|
|
1727
|
+
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock, usageStats) {
|
|
1660
1728
|
if (!(0, fs_1.existsSync)(filePath))
|
|
1661
1729
|
return [];
|
|
1662
1730
|
let content;
|
|
@@ -1678,6 +1746,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1678
1746
|
const flush = () => {
|
|
1679
1747
|
if (current) {
|
|
1680
1748
|
current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
1749
|
+
// Issue #1755: resolve usage once per entry, before scoring, so the
|
|
1750
|
+
// counters a surface renders and the clock the score uses come from
|
|
1751
|
+
// the same record rather than two independent lookups.
|
|
1752
|
+
current.usage = usageStats ? usageStats(current.id, fileType, current.title) : null;
|
|
1681
1753
|
if (isScoreGated) {
|
|
1682
1754
|
if (curLastSeen !== null) {
|
|
1683
1755
|
const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
|
|
@@ -1712,7 +1784,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1712
1784
|
title: header[2].trim(),
|
|
1713
1785
|
id: null,
|
|
1714
1786
|
body: '', source: displayPath, category, level, domain,
|
|
1715
|
-
score: null, injected: true,
|
|
1787
|
+
score: null, injected: true, usage: null,
|
|
1716
1788
|
};
|
|
1717
1789
|
if (tier !== undefined)
|
|
1718
1790
|
entry.tier = tier;
|
|
@@ -1758,13 +1830,14 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1758
1830
|
const threshold = getScoreThreshold(workspaceRoot);
|
|
1759
1831
|
// Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
|
|
1760
1832
|
// read the same clock. One store read serves the whole call.
|
|
1761
|
-
|
|
1833
|
+
// Issue #1755: the same read also yields the counters the Hub renders.
|
|
1834
|
+
const { clock: usageClock, stats: usageStats } = resolveUsageViews(workspaceRoot);
|
|
1762
1835
|
const out = [];
|
|
1763
1836
|
// Helper: parse + merge a list of tier paths for one file family.
|
|
1764
1837
|
// ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
|
|
1765
1838
|
const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
|
|
1766
1839
|
const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
|
|
1767
|
-
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock),
|
|
1840
|
+
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock, usageStats),
|
|
1768
1841
|
})));
|
|
1769
1842
|
if (scope === 'org') {
|
|
1770
1843
|
for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
|
|
@@ -1785,7 +1858,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1785
1858
|
seenDomainFile.add(fileName);
|
|
1786
1859
|
const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
|
|
1787
1860
|
if (domainTiers.length === 0) {
|
|
1788
|
-
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold));
|
|
1861
|
+
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageClock, usageStats));
|
|
1789
1862
|
}
|
|
1790
1863
|
else {
|
|
1791
1864
|
out.push(...parseTiers(domainTiers, cat, undefined, domain));
|
|
@@ -1802,13 +1875,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1802
1875
|
for (const cat of cats) {
|
|
1803
1876
|
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
1804
1877
|
const f = `${resolvedUserId}-${fileType}.md`;
|
|
1805
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold));
|
|
1878
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
|
|
1806
1879
|
const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
|
|
1807
1880
|
if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
|
|
1808
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold));
|
|
1881
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
|
|
1809
1882
|
}
|
|
1810
1883
|
for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
|
|
1811
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold));
|
|
1884
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
|
|
1812
1885
|
}
|
|
1813
1886
|
}
|
|
1814
1887
|
return out;
|
|
@@ -1839,6 +1912,25 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1839
1912
|
}
|
|
1840
1913
|
return out;
|
|
1841
1914
|
}
|
|
1915
|
+
/** True for a domain value that means "the undecorated global file". */
|
|
1916
|
+
function isGlobalDomain(domain) {
|
|
1917
|
+
return !domain || domain === 'global';
|
|
1918
|
+
}
|
|
1919
|
+
/** Normalize a caller-supplied domain to one of the known domains, or 'global'. */
|
|
1920
|
+
function normalizeLearningDomain(domain) {
|
|
1921
|
+
if (isGlobalDomain(domain))
|
|
1922
|
+
return 'global';
|
|
1923
|
+
const found = learning_domains_1.LEARNING_DOMAINS.find((d) => d === domain);
|
|
1924
|
+
return found ?? 'global';
|
|
1925
|
+
}
|
|
1926
|
+
function learningFileName(userId, fileType, domain) {
|
|
1927
|
+
// Issue #1755 / #754: normalize here rather than trusting the caller. The HTTP
|
|
1928
|
+
// layer already allowlists the domain, but this function builds a path and a
|
|
1929
|
+
// direct caller passing `../../escape` would otherwise write outside the
|
|
1930
|
+
// learnings root. Anything not in the known set resolves to the global file.
|
|
1931
|
+
const safe = normalizeLearningDomain(domain);
|
|
1932
|
+
return safe === 'global' ? `${userId}-${fileType}.md` : `${userId}-${safe}-${fileType}.md`;
|
|
1933
|
+
}
|
|
1842
1934
|
function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
1843
1935
|
const roots = getLearningRoots(workspaceRoot);
|
|
1844
1936
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
@@ -1846,12 +1938,12 @@ function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
|
1846
1938
|
// Write org entries to the org pack home — same location share-with-others uses.
|
|
1847
1939
|
// The Hub read path already merges pack home + repo-local, so edits land where
|
|
1848
1940
|
// the agent reads them without needing a repo commit.
|
|
1849
|
-
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings',
|
|
1941
|
+
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
|
|
1850
1942
|
}
|
|
1851
1943
|
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
1852
1944
|
// Issue #1070: for the 'machine' level use the configured manager home.
|
|
1853
1945
|
const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
|
|
1854
|
-
return (0, path_1.join)(base,
|
|
1946
|
+
return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain));
|
|
1855
1947
|
}
|
|
1856
1948
|
function titleCaseFromFileType(fileType) {
|
|
1857
1949
|
return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
@@ -1875,9 +1967,10 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1875
1967
|
const body = String(payload.body || '').trim();
|
|
1876
1968
|
const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
|
|
1877
1969
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
1970
|
+
const addDomain = normalizeLearningDomain(ref.domain);
|
|
1878
1971
|
const preamble = existing.trim()
|
|
1879
1972
|
? existing.replace(/\s*$/, '')
|
|
1880
|
-
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
|
|
1973
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}${addDomain === 'global' ? '' : ` (${addDomain})`}`;
|
|
1881
1974
|
write(preamble + nl + nl + block);
|
|
1882
1975
|
return { ok: true, path: filePath };
|
|
1883
1976
|
}
|
|
@@ -1894,8 +1987,12 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1894
1987
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1895
1988
|
return { ok: true, path: filePath };
|
|
1896
1989
|
}
|
|
1897
|
-
// edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
|
|
1990
|
+
// edit: keep the entry's metadata lines (Id/Score/Last seen/Recurrences/...) intact,
|
|
1898
1991
|
// replace only the heading (priority + title) and the prose body.
|
|
1992
|
+
//
|
|
1993
|
+
// Issue #1755: `Id` is in this list. It used not to be, so every Hub edit dropped
|
|
1994
|
+
// the entry's stable identifier and the usage record keyed to it (#1752) was
|
|
1995
|
+
// orphaned: silently, and permanently once the title also changed.
|
|
1899
1996
|
const headerMatch = lines[start].match(headingRe);
|
|
1900
1997
|
const sev = (payload.severity || headerMatch[1]);
|
|
1901
1998
|
const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
|
|
@@ -1904,11 +2001,32 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1904
2001
|
const t = l.trim();
|
|
1905
2002
|
if (t === '---')
|
|
1906
2003
|
continue;
|
|
1907
|
-
if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
2004
|
+
if (/^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
1908
2005
|
meta.push(l);
|
|
1909
2006
|
}
|
|
1910
2007
|
const body = String(payload.body !== undefined ? payload.body : '').trim();
|
|
1911
2008
|
const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
|
|
2009
|
+
// Issue #1755 R2/R4: a domain change is a move between files, because the domain
|
|
2010
|
+
// IS the filename. Cut the rebuilt block from the source file and append it to the
|
|
2011
|
+
// destination, so the entry keeps its id, score and history rather than being
|
|
2012
|
+
// re-created as a new entry with no past.
|
|
2013
|
+
const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain);
|
|
2014
|
+
if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain)) {
|
|
2015
|
+
const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain });
|
|
2016
|
+
const destExisting = (0, fs_1.existsSync)(destPath) ? (0, fs_1.readFileSync)(destPath, 'utf8') : '';
|
|
2017
|
+
const destNl = destExisting.includes('\r\n') ? '\r\n' : nl;
|
|
2018
|
+
const fileTypeForHeader = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
2019
|
+
const destPreamble = destExisting.trim()
|
|
2020
|
+
? destExisting.replace(/\s*$/, '')
|
|
2021
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileTypeForHeader)}${targetDomain === 'global' ? '' : ` (${targetDomain})`}`;
|
|
2022
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(destPath), { recursive: true });
|
|
2023
|
+
(0, fs_1.writeFileSync)(destPath, (destPreamble + destNl + destNl + rebuilt.join(destNl)).replace(/\s*$/, '') + destNl, 'utf8');
|
|
2024
|
+
// Remove from the source only after the destination write succeeded, so a
|
|
2025
|
+
// failure mid-move duplicates rather than loses the entry.
|
|
2026
|
+
const without = [...lines.slice(0, start), ...lines.slice(end)];
|
|
2027
|
+
write(without.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
2028
|
+
return { ok: true, path: destPath };
|
|
2029
|
+
}
|
|
1912
2030
|
const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
|
|
1913
2031
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1914
2032
|
return { ok: true, path: filePath };
|
|
@@ -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;
|