fraim 2.0.339 → 2.0.341

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeRunStateKey = safeRunStateKey;
4
+ const crypto_1 = require("crypto");
5
+ /** UUIDs retain their documented filenames; other IDs cannot become paths or device names. */
6
+ function safeRunStateKey(id) {
7
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
8
+ ? id : `id-${(0, crypto_1.createHash)('sha256').update(id).digest('hex')}`;
9
+ }
@@ -28,9 +28,14 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
28
28
  'learning_usage',
29
29
  'hub_event_logs',
30
30
  ];
31
+ function contentRoot(workspaceRoot) {
32
+ const packContent = node_path_1.default.join(workspaceRoot, 'personalized-employee');
33
+ return node_fs_1.default.existsSync(packContent) ? packContent : node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee');
34
+ }
31
35
  function readWorkspaceConfig(workspaceRoot) {
32
36
  try {
33
- const configPath = (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);
@@ -24,7 +24,9 @@ exports.readOrgBrand = readOrgBrand;
24
24
  exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
25
25
  exports.countPreservedLearnings = countPreservedLearnings;
26
26
  exports.collectBrainLearningDots = collectBrainLearningDots;
27
+ exports.readRejectedProposals = readRejectedProposals;
27
28
  exports.readPreservedLearnings = readPreservedLearnings;
29
+ exports.normalizeLearningDomain = normalizeLearningDomain;
28
30
  exports.applyLearningEntryChange = applyLearningEntryChange;
29
31
  exports.isTruthyFlag = isTruthyFlag;
30
32
  const fs_1 = require("fs");
@@ -496,6 +498,15 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
496
498
  * behaviour instead of merely equivalent.
497
499
  */
498
500
  function resolveUsageClock(workspaceRoot) {
501
+ return resolveUsageViews(workspaceRoot).clock;
502
+ }
503
+ /**
504
+ * Issue #1755: one store read, two views of it: the clock the scorer needs and
505
+ * the counters a surface renders. They are resolved together because reading the
506
+ * store twice for the same pass would let the two disagree, and because the
507
+ * identity resolution that finds a record is the projection's, not each caller's.
508
+ */
509
+ function resolveUsageViews(workspaceRoot) {
499
510
  try {
500
511
  // Required lazily: the store imports the retention config, which imports the
501
512
  // workspace path helpers, and a cycle through this module would be fragile.
@@ -503,14 +514,31 @@ function resolveUsageClock(workspaceRoot) {
503
514
  const projection = require('./learning-usage-projection');
504
515
  const loaded = store.readUsageStore();
505
516
  if (Object.keys(loaded.entries).length === 0)
506
- return undefined;
517
+ return {};
507
518
  const lookup = projection.buildUsageLookup(loaded);
508
- return (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null;
519
+ return {
520
+ clock: (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null,
521
+ stats: (id, fileType, title) => {
522
+ const found = projection.lookupUsage(lookup, id, fileType, title);
523
+ if (!found)
524
+ return null;
525
+ return {
526
+ offered: found.offered,
527
+ offeredAboveThreshold: found.offeredAboveThreshold,
528
+ applied: found.byOutcome.applied || 0,
529
+ prevented: found.byOutcome.prevented || 0,
530
+ ignored: found.byOutcome.ignored || 0,
531
+ notApplicable: found.byOutcome['not-applicable'] || 0,
532
+ lastOfferedAt: found.lastOffered,
533
+ lastFiredAt: found.lastFired,
534
+ };
535
+ },
536
+ };
509
537
  }
510
538
  catch {
511
539
  // Usage data is derived and optional. A failure here must never change what
512
540
  // the agent is given.
513
- return undefined;
541
+ return {};
514
542
  }
515
543
  }
516
544
  function readFrontmatter(content) {
@@ -552,6 +580,34 @@ function pendingL0SortKey(fileName) {
552
580
  const timestamp = fileName.match(/\d{4}-\d{2}-\d{2}(?:T\d{2}-\d{2}-\d{2})?/);
553
581
  return timestamp?.[0] || fileName;
554
582
  }
583
+ /**
584
+ * Issue #1857 R11: how many proposals are waiting for the manager's review.
585
+ *
586
+ * This counts directory entries and nothing else. It does not open a proposal, and
587
+ * deliberately has no access to one: R10 is an invariant about influence rather
588
+ * than storage, so the employee learns that a backlog exists and which job clears
589
+ * it, never what any pending claim says.
590
+ *
591
+ * The same shape as the pending-L0 line it sits beside, for the same reason: a
592
+ * count plus the job that clears it is actionable; a list of contents is not.
593
+ */
594
+ function countPendingProposals(roots, resolvedUserId) {
595
+ let total = 0;
596
+ for (const base of [roots.repoLearningsBase, roots.globalPersonalBase]) {
597
+ if (!base)
598
+ continue;
599
+ const queueDir = (0, path_1.join)(base, 'review-queue', resolvedUserId);
600
+ if (!(0, fs_1.existsSync)(queueDir))
601
+ continue;
602
+ try {
603
+ total += (0, fs_1.readdirSync)(queueDir).filter((name) => name.endsWith('.json')).length;
604
+ }
605
+ catch {
606
+ // An unreadable queue is not worth failing a job's startup over.
607
+ }
608
+ }
609
+ return total;
610
+ }
555
611
  function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
556
612
  const sources = [];
557
613
  const rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
@@ -796,7 +852,11 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
796
852
  const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
797
853
  const hasL2 = l2Files.length > 0;
798
854
  const hasL1 = l1Files.length > 0;
799
- const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0;
855
+ // Issue #1857 R11. Counted before the gate on purpose: a manager with a full
856
+ // review queue and an empty corpus is exactly the case where the backlog most
857
+ // needs saying, and computing it after the early return would silence it there.
858
+ const pendingProposals = countPendingProposals(roots, resolvedUserId);
859
+ const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0 || pendingProposals > 0;
800
860
  if (!hasContent)
801
861
  return '';
802
862
  let section = forJob
@@ -835,6 +895,13 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
835
895
  }
836
896
  section += '\n';
837
897
  }
898
+ // Issue #1857 R11. The count, never the content: an unapproved proposal does
899
+ // not influence this job, and the employee is told only that the manager has a
900
+ // backlog and which job clears it.
901
+ if (pendingProposals > 0) {
902
+ const plural = pendingProposals !== 1;
903
+ section += `${pendingProposals} proposal${plural ? 's are' : ' is'} waiting for your manager's review; run \`sleep-on-learnings\` to review ${plural ? 'them' : 'it'}.\n\n`;
904
+ }
838
905
  const coachPresent = resolved.coachPresent;
839
906
  if (forJob) {
840
907
  if (hasL2 || hasL1) {
@@ -1216,7 +1283,7 @@ function isTeamContextKey(value) {
1216
1283
  * user-level `~/.fraim/…` path (where org onboarding writes).
1217
1284
  * - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
1218
1285
  */
1219
- function resolveTeamContextFile(workspaceRoot, key) {
1286
+ function resolveTeamContextFile(workspaceRoot, key, options = {}) {
1220
1287
  const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
1221
1288
  const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
1222
1289
  const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
@@ -1245,7 +1312,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
1245
1312
  // destination (R5.1), so writePath stays empty and the caller must route
1246
1313
  // edits through the org backend's propose-and-approve flow.
1247
1314
  if (key === 'org' || key === 'orgRules') {
1248
- const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
1315
+ const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', options).contentRoot, relativePath);
1249
1316
  if ((0, fs_1.existsSync)(cachePath)) {
1250
1317
  const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
1251
1318
  const orgDisplayPath = cachePath.startsWith(fraimDir)
@@ -1262,7 +1329,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
1262
1329
  }
1263
1330
  }
1264
1331
  if (key === 'manager' || key === 'managerRules') {
1265
- const managerHome = (0, pack_home_1.resolvePackHome)('manager');
1332
+ const managerHome = (0, pack_home_1.resolvePackHome)('manager', options);
1266
1333
  const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
1267
1334
  if ((0, fs_1.existsSync)(managerCachePath)) {
1268
1335
  const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
@@ -1697,7 +1764,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
1697
1764
  // threshold: entries with score >= threshold (or in a non-gated category) are
1698
1765
  // marked injected:true. Callers that don't need scoring pass Infinity so
1699
1766
  // all entries are injected.
1700
- function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock) {
1767
+ function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock, usageStats) {
1701
1768
  if (!(0, fs_1.existsSync)(filePath))
1702
1769
  return [];
1703
1770
  let content;
@@ -1719,6 +1786,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1719
1786
  const flush = () => {
1720
1787
  if (current) {
1721
1788
  current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
1789
+ // Issue #1755: resolve usage once per entry, before scoring, so the
1790
+ // counters a surface renders and the clock the score uses come from
1791
+ // the same record rather than two independent lookups.
1792
+ current.usage = usageStats ? usageStats(current.id, fileType, current.title) : null;
1722
1793
  if (isScoreGated) {
1723
1794
  if (curLastSeen !== null) {
1724
1795
  const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
@@ -1753,7 +1824,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1753
1824
  title: header[2].trim(),
1754
1825
  id: null,
1755
1826
  body: '', source: displayPath, category, level, domain,
1756
- score: null, injected: true,
1827
+ score: null, injected: true, usage: null,
1757
1828
  };
1758
1829
  if (tier !== undefined)
1759
1830
  entry.tier = tier;
@@ -1794,18 +1865,106 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1794
1865
  flush();
1795
1866
  return out;
1796
1867
  }
1868
+ /**
1869
+ * Read the rejection history a sleep run writes on completion.
1870
+ *
1871
+ * The file is a sequence of `<!-- sleep-rejection:run:action:revision -->` blocks,
1872
+ * each holding a heading, the manager's reason, an optional revisit condition and
1873
+ * the rejected proposal as JSON. `run-application.ts` writes it; this reads it
1874
+ * back without changing a byte, because the file is also a synthesis input and a
1875
+ * reader that rewrote it would corrupt the next run's history.
1876
+ *
1877
+ * A malformed block is skipped rather than failing the whole read: the manager
1878
+ * losing the view of every rejection because one block is damaged is worse than
1879
+ * the view missing that one.
1880
+ */
1881
+ function readRejectedProposals(workspaceRoot, userId) {
1882
+ const roots = getLearningRoots(workspaceRoot);
1883
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1884
+ if (!resolvedUserId)
1885
+ return [];
1886
+ const out = [];
1887
+ const seen = new Set();
1888
+ const headingRe = /^## (Rejected|Superseded): (.+)$/m;
1889
+ const reasonRe = /^Reason: (.+)$/m;
1890
+ const revisitRe = /^Revisit when: (.+)$/m;
1891
+ const jsonRe = /```json\r?\n([\s\S]*?)\r?\n```/;
1892
+ for (const base of [roots.repoLearningsBase, roots.globalPersonalBase, roots.managerCacheBase]) {
1893
+ if (!base)
1894
+ continue;
1895
+ const file = (0, path_1.join)(base, `${resolvedUserId}-rejected-proposals.md`);
1896
+ if (!(0, fs_1.existsSync)(file))
1897
+ continue;
1898
+ let body;
1899
+ try {
1900
+ body = (0, fs_1.readFileSync)(file, 'utf8');
1901
+ }
1902
+ catch {
1903
+ continue;
1904
+ }
1905
+ const displayPath = file.split('\\').join('/');
1906
+ for (const raw of body.split('<!-- sleep-rejection:').slice(1)) {
1907
+ const end = raw.indexOf('<!-- /sleep-rejection -->');
1908
+ const chunk = end >= 0 ? raw.slice(0, end) : raw;
1909
+ const marker = chunk.slice(0, Math.max(0, chunk.indexOf('-->')));
1910
+ const parts = marker.trim().split(':');
1911
+ const heading = headingRe.exec(chunk);
1912
+ const reason = reasonRe.exec(chunk);
1913
+ const revisit = revisitRe.exec(chunk);
1914
+ const json = jsonRe.exec(chunk);
1915
+ if (!heading || !reason)
1916
+ continue;
1917
+ let proposal = {};
1918
+ if (json) {
1919
+ try {
1920
+ proposal = JSON.parse(json[1]);
1921
+ }
1922
+ catch {
1923
+ // Keep the human-readable half; the JSON only enriches it.
1924
+ }
1925
+ }
1926
+ const decision = (proposal.decision || {});
1927
+ const content = (proposal.content || {});
1928
+ const trace = (content.trace || {});
1929
+ const claimKey = typeof proposal.claimKey === 'string' && proposal.claimKey
1930
+ ? proposal.claimKey
1931
+ : parts[1] || heading[2];
1932
+ // The same claim can appear in more than one configured base. The first
1933
+ // base that carries it wins, matching how the tier merge resolves.
1934
+ const key = `${claimKey}:${String(decision.at || '')}`;
1935
+ if (seen.has(key))
1936
+ continue;
1937
+ seen.add(key);
1938
+ out.push({
1939
+ claimKey,
1940
+ title: heading[2].trim(),
1941
+ reason: reason[1].trim(),
1942
+ ...(revisit ? { revisitCondition: revisit[1].trim() } : {}),
1943
+ // The heading and the stored decision agree; the heading is what a
1944
+ // manager reading the file sees, so it is the one that governs here.
1945
+ kind: heading[1] === 'Superseded' || decision.kind === 'superseded' ? 'superseded' : 'refused',
1946
+ rejectedAt: typeof decision.at === 'string' ? decision.at : '',
1947
+ ...(typeof trace.recurrences === 'number' ? { recurrencesAtRejection: trace.recurrences } : {}),
1948
+ displayPath,
1949
+ });
1950
+ }
1951
+ }
1952
+ // Newest first: the manager is most often checking what they just said no to.
1953
+ return out.sort((a, b) => String(b.rejectedAt).localeCompare(String(a.rejectedAt)));
1954
+ }
1797
1955
  function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
1798
1956
  const roots = getLearningRoots(workspaceRoot);
1799
1957
  const threshold = getScoreThreshold(workspaceRoot);
1800
1958
  // Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
1801
1959
  // read the same clock. One store read serves the whole call.
1802
- const usageClock = resolveUsageClock(workspaceRoot);
1960
+ // Issue #1755: the same read also yields the counters the Hub renders.
1961
+ const { clock: usageClock, stats: usageStats } = resolveUsageViews(workspaceRoot);
1803
1962
  const out = [];
1804
1963
  // Helper: parse + merge a list of tier paths for one file family.
1805
1964
  // ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
1806
1965
  const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
1807
1966
  const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
1808
- entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock),
1967
+ entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock, usageStats),
1809
1968
  })));
1810
1969
  if (scope === 'org') {
1811
1970
  for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
@@ -1826,7 +1985,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1826
1985
  seenDomainFile.add(fileName);
1827
1986
  const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
1828
1987
  if (domainTiers.length === 0) {
1829
- out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold));
1988
+ out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageClock, usageStats));
1830
1989
  }
1831
1990
  else {
1832
1991
  out.push(...parseTiers(domainTiers, cat, undefined, domain));
@@ -1843,13 +2002,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1843
2002
  for (const cat of cats) {
1844
2003
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1845
2004
  const f = `${resolvedUserId}-${fileType}.md`;
1846
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold));
2005
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
1847
2006
  const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
1848
2007
  if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
1849
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold));
2008
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
1850
2009
  }
1851
2010
  for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
1852
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold));
2011
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
1853
2012
  }
