fraim 2.0.360-next.0 → 2.0.360-next.1

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CATEGORY_TO_FILETYPE = exports.BRAIN_LEARNING_FILETYPE_TO_REGION = exports.TEAM_CONTEXT_RELATIVE_PATHS = exports.generateLearningEntryId = exports.learningEntryIdLineRegex = exports.learningEntryHeadingRegex = exports.LEARNING_ENTRY_ID_REGEX = exports.LEARNING_PRIORITIES = exports.DEFAULT_THRESHOLD = void 0;
4
+ exports.storedLearningDomains = storedLearningDomains;
4
5
  exports.getScoreThreshold = getScoreThreshold;
5
6
  exports.computeEffectiveScore = computeEffectiveScore;
6
7
  exports.getLearningInputHomes = getLearningInputHomes;
@@ -40,6 +41,13 @@ const brand_store_1 = require("../core/brand-store");
40
41
  // cache, which silently returned nothing once that cache stopped existing.
41
42
  const pack_home_1 = require("../cli/utils/pack-home");
42
43
  const learning_domains_1 = require("../config/learning-domains");
44
+ const entry_authoring_1 = require("../../registry/scripts/learning/entry-authoring");
45
+ /** Catalog-backed allowlist for existing files, including retired storage identities. */
46
+ function storedLearningDomains(effectiveCatalog) {
47
+ const configured = effectiveCatalog?.schemaVersion === 2 && effectiveCatalog.domains && typeof effectiveCatalog.domains === 'object'
48
+ ? Object.keys(effectiveCatalog.domains).filter(domain => /^[a-z][a-z-]*$/.test(domain) && !['global', 'constructor', 'prototype'].includes(domain)) : [];
49
+ return [...new Set([...learning_domains_1.STORED_LEARNING_DOMAINS, ...configured])];
50
+ }
43
51
  const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/learnings').replace(/\/$/, '');
44
52
  /**
45
53
  * The score at or above which an entry is injected. Exported so a test asserts
@@ -535,7 +543,7 @@ const CONTEXT_CATEGORIES = {
535
543
  validated: { ft: 'validated-patterns', label: '(all entries)' },
536
544
  };
537
545
  /** Resolve the files listed in injected context, in delivery order. */
