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.
@@ -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 undefined;
516
+ return {};
507
517
  const lookup = projection.buildUsageLookup(loaded);
508
- return (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null;
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 undefined;
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
- key: current.id ?? (0, learning_usage_store_1.titleUsageKey)(file.fileType, current.title),
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
- * Deduplicated by key with the later tier winning, matching how
943
- * `mergeLearningEntriesAcrossTiers` resolves a title collision across levels, so
944
- * one lesson is one offer even when two levels hold it.
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
- const byKey = new Map();
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
- byKey.set(entry.key, entry);
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
- return [...byKey.values()];
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
- const usageClock = resolveUsageClock(workspaceRoot);
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', `org-${fileType}.md`);
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, `${resolvedUserId}-${fileType}.md`);
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 = options.currentModel ?? (0, learning_usage_projection_1.resolveCurrentModel)(store);
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)(store),
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
- key: entry.id ?? (0, learning_usage_store_1.titleUsageKey)(fileType, entry.title),
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, { limits, now: options.now, storePath: options.storePath });
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;