1854
2013
  }
1855
2014
  return out;
@@ -1880,6 +2039,25 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1880
2039
  }
1881
2040
  return out;
1882
2041
  }
2042
+ /** True for a domain value that means "the undecorated global file". */
2043
+ function isGlobalDomain(domain) {
2044
+ return !domain || domain === 'global';
2045
+ }
2046
+ /** Normalize a caller-supplied domain to one of the known domains, or 'global'. */
2047
+ function normalizeLearningDomain(domain) {
2048
+ if (isGlobalDomain(domain))
2049
+ return 'global';
2050
+ const found = learning_domains_1.LEARNING_DOMAINS.find((d) => d === domain);
2051
+ return found ?? 'global';
2052
+ }
2053
+ function learningFileName(userId, fileType, domain) {
2054
+ // Issue #1755 / #754: normalize here rather than trusting the caller. The HTTP
2055
+ // layer already allowlists the domain, but this function builds a path and a
2056
+ // direct caller passing `../../escape` would otherwise write outside the
2057
+ // learnings root. Anything not in the known set resolves to the global file.
2058
+ const safe = normalizeLearningDomain(domain);
2059
+ return safe === 'global' ? `${userId}-${fileType}.md` : `${userId}-${safe}-${fileType}.md`;
2060
+ }
1883
2061
  function resolveLearningFilePath(workspaceRoot, userId, ref) {
1884
2062
  const roots = getLearningRoots(workspaceRoot);
1885
2063
  const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
@@ -1887,12 +2065,12 @@ function resolveLearningFilePath(workspaceRoot, userId, ref) {
1887
2065
  // Write org entries to the org pack home — same location share-with-others uses.
1888
2066
  // The Hub read path already merges pack home + repo-local, so edits land where
1889
2067
  // the agent reads them without needing a repo commit.
1890
- return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', `org-${fileType}.md`);
2068
+ return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
1891
2069
  }
1892
2070
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1893
2071
  // Issue #1070: for the 'machine' level use the configured manager home.
1894
2072
  const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
1895
- return (0, path_1.join)(base, `${resolvedUserId}-${fileType}.md`);
2073
+ return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain));
1896
2074
  }
