fraim-hub 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.
@@ -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 = (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
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(/^---\r?\n([\s\S]*?)\r?\n---/);
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(fileName, activeUserEmail) {
171
- return fileName.startsWith(`${activeUserEmail}-`);
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 archiveDir = node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings', 'archive');
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(fileName, activeUserEmail))
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 retrospectivesDir = node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives');
198
- for (const filePath of listFiles(retrospectivesDir)) {
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(fileName, activeUserEmail))
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);
@@ -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 };