538
- function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain) {
546
+ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain, effectiveCatalog) {
539
547
  // Domain axis (issue #806) plus the connect/job single-injection contract (issue
540
548
  // #860). Session startup stays broad enough for stable preferences plus L0, while
541
549
  // job contexts add the remaining global categories and may append the current job's
@@ -569,11 +577,25 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
569
577
  const collect = (cats, tiersFor, fallbackLevel) => {
570
578
  const globalFiles = [];
571
579
  const domainFiles = [];
572
- const push = (into, key, tiers) => {
580
+ const push = (into, key, tiers, storageDomain = 'global', includeGlobal = true) => {
573
581
  if (tiers.length > 1)
574
582
  multiTier = true;
575
583
  const meta = CONTEXT_CATEGORIES[key];
576
584
  for (const tier of tiers) {
585
+ let selected;
586
+ try {
587
+ const content = (0, fs_1.readFileSync)(tier.path, 'utf8');
588
+ const explicitProvenance = /^\*\*(?:Domain|Originating job)\*\*:/m.test(content);
589
+ if (storageDomain !== 'global' || explicitProvenance)
590
+ selected = (0, entry_authoring_1.selectStoredLearningEntries)(content, storageDomain, activeDomain ?? 'global', includeGlobal, effectiveCatalog);
591
+ else if (!includeGlobal)
592
+ continue;
593
+ }
594
+ catch {
595
+ selected = { entries: [], unresolved: 1 };
596
+ }
597
+ if (selected && !selected.entries.length && !selected.unresolved)
598
+ continue;
577
599
  into.push({
578
600
  path: tier.path,
579
601
  displayPath: tier.displayPath,
@@ -581,6 +603,7 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
581
603
  label: meta.label,
582
604
  isCoach: key === 'coach',
583
605
  level: tier.level ?? fallbackLevel,
606
+ ...(selected ? { selectedEntries: selected.entries, unresolved: selected.unresolved } : {}),
584
607
  });
585
608
  if (key === 'coach')
586
609
  coachPresent = true;
@@ -591,7 +614,11 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
591
614
  }
592
615
  if (activeDomain) {
593
616
  for (const key of cats.domain) {
594
- push(domainFiles, key, tiersFor(CONTEXT_CATEGORIES[key].ft, activeDomain));
617
+ if (sessionCats.includes(key))
618
+ push(domainFiles, key, tiersFor(CONTEXT_CATEGORIES[key].ft), 'global', false);
619
+ for (const storageDomain of storedLearningDomains(effectiveCatalog)) {
620
+ push(domainFiles, key, tiersFor(CONTEXT_CATEGORIES[key].ft, storageDomain), storageDomain);
621
+ }
595
622
  }
596
623
  }
597
624
  return [...globalFiles, ...domainFiles];
@@ -600,16 +627,28 @@ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJo
600
627
  const l1 = collect({ global: l1GlobalCats, domain: l1DomainCats }, (ft, dom) => resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`), 'manager');
601
628
  return { l2, l1, multiTier, coachPresent };
602
629
  }
603
- function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
630
+ function buildLearningContextSection(workspaceRoot, userId, forJob, domain, effectiveCatalog) {
604
631
  const roots = getLearningRoots(workspaceRoot);
605
632
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
606
- const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
633
+ const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain, effectiveCatalog);
607
634
  const multiTier = resolved.multiTier;
608
635
  const toListed = (f) => ({
609
636
  displayPath: f.displayPath,
610
637
  label: f.label,
611
638
  isCoach: f.isCoach,
639
+ selectedEntries: f.selectedEntries,
640
+ unresolved: f.unresolved,
612
641
  });
642
+ const renderLearningFile = (f) => {
643
+ if (!f.selectedEntries)
644
+ return `\`${f.displayPath}\` ${f.label}\n`;
645
+ let result = '';
646
+ if (f.selectedEntries.length)
647
+ result += `Selected entries from \`${f.displayPath}\` ${f.isCoach ? '(manager-facing feedback)' : ''}:\n${f.selectedEntries.join('\n\n')}\n`;
648
+ if (f.unresolved)
649
+ result += `needs-domain: ${f.unresolved} historical entr${f.unresolved === 1 ? 'y' : 'ies'} in \`${f.displayPath}\` require original job provenance or an explicit manager domain decision. Retained for review; not standing guidance.\n`;
650
+ return result;
651
+ };
613
652
  const l2Files = resolved.l2.map(toListed);
614
653
  const l1Files = resolved.l1.map(toListed);
615
654
  const pendingL0Sources = collectPendingL0SourceFiles(workspaceRoot, userId.trim(), roots);
@@ -628,13 +667,13 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
628
667
  if (hasL2) {
629
668
  section += '### L2 - Org patterns\n';
630
669
  for (const f of l2Files)
631
- section += `\`${f.displayPath}\` ${f.label}\n`;
670
+ section += renderLearningFile(f);
632
671
  section += '\n';
633
672
  }
634
673
  if (hasL1) {
635
674
  section += '### L1 - Your patterns\n';
636
675
  for (const f of l1Files)
637
- section += `\`${f.displayPath}\` ${f.label}\n`;
676
+ section += renderLearningFile(f);
638
677
  section += '\n';
639
678
  }
640
679
  if (l0CoachingCount > 0 || l0RetroCount > 0) {
@@ -662,7 +701,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
662
701
  const coachPresent = resolved.coachPresent;
663
702
  if (forJob) {
664
703
  if (hasL2 || hasL1) {
665
- section += 'Read the listed synthesized learning files before continuing, then apply the relevant patterns and preferences in this job.\n';
704
+ section += 'Read the listed global learning files before continuing and apply the selected domain entries shown above. Source paths for selected or needs-domain entries are provenance, not instructions to load the whole file. Do not apply omitted or unresolved entries.\n';
666
705
  if (multiTier) {
667
706
  section += 'A family listed at more than one level is combined, not replaced. Stable IDs identify lessons; equal titles do not establish identity. Conflicting copies require explicit reconciliation: do not choose a revision by scope, timestamp, count, or title. Preserve approved decisions and useful refinements; unresolved conflicting claims are not authority for action.\n';
668
707
  }
@@ -672,7 +711,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
672
711
  }
673
712
  }
674
713
  else if (hasL2 || hasL1) {
675
- section += 'Read the listed synthesized learning files before continuing, then use this synthesized learning context throughout the session.\n';
714
+ section += 'Read the listed synthesized learning files before continuing, then use this synthesized learning context throughout the session. Selected entry source paths are provenance; do not read omitted entries or apply needs-domain material.\n';
676
715
  if (multiTier) {
677
716
  section += 'A family listed at more than one level is combined, not replaced. Stable IDs identify lessons; equal titles do not establish identity. Conflicting copies require explicit reconciliation: do not choose a revision by scope, timestamp, count, or title. Preserve approved decisions and useful refinements; unresolved conflicting claims are not authority for action.\n';
678
717
  }
@@ -1034,7 +1073,7 @@ function readLearningEntryTitles(filePath) {
1034
1073
  * manager-coaching entries that field used to hold, so the tile labelled
1035
1074
  * "Manager" reports the level rather than a category.
1036
1075
  */
1037
- function countPreservedLearnings(workspaceRoot, userId) {
1076
+ function countPreservedLearnings(workspaceRoot, userId, effectiveCatalog) {
1038
1077
  const roots = getLearningRoots(workspaceRoot);
1039
1078
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1040
1079
  // Distinct entry titles across every level holding this company family, so a
@@ -1086,7 +1125,7 @@ function countPreservedLearnings(workspaceRoot, userId) {
1086
1125
  let reverseMentoring = coachByLevel.manager + coachByLevel.project;
1087
1126
  // #806: domain-scoped files fold into the same per-level counts so the Brain
1088
1127
  // reconciles with what the loader injects for a job.
1089
- for (const domain of learning_domains_1.LEARNING_DOMAINS) {
1128
+ for (const domain of storedLearningDomains(effectiveCatalog)) {
1090
1129
  for (const ft of ORG_TYPES)
1091
1130
  organization += countOrgFamily(`org-${domain}-${ft}.md`);
1092
1131
  for (const ft of PERSONAL_WORK_TYPES) {
@@ -1297,11 +1336,11 @@ const FILETYPE_TO_CATEGORY = {
1297
1336
  * #806) present in a directory, in a deterministic domain order. Used by the Hub
1298
1337
  * read/count paths so domain learnings appear alongside the flat global files.
1299
1338
  */
1300
- function listDomainLearningFiles(dir, prefix, fileType) {
1339
+ function listDomainLearningFiles(dir, prefix, fileType, effectiveCatalog) {
1301
1340
  if (!(0, fs_1.existsSync)(dir))
1302
1341
  return [];
1303
1342
  const out = [];
1304
- for (const domain of learning_domains_1.LEARNING_DOMAINS) {
1343
+ for (const domain of storedLearningDomains(effectiveCatalog)) {
1305
1344
  const fileName = `${prefix}-${domain}-${fileType}.md`;
1306
1345
  if ((0, fs_1.existsSync)((0, path_1.join)(dir, fileName)))
1307
1346
  out.push({ fileName, domain });
@@ -1404,7 +1443,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1404
1443
  }
1405
1444
  // `Id` is listed here as well as matched above so a malformed id line is still
1406
1445
  // treated as bookkeeping rather than leaking into the displayed lesson.
1407
- if (/^\*\*(Id|Score|Technical trace|Users|First synthesized)\*\*:/i.test(t))
1446
+ if (/^\*\*(Id|Score|Technical trace|Users|First recorded|First synthesized|Originating job|Sources|Domain|Domain basis|Domain history)\*\*:/i.test(t))
1408
1447
  continue;
1409
1448
  if (/^(First|Last) synthesized:/i.test(t))
1410
1449
  continue;
@@ -1516,7 +1555,7 @@ function readRejectedProposals(workspaceRoot, userId) {
1516
1555
  // Newest first: the manager is most often checking what they just said no to.
1517
1556
  return out.sort((a, b) => String(b.rejectedAt).localeCompare(String(a.rejectedAt)));
1518
1557
  }
1519
- function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
1558
+ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine', effectiveCatalog) {
1520
1559
  const roots = getLearningRoots(workspaceRoot);
1521
1560
  const usageStats = resolveUsageStats();
1522
1561
  // Only the human library reads these observations. An unavailable store must
@@ -1565,8 +1604,8 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1565
1604
  out.push(...parseTiers(resolveOrgLearningFileTiers(roots.repoLearningsBase, f), cat));
1566
1605
  // #806: domain-scoped org learnings — union from both locations.
1567
1606
  const allDomainFiles = [
1568
- ...listDomainLearningFiles(roots.repoLearningsBase, 'org', fileType).map((d) => ({ ...d, base: roots.repoLearningsBase })),
1569
- ...listDomainLearningFiles((0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings'), 'org', fileType)
1607
+ ...listDomainLearningFiles(roots.repoLearningsBase, 'org', fileType, effectiveCatalog).map((d) => ({ ...d, base: roots.repoLearningsBase })),
1608
+ ...listDomainLearningFiles((0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings'), 'org', fileType, effectiveCatalog)
1570
1609
  .map((d) => ({ ...d, base: (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings') })),
1571
1610
  ];
1572
1611
  const seenDomainFile = new Set();
@@ -1590,7 +1629,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1590
1629
  const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
1591
1630
  for (const cat of cats) {
1592
1631
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1593
- for (const domain of ['global', ...learning_domains_1.LEARNING_DOMAINS]) {
1632
+ for (const domain of ['global', ...storedLearningDomains(effectiveCatalog)]) {
1594
1633
  const domainPart = domain === 'global' ? '' : `-${domain}`;
1595
1634
  for (const storageTier of ['hot', 'cold']) {
1596
1635
  const suffix = storageTier === 'cold' ? COLD_FILE_SUFFIX : '';
@@ -1607,39 +1646,39 @@ function isGlobalDomain(domain) {
1607
1646
  return !domain || domain === 'global';
1608
1647
  }
1609
1648
  /** Normalize a caller-supplied domain to one of the known domains, or 'global'. */
1610
- function normalizeLearningDomain(domain) {
1649
+ function normalizeLearningDomain(domain, effectiveCatalog) {
1611
1650
  if (isGlobalDomain(domain))
1612
1651
  return 'global';
1613
- const found = learning_domains_1.LEARNING_DOMAINS.find((d) => d === domain);
1652
+ const found = storedLearningDomains(effectiveCatalog).find((d) => d === domain);
1614
1653
  return found ?? 'global';
1615
1654
  }
1616
- function learningFileName(userId, fileType, domain) {
1655
+ function learningFileName(userId, fileType, domain, effectiveCatalog) {
1617
1656
  // Issue #1755 / #754: normalize here rather than trusting the caller. The HTTP
1618
1657
  // layer already allowlists the domain, but this function builds a path and a
1619
1658
  // direct caller passing `../../escape` would otherwise write outside the
1620
1659
  // learnings root. Anything not in the known set resolves to the global file.
1621
- const safe = normalizeLearningDomain(domain);
1660
+ const safe = normalizeLearningDomain(domain, effectiveCatalog);
1622
1661
  return safe === 'global' ? `${userId}-${fileType}.md` : `${userId}-${safe}-${fileType}.md`;
1623
1662
  }
1624
- function resolveLearningFilePath(workspaceRoot, userId, ref) {
1663
+ function resolveLearningFilePath(workspaceRoot, userId, ref, effectiveCatalog) {
1625
1664
  const roots = getLearningRoots(workspaceRoot);
1626
1665
  const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
1627
1666
  if (ref.scope === 'org') {
1628
1667
  // Write org entries to the org pack home — same location share-with-others uses.
1629
1668
  // The Hub read path already merges pack home + repo-local, so edits land where
1630
1669
  // the agent reads them without needing a repo commit.
1631
- return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
1670
+ return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings', learningFileName('org', fileType, ref.domain, effectiveCatalog));
1632
1671
  }
1633
1672
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1634
1673
  // Issue #1070: for the 'machine' level use the configured manager home.
1635
1674
  const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
1636
- return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain));
1675
+ return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain, effectiveCatalog));
1637
1676
  }
1638
1677
  function titleCaseFromFileType(fileType) {
1639
1678
  return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
1640
1679
  }
1641
- function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1642
- const filePath = resolveLearningFilePath(workspaceRoot, userId, ref);
1680
+ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload, effectiveCatalog) {
1681
+ const filePath = resolveLearningFilePath(workspaceRoot, userId, ref, effectiveCatalog);
1643
1682
  const existing = (0, fs_1.existsSync)(filePath) ? (0, fs_1.readFileSync)(filePath, 'utf8') : '';
1644
1683
  const nl = existing.includes('\r\n') ? '\r\n' : '\n';
1645
1684
  const lines = existing.length ? existing.split(/\r?\n/) : [];
@@ -1651,13 +1690,28 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1651
1690
  (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
1652
1691
  (0, fs_1.writeFileSync)(filePath, text.replace(/\s*$/, '') + nl, 'utf8');
1653
1692
  };
1693
+ const metadataLine = /^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First recorded|First synthesized|Originating job|Sources|Domain|Domain basis|Domain history)\*\*:/i;
1694
+ const setManagerDomain = (metadata, domain) => {
1695
+ const version = effectiveCatalog?.mapVersion ?? learning_domains_1.LEARNING_DOMAIN_MAP_VERSION;
1696
+ if (!Number.isInteger(version) || version < 1)
1697
+ throw new Error('Cannot record a learning domain decision: restore the domain catalog first.');
1698
+ const oldDomain = metadata.find(line => /^\*\*Domain\*\*:/i.test(line.trim()))?.trim().replace(/^\*\*Domain\*\*:\s*/i, '');
1699
+ const oldBasis = metadata.find(line => /^\*\*Domain basis\*\*:/i.test(line.trim()))?.trim().replace(/^\*\*Domain basis\*\*:\s*/i, '');
1700
+ const retained = metadata.filter(line => !/^\*\*(Domain|Domain basis)\*\*:/i.test(line.trim()));
1701
+ if (oldDomain && (oldDomain !== domain || oldBasis !== `manager; map ${version}`)) {
1702
+ retained.push(`**Domain history**: ${oldDomain}; ${oldBasis ?? 'basis not recorded'}`);
1703
+ }
1704
+ return [...retained, `**Domain**: ${domain}`, `**Domain basis**: manager; map ${version}`];
1705
+ };
1654
1706
  if (action === 'add') {
1655
1707
  const sev = (payload.severity || 'P-MED');
1656
1708
  const title = (payload.title || '').trim() || 'Untitled learning';
1657
- const body = String(payload.body || '').trim();
1658
- const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
1709
+ const suppliedLines = String(payload.body || '').trim().split(/\r?\n/);
1710
+ const addDomain = normalizeLearningDomain(ref.domain, effectiveCatalog);
1711
+ const metadata = setManagerDomain(suppliedLines.filter(line => metadataLine.test(line.trim())), addDomain);
1712
+ const body = suppliedLines.filter(line => !metadataLine.test(line.trim())).join(nl).trim();
1713
+ const block = [`#### [${sev}] ${title}`, '', ...metadata, '', body, '', '---'].join(nl);
1659
1714
  const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
1660
- const addDomain = normalizeLearningDomain(ref.domain);
1661
1715
  const preamble = existing.trim()
1662
1716
  ? existing.replace(/\s*$/, '')
1663
1717
  : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}${addDomain === 'global' ? '' : ` (${addDomain})`}`;
@@ -1701,18 +1755,22 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1701
1755
  const t = l.trim();
1702
1756
  if (t === '---')
1703
1757
  continue;
1704
- if (/^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
1758
+ if (metadataLine.test(t) || /^(First|Last) synthesized:/i.test(t))
1705
1759
  meta.push(l);
1706
1760
  }
1707
- const body = String(payload.body !== undefined ? payload.body : '').trim();
1708
- const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
1761
+ const originalBody = lines.slice(start + 1, end).filter(line => line.trim() !== '---' && !metadataLine.test(line.trim()) && !/^(First|Last) synthesized:/i.test(line.trim())).join(nl).trim();
1762
+ const body = payload.body === undefined ? originalBody : String(payload.body).split(/\r?\n/).filter(line => !metadataLine.test(line.trim())).join(nl).trim();
1763
+ const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain, effectiveCatalog);
1764
+ // A normal prose edit is not authority to reclassify an ambiguous legacy entry.
1765
+ // An explicit choice records a manager decision even when its filename is unchanged.
1766
+ const updatedMeta = targetDomain !== undefined && targetDomain === payload.targetDomain ? setManagerDomain(meta, targetDomain) : meta;
1767
+ const rebuilt = [`#### [${sev}] ${title}`, '', ...updatedMeta, ...(updatedMeta.length ? [''] : []), body, '', '---'];
1709
1768
  // Issue #1755 R2/R4: a domain change is a move between files, because the domain
1710
1769
  // IS the filename. Cut the rebuilt block from the source file and append it to the
1711
1770
  // destination, so the entry keeps its id, score and history rather than being
1712
1771
  // re-created as a new entry with no past.
1713
- const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain);
1714
- if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain)) {
1715
- const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain });
1772
+ if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain, effectiveCatalog)) {
1773
+ const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain }, effectiveCatalog);
1716
1774
  const destExisting = (0, fs_1.existsSync)(destPath) ? (0, fs_1.readFileSync)(destPath, 'utf8') : '';
1717
1775
  const destNl = destExisting.includes('\r\n') ? '\r\n' : nl;
1718
1776
  const fileTypeForHeader = exports.CATEGORY_TO_FILETYPE[ref.category];
@@ -1,19 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- /**
4
- * FRAIM Local MCP Server - STDIO Version
5
- *
6
- * Proxy that:
7
- * 1. Accepts MCP requests via stdin/stdout
8
- * 2. Proxies to remote FRAIM server
9
- * 3. Performs template substitution:
10
- * - Proxy config variables: {{proxy.config.path.to.value}}
11
- * - Platform-specific actions: {{proxy.action.get_issue}}, {{proxy.action.create_pr}}, etc.
12
- * 4. Automatically detects and injects machine/repo info for fraim_connect
13
- * 5. Substitutes {{proxy.delivery.*}} templates based on user's workingStyle
14
- * (PR or Conversation from ~/.fraim/config.json). Delivery phases live
15
- * server-side in the workflow; the proxy just fills in mode-specific content.
16
- */
17
3
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
4
  if (k2 === undefined) k2 = k;
19
5
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -54,6 +40,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
54
40
  exports.FraimLocalMCPServer = exports.FraimTemplateEngine = void 0;
55
41
  exports.parseExecutionModeFromResponse = parseExecutionModeFromResponse;
56
42
  exports.buildExecutionModeForwardUrl = buildExecutionModeForwardUrl;
43
+ const domain_rule_assembler_1 = require("../core/utils/domain-rule-assembler");
44
+ /**
45
+ * FRAIM Local MCP Server - STDIO Version
46
+ *
47
+ * Proxy that:
48
+ * 1. Accepts MCP requests via stdin/stdout
49
+ * 2. Proxies to remote FRAIM server
50
+ * 3. Performs template substitution:
51
+ * - Proxy config variables: {{proxy.config.path.to.value}}
52
+ * - Platform-specific actions: {{proxy.action.get_issue}}, {{proxy.action.create_pr}}, etc.
53
+ * 4. Automatically detects and injects machine/repo info for fraim_connect
54
+ * 5. Substitutes {{proxy.delivery.*}} templates based on user's workingStyle
55
+ * (PR or Conversation from ~/.fraim/config.json). Delivery phases live
56
+ * server-side in the workflow; the proxy just fills in mode-specific content.
57
+ */
57
58
  const fs_1 = require("fs");
58
59
  const path_1 = require("path");
59
60
  const os_1 = require("os");
@@ -80,7 +81,7 @@ const usage_collector_js_1 = require("./usage-collector.js");
80
81
  const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
81
82
  const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
82
83
  const learning_context_builder_js_1 = require("./learning-context-builder.js");
83
- const learning_domains_js_1 = require("../config/learning-domains.js");
84
+ const catalog_1 = require("../../registry/scripts/domains/catalog");
84
85
  const skill_include_dedup_js_1 = require("./skill-include-dedup.js");
85
86
  const git_utils_1 = require("../core/utils/git-utils");
86
87
  /**
@@ -1289,6 +1290,10 @@ class FraimLocalMCPServer {
1289
1290
  * The Hub run pointer bridges CLI and MCP session IDs, which are unrelated.
1290
1291
  */
1291
1292
  recordRecoveryState(args, sessionId, acceptedNext) {
1293
+ // Instruction restoration is a read, including after terminal/review states.
1294
+ // Do not rebind the original execution to the requesting transport session.
1295
+ if (args.status === 'starting' && args.requestInstructions === true)
1296
+ return;
1292
1297
  if (!sessionId || sessionId === 'unknown'
1293
1298
  || typeof args.jobName !== 'string' || !args.jobName
1294
1299
  || typeof args.currentPhase !== 'string' || !args.currentPhase
@@ -1382,6 +1387,8 @@ class FraimLocalMCPServer {
1382
1387
  }
1383
1388
  /** Called only after the response has passed every rejecting contract. */
1384
1389
  acceptPhaseTracking(args, sessionId, nextPhase) {
1390
+ if (args.status === 'starting' && args.requestInstructions === true)
1391
+ return '';
1385
1392
  this.recordRecoveryState(args, sessionId, { nextPhase });
1386
1393
  if (!sessionId || sessionId === 'unknown' || !args.jobName || !args.currentPhase) {
1387
1394
  return args.status === 'complete' || args.status === 'failure'
@@ -1689,6 +1696,23 @@ class FraimLocalMCPServer {
1689
1696
  if (request.method !== 'tools/call' || typeof toolName !== 'string') {
1690
1697
  return this.processResponseWithHydration(response, requestSessionId);
1691
1698
  }
1699
+ // A remote catalog miss does not mean an authorized personalized file is
1700
+ // missing. Keep this exception tied to the file lookup producer's exact
1701
+ // error; authentication, execution and workflow rejections stay authoritative.
1702
+ const remoteFileMissing = toolName === 'get_fraim_file'
1703
+ && typeof args.path === 'string'
1704
+ && response.error?.code === -32603
1705
+ && response.error.message === `File not found: ${args.path}. Check for typos in the file name or file path.`;
1706
+ if (response.result?.isError || response.result?.personaLock || (response.error && !remoteFileMissing))
1707
+ return response;
1708
+ // Connect creates the session: its response, rather than the sessionless
1709
+ // request, authorizes dependent catalog, include and rule reads. Keep it
1710
+ // request-local so a later rejected reconnect cannot change another call.
1711
+ if (toolName === 'fraim_connect') {
1712
+ const connectedSessionId = response.result?.structuredContent?.sessionId ?? response.result?.sessionId;
1713
+ if (typeof connectedSessionId === 'string' && connectedSessionId.trim())
1714
+ requestSessionId = connectedSessionId;
1715
+ }
1692
1716
  const resolver = this.getRegistryResolver(requestSessionId);
1693
1717
  let finalizedResponse = response;
1694
1718
  // 1. Resolve top-level override if this tool maps to a specific file.
@@ -1702,20 +1726,18 @@ class FraimLocalMCPServer {
1702
1726
  registryPath = await resolver.findRegistryPath('jobs', args.job);
1703
1727
  if (registryPath) {
1704
1728
  const localOverride = resolver.hasPersonalizedOverride(registryPath);
1705
- const remoteFailed = Boolean(response.error);
1706
- if (response.error) {
1707
- this.log(`[req:${requestId}] Remote error for ${registryPath}: code=${response.error.code}`);
1708
- }
1709
- if (localOverride || remoteFailed) {
1729
+ if (localOverride || remoteFileMissing) {
1710
1730
  try {
1711
1731
  // Every configured personalized layer takes priority over the remote baseline.
1712
- const resolved = await resolver.resolveFile(registryPath).catch(() => null);
1732
+ const resolved = await resolver.resolveFile(registryPath, { includeMetadata: false }).catch(() => null);
1713
1733
  if (resolved?.content) {
1714
1734
  const newResponse = {
1715
1735
  ...response,
1716
1736
  result: {
1717
1737
  ...response.result,
1718
- content: [{ type: 'text', text: resolved.content }]
1738
+ content: response.result?.content?.length
1739
+ ? response.result.content.map((block, index) => index === 0 ? { type: 'text', text: resolved.content } : block)
1740
+ : [{ type: 'text', text: resolved.content }]
1719
1741
  }
1720
1742
  };
1721
1743
  delete newResponse.error;
@@ -1728,6 +1750,8 @@ class FraimLocalMCPServer {
1728
1750
  }
1729
1751
  }
1730
1752
  }
1753
+ if (finalizedResponse.error || finalizedResponse.result?.isError)
1754
+ return finalizedResponse;
1731
1755
  // 2. Resolve includes within the content (for all registry tools)
1732
1756
  finalizedResponse = await this.resolveIncludesInResponse(finalizedResponse, requestSessionId, requestId);
1733
1757
  // 3. After fraim_connect succeeds, capture user email and inject learning context.
@@ -1768,11 +1792,13 @@ class FraimLocalMCPServer {
1768
1792
  finalizedResponse.result.content[0].text = responseText;
1769
1793
  }
1770
1794
  }
1795
+ let catalogRead;
1771
1796
  // 4. After get_fraim_job succeeds, inject learning context with job-focus frame.
1772
1797
  if (toolName === 'get_fraim_job' && !finalizedResponse.error) {
1773
1798
  const text = finalizedResponse.result?.content?.[0]?.text;
1774
1799
  if (typeof text === 'string') {
1775
- finalizedResponse.result.content[0].text = await this.appendJobContext(text, args.job, requestSessionId, requestId);
1800
+ catalogRead = (0, domain_rule_assembler_1.withRuleDeliveryTimeout)(resolver.resolveFile('domains.json', { includeMetadata: false }));
1801
+ finalizedResponse.result.content[0].text = await this.appendJobContext(text, args.job, requestSessionId, requestId, catalogRead);
1776
1802
  }
1777
1803
  // Issue #1345: forward executionMode from get_fraim_job response to Hub so
1778
1804
  // the chip reads from run data (no local file read, no separate endpoint needed).
@@ -1788,16 +1814,23 @@ class FraimLocalMCPServer {
1788
1814
  }
1789
1815
  }
1790
1816
  }
1817
+ finalizedResponse = await this.addRuleDelivery(request, finalizedResponse, requestSessionId, catalogRead);
1791
1818
  return this.processResponseWithHydration(finalizedResponse, requestSessionId);
1792
1819
  }
1793
1820
  /** Keep context delivery and offer attribution together for local and remote jobs. */
1794
- async appendJobContext(text, jobName, requestSessionId, requestId) {
1821
+ async appendJobContext(text, jobName, requestSessionId, requestId, catalogRead) {
1795
1822
  const userEmail = this.ensureEngine().getUserEmail();
1823
+ const workspaceRoot = this.findProjectRoot() || process.cwd();
1824
+ let effectiveCatalog;
1825
+ try {
1826
+ const source = await (catalogRead ?? (0, domain_rule_assembler_1.withRuleDeliveryTimeout)(this.getRegistryResolver(requestSessionId).resolveFile('domains.json', { includeMetadata: false })));
1827
+ effectiveCatalog = JSON.parse(source.content);
1828
+ }
1829
+ catch { /* Rule delivery returns the source warning; retain available global learning. */ }
1796
1830
  if (!userEmail)
1797
1831
  return text;
1798
- const workspaceRoot = this.findProjectRoot() || process.cwd();
1799
- const jobDomain = await this.resolveJobDomainForJob(jobName, requestSessionId);
1800
- const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true, jobDomain);
1832
+ const jobDomain = effectiveCatalog ? await this.resolveJobDomainForJob(jobName, requestSessionId, effectiveCatalog) : null;
1833
+ const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true, jobDomain, effectiveCatalog);
1801
1834
  const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
1802
1835
  if (learningSection || teamContextSection) {
1803
1836
  text += `\n\n---` + learningSection + teamContextSection;
@@ -1812,7 +1845,7 @@ class FraimLocalMCPServer {
1812
1845
  * context must never break a job response, and an unknown job never guesses a
1813
1846
  * wrong domain.
1814
1847
  */
1815
- async resolveJobDomainForJob(jobName, requestSessionId) {
1848
+ async resolveJobDomainForJob(jobName, requestSessionId, effectiveCatalog) {
1816
1849
  try {
1817
1850
  const resolver = this.getRegistryResolver(requestSessionId);
1818
1851
  if (!resolver)
@@ -1820,12 +1853,9 @@ class FraimLocalMCPServer {
1820
1853
  const registryPath = await resolver.findRegistryPath('jobs', jobName);
1821
1854
  if (!registryPath)
1822
1855
  return null;
1823
- // Read the job file so a domain it self-declares in frontmatter can override
1824
- // the category map (extensible for newly-taught jobs); the pure resolver
1825
- // owns the decision logic. Display metadata would prepend an HTML comment
1826
- // to personalized content, hiding its frontmatter from the domain parser.
1827
- const resolved = await resolver.resolveFile(registryPath, { includeMetadata: false }).catch(() => null);
1828
- return (0, learning_domains_js_1.resolveDomainForJob)(registryPath, resolved?.content ?? null);
1856
+ const catalog = effectiveCatalog ?? JSON.parse((await (0, domain_rule_assembler_1.withRuleDeliveryTimeout)(resolver.resolveFile('domains.json', { includeMetadata: false }))).content);
1857
+ const selected = (0, catalog_1.selectCatalogDomain)(catalog, registryPath);
1858
+ return selected.selection === 'mapped' && selected.domain !== 'global' ? selected.domain : null;
1829
1859
  }
1830
1860
  catch {
1831
1861
  return null;
@@ -1862,7 +1892,51 @@ class FraimLocalMCPServer {
1862
1892
  }
1863
1893
  return errors;
1864
1894
  }
1865
- async finalizeLocalToolTextResponse(request, requestSessionId, requestId, text) {
1895
+ async addRuleDelivery(request, response, sessionId, catalogRead) {
1896
+ if (response.error || response.result?.isError || response.result?.personaLock || !response.result)
1897
+ return response;
1898
+ const tool = request.params?.name;
1899
+ const args = request.params?.arguments || {};
1900
+ let jobPath = null;
1901
+ if (tool === 'get_fraim_job' || tool === 'seekMentoring') {
1902
+ const context = await this.getMentor(sessionId).getRuleDeliveryContext(args.job || args.jobName);
1903
+ if (!context)
1904
+ return response;
1905
+ jobPath = context.jobPath;
1906
+ if (tool === 'seekMentoring' && !(args.status === 'starting' &&
1907
+ (args.currentPhase === 'starting' || args.currentPhase === context.initialPhase || args.requestInstructions === true)))
1908
+ return response;
1909
+ }
1910
+ else if (tool !== 'fraim_connect')
1911
+ return response;
1912
+ // Reuse authenticated connect policy only as the remote fallback. The
1913
+ // ordinary resolver still checks every effective local/overlay source first.
1914
+ const remoteFiles = new Map();
1915
+ if (tool === 'fraim_connect') {
1916
+ const prior = response.result.structuredContent?.ruleAssembly;
1917
+ const block = Number.isInteger(prior?.contentBlockIndex) && prior.contentBlockIndex >= 0
1918
+ ? response.result.content?.[prior.contentBlockIndex] : undefined;
1919
+ const artifact = Array.isArray(prior?.artifacts) && prior.artifacts.length === 1 ? prior.artifacts[0] : undefined;
1920
+ const prefix = '## Applicable rules\n\n### rules/constitution.md\n\n';
1921
+ if (prior?.complete === true && prior.selection === 'connect' && prior.domain === 'global'
1922
+ && Array.isArray(prior.warnings) && prior.warnings.length === 0
1923
+ && artifact?.path === 'rules/constitution.md' && block?.type === 'text'
1924
+ && typeof block.text === 'string' && block.text.startsWith(prefix)) {
1925
+ const body = block.text.slice(prefix.length);
1926
+ if ((0, crypto_1.createHash)('sha256').update(body).digest('hex') === artifact?.hash)
1927
+ remoteFiles.set(artifact.path, body);
1928
+ }
1929
+ }
1930
+ const resolver = this.getRegistryResolver(sessionId, remoteFiles);
1931
+ const result = (0, domain_rule_assembler_1.attachRuleAssembly)(response.result, await (0, domain_rule_assembler_1.assembleDomainRules)({ jobPath,
1932
+ resolveFile: path => path === 'domains.json' && catalogRead ? catalogRead : resolver.resolveFile(path, { includeMetadata: false, stripMcpHeader: true }) }));
1933
+ const metadata = result.structuredContent?.ruleAssembly;
1934
+ for (const warning of metadata?.warnings ?? []) {
1935
+ this.logError('Rule delivery warning: ' + JSON.stringify(warning));
1936
+ }
1937
+ return { ...response, result };
1938
+ }
1939
+ async finalizeLocalToolTextResponse(request, requestSessionId, requestId, text, catalogRead) {
1866
1940
  const response = {
1867
1941
  jsonrpc: '2.0',
1868
1942
  id: request.id,
@@ -1870,7 +1944,8 @@ class FraimLocalMCPServer {
1870
1944
  content: [{ type: 'text', text }]
1871
1945
  }
1872
1946
  };
1873
- const withResolvedIncludes = await this.resolveIncludesInResponse(response, requestSessionId, requestId);
1947
+ const withRules = await this.addRuleDelivery(request, response, requestSessionId, catalogRead);
1948
+ const withResolvedIncludes = await this.resolveIncludesInResponse(withRules, requestSessionId, requestId);
1874
1949
  return this.processResponseWithHydration(withResolvedIncludes, requestSessionId);
1875
1950
  }
1876
1951
  rewriteProxyTokensInText(text) {
@@ -2015,7 +2090,7 @@ class FraimLocalMCPServer {
2015
2090
  /**
2016
2091
  * Initialize the LocalRegistryResolver for override resolution
2017
2092
  */
2018
- getRegistryResolver(requestSessionId) {
2093
+ getRegistryResolver(requestSessionId, remoteFiles) {
2019
2094
  const projectRoot = this.findProjectRoot();
2020
2095
  this.log(`🔍 getRegistryResolver: projectRoot = ${projectRoot}`);
2021
2096
  // Determine effective FRAIM dir using shadow semantics
@@ -2042,6 +2117,8 @@ class FraimLocalMCPServer {
2042
2117
  orgCacheRoot: (0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot,
2043
2118
  shouldFilter: (content) => this.isStub(content),
2044
2119
  remoteContentResolver: async (path) => {
2120
+ if (remoteFiles?.has(path))
2121
+ return remoteFiles.get(path);
2045
2122
  // Fetch parent content from remote for inheritance
2046
2123
  this.log(`🔄 Remote content resolver: fetching ${path}`);
2047
2124
  this.log(`🔄 Fetching raw file content: ${path}`);
@@ -2820,8 +2897,9 @@ class FraimLocalMCPServer {
2820
2897
  responseText = `${mentor.getCompactPhaseAuthority()}\n\n${responseText}`;
2821
2898
  responseText += `\n\n---\n\n**Job ID:** \`${jobId}\`\n\n**This job has phases.** Use \`seekMentoring\` with the jobId above to get phase-specific instructions.`;
2822
2899
  }
2823
- responseText = await this.appendJobContext(responseText, name, requestSessionId, requestId);
2824
- return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, responseText);
2900
+ const catalogRead = (0, domain_rule_assembler_1.withRuleDeliveryTimeout)(this.getRegistryResolver(requestSessionId).resolveFile('domains.json', { includeMetadata: false }));
2901
+ responseText = await this.appendJobContext(responseText, name, requestSessionId, requestId, catalogRead);
2902
+ return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, responseText, catalogRead);
2825
2903
  }
2826
2904
  }
2827
2905
  catch (error) {
@@ -3150,6 +3228,11 @@ class FraimLocalMCPServer {
3150
3228
  }
3151
3229
  const toolName = request.params?.name;
3152
3230
  let args = request.params?.arguments || {};
3231
+ if (toolName === 'seekMentoring' && args.status === 'starting' && args.requestInstructions === true) {
3232
+ // A context read must not publish a phase-start event to execution projections.
3233
+ this.mentoringResponseCache?.delete(request.id);
3234
+ return;
3235
+ }
3153
3236
  const requestSessionId = this.extractSessionIdFromRequest(request);
3154
3237
  if (toolName && requestSessionId) {
3155
3238
  const success = !response.error && !response.result?.isError;