1897
2075
  function titleCaseFromFileType(fileType) {
1898
2076
  return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
@@ -1916,9 +2094,10 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1916
2094
  const body = String(payload.body || '').trim();
1917
2095
  const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
1918
2096
  const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
2097
+ const addDomain = normalizeLearningDomain(ref.domain);
1919
2098
  const preamble = existing.trim()
1920
2099
  ? existing.replace(/\s*$/, '')
1921
- : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
2100
+ : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}${addDomain === 'global' ? '' : ` (${addDomain})`}`;
1922
2101
  write(preamble + nl + nl + block);
1923
2102
  return { ok: true, path: filePath };
1924
2103
  }
@@ -1935,8 +2114,12 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1935
2114
  write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
1936
2115
  return { ok: true, path: filePath };
1937
2116
  }
1938
- // edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
2117
+ // edit: keep the entry's metadata lines (Id/Score/Last seen/Recurrences/...) intact,
1939
2118
  // replace only the heading (priority + title) and the prose body.
2119
+ //
2120
+ // Issue #1755: `Id` is in this list. It used not to be, so every Hub edit dropped
2121
+ // the entry's stable identifier and the usage record keyed to it (#1752) was
2122
+ // orphaned: silently, and permanently once the title also changed.
1940
2123
  const headerMatch = lines[start].match(headingRe);
1941
2124
  const sev = (payload.severity || headerMatch[1]);
1942
2125
  const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
@@ -1945,11 +2128,32 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1945
2128
  const t = l.trim();
1946
2129
  if (t === '---')
1947
2130
  continue;
1948
- if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
2131
+ if (/^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
1949
2132
  meta.push(l);
1950
2133
  }
1951
2134
  const body = String(payload.body !== undefined ? payload.body : '').trim();
1952
2135
  const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
2136
+ // Issue #1755 R2/R4: a domain change is a move between files, because the domain
2137
+ // IS the filename. Cut the rebuilt block from the source file and append it to the
2138
+ // destination, so the entry keeps its id, score and history rather than being
2139
+ // re-created as a new entry with no past.
2140
+ const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain);
2141
+ if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain)) {
2142
+ const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain });
2143
+ const destExisting = (0, fs_1.existsSync)(destPath) ? (0, fs_1.readFileSync)(destPath, 'utf8') : '';
2144
+ const destNl = destExisting.includes('\r\n') ? '\r\n' : nl;
2145
+ const fileTypeForHeader = exports.CATEGORY_TO_FILETYPE[ref.category];
2146
+ const destPreamble = destExisting.trim()
2147
+ ? destExisting.replace(/\s*$/, '')
2148
+ : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileTypeForHeader)}${targetDomain === 'global' ? '' : ` (${targetDomain})`}`;
2149
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(destPath), { recursive: true });
2150
+ (0, fs_1.writeFileSync)(destPath, (destPreamble + destNl + destNl + rebuilt.join(destNl)).replace(/\s*$/, '') + destNl, 'utf8');
2151
+ // Remove from the source only after the destination write succeeded, so a
2152
+ // failure mid-move duplicates rather than loses the entry.
2153
+ const without = [...lines.slice(0, start), ...lines.slice(end)];
2154
+ write(without.join(nl).replace(/\n{3,}/g, '\n\n'));
2155
+ return { ok: true, path: destPath };
2156
+ }
1953
2157
  const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
1954
2158
  write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
1955
2159
  return { ok: true, path: filePath };
@@ -27,6 +27,7 @@ const fs_1 = require("fs");
27
27
  const path_1 = require("path");
28
28
  const learning_usage_store_1 = require("./learning-usage-store");
29
29
  const learning_usage_projection_1 = require("./learning-usage-projection");
30
+ const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
30
31
  function daysBetween(from, to) {
31
32
  const a = new Date(from).getTime();
32
33
  const b = new Date(to).getTime();
@@ -39,6 +40,7 @@ function firedCount(record) {
39
40
  }
40
41
  function classifyRecurrence(store, options) {
41
42
  const record = store.entries[options.key];
43
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
42
44
  const base = {
43
45
  key: options.key,
44
46
  title: record?.titles[record.titles.length - 1] ?? null,
@@ -51,6 +53,14 @@ function classifyRecurrence(store, options) {
51
53
  firedTotal: record ? firedCount(record) : 0,
52
54
  },
53
55
  };
56
+ if (record && excluded.has(record.key)) {
57
+ return {
58
+ ...base, classification: 'undetermined',
59
+ diagnosis: 'The available usage evidence cannot establish this recurrence.',
60
+ recommendation: 'Review independent source evidence without attributing this recurrence to usage.',
61
+ recurrenceBump: false, targetOfFix: 'none',
62
+ };
63
+ }
54
64
  if (!record) {
55
65
  return {
56
66
  ...base,
@@ -94,7 +104,7 @@ function classifyRecurrence(store, options) {
94
104
  // per entry per session and a plain newest-N bound evicted the job-labelled
95
105
  // ones within about a day. Asking about a job the store has never retained an
96
106
  // offer for is a gap in the record, not a delivery failure.
97
- const jobCoverage = Object.values(store.entries).some((entry) => entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
107
+ const jobCoverage = Object.values(store.entries).some((entry) => !excluded.has(entry.key) && entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
98
108
  if (matchingOffers.length > 0) {
99
109
  return {
100
110
  ...base,
@@ -231,7 +241,7 @@ function firingsUnderModel(record, model) {
231
241
  * unrecorded.
232
242
  */
233
243
  function modelAttributionUnknown(record) {
234
- return firedCount(record) > 0 && Object.keys(record.fired.byModel).length === 0;
244
+ return (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record);
235
245
  }
236
246
  function earlierModelFirings(record, currentModel) {
237
247
  return Object.entries(record.fired.byModel)
@@ -261,24 +271,22 @@ function findRetirementCandidates(store, options) {
261
271
  }
262
272
  const candidates = [];
263
273
  const notCandidates = [];
274
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
264
275
  for (const record of Object.values(store.entries)) {
265
276
  if (record.family !== 'learning')
266
277
  continue;
267
278
  const title = latestTitle(record);
279
+ if (excluded.has(record.key) || modelAttributionUnknown(record)) {
280
+ notCandidates.push({ key: record.key, title,
281
+ reason: excluded.get(record.key) ?? 'Model attribution is incomplete; unknown use is not zero use.' });
282
+ continue;
283
+ }
268
284
  const offered = offersUnderModel(record, currentModel);
269
285
  const fired = firingsUnderModel(record, currentModel);
270
286
  if (fired > 0) {
271
287
  notCandidates.push({ key: record.key, title, reason: `It fired ${fired} time${fired === 1 ? '' : 's'} under the current model, so it is still earning its place.` });
272
288
  continue;
273
289
  }
274
- if (modelAttributionUnknown(record)) {
275
- notCandidates.push({
276
- key: record.key,
277
- title,
278
- reason: `Per-model attribution is unknown for this entry: it fired ${firedCount(record)} time${firedCount(record) === 1 ? '' : 's'} and no firing carries a model, so how hard it is working under ${currentModel} cannot be measured. Held back rather than counted as zero.`,
279
- });
280
- continue;
281
- }
282
290
  if (offered < threshold) {
283
291
  notCandidates.push({
284
292
  key: record.key,
@@ -326,6 +334,10 @@ function deriveStanding(record, options) {
326
334
  const offered = record.offered.aboveThreshold;
327
335
  const title = latestTitle(record);
328
336
  const threshold = options.limits.retirementOfferThreshold;
337
+ if ((0, learning_usage_eligibility_1.countEvidenceProblem)(record, options.offersEraStart)) {
338
+ return { key: record.key, title, standing: options.currentStanding?.(record.key) ?? 'standard',
339
+ reason: 'No usage-based change is supported.', offered: null, fired: null, recommendation: null };
340
+ }
329
341
  let standing;
330
342
  let reason;
331
343
  if (fired > 0 && daysSince(record.fired.lastFiredAt, now) <= STANDING_RECENT_DAYS) {
@@ -356,10 +368,11 @@ function deriveStanding(record, options) {
356
368
  */
357
369
  function assessStanding(store, options) {
358
370
  const now = options.now ?? new Date();
371
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
359
372
  const assessments = Object.values(store.entries)
360
- .filter((r) => r.family === 'learning')
361
- .map((r) => deriveStanding(r, { ...options, now }))
362
- .sort((a, b) => b.fired - a.fired || b.offered - a.offered);
373
+ .filter((r) => r.family === 'learning' && !excluded.has(r.key))
374
+ .map((r) => deriveStanding(r, { ...options, now, offersEraStart: store.offersEraStart }))
375
+ .sort((a, b) => (b.fired ?? 0) - (a.fired ?? 0) || (b.offered ?? 0) - (a.offered ?? 0));
363
376
  return {
364
377
  generatedAt: now.toISOString(),
365
378
  assessments,
@@ -374,7 +387,7 @@ function toReportEntry(record) {
374
387
  fired: firedCount(record),
375
388
  ignored: record.fired.byOutcome.ignored,
376
389
  lastFired: record.fired.lastFiredAt,
377
- firedUnder: Object.keys(record.fired.byModel),
390
+ firedUnder: (0, learning_usage_eligibility_1.modelAttributionIncomplete)(record) ? undefined : Object.keys(record.fired.byModel),
378
391
  recentLog: record.fired.log,
379
392
  };
380
393
  }
@@ -388,7 +401,10 @@ function toReportEntry(record) {
388
401
  function buildUsageReport(store, options) {
389
402
  const now = options.now ?? new Date();
390
403
  const limit = options.limit ?? 10;
391
- const learningRecords = Object.values(store.entries).filter((r) => r.family === 'learning');
404
+ const excluded = (0, learning_usage_eligibility_1.usageEvidenceExclusions)(store);
405
+ const allRecords = Object.values(store.entries).filter((r) => r.family === 'learning');
406
+ const learningRecords = allRecords.filter(r => !excluded.has(r.key));
407
+ const complete = excluded.size === 0 && (!store.evidence || store.evidence.status === 'reliable');
392
408
  const offered = learningRecords.filter((r) => r.offered.total > 0);
393
409
  const fired = learningRecords.filter((r) => firedCount(r) > 0);
394
410
  const neverFired = offered.filter((r) => firedCount(r) === 0);
@@ -397,11 +413,11 @@ function buildUsageReport(store, options) {
397
413
  generatedAt: now.toISOString(),
398
414
  windowDays: options.windowDays,
399
415
  currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
400
- integrityFlagged: learningRecords.filter((r) => Boolean(r.integrity)).length,
416
+ integrityFlagged: excluded.size,
401
417
  totals: {
402
- entriesOffered: offered.length,
403
- entriesWithFirings: fired.length,
404
- entriesNeverFired: neverFired.length,
418
+ entriesOffered: complete ? offered.length : null,
419
+ entriesWithFirings: complete ? fired.length : null,
420
+ entriesNeverFired: complete ? neverFired.length : null,
405
421
  },
406
422
  workingHardest: fired
407
423
  .sort((a, b) => {