fraim 2.0.161 → 2.0.163

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.
@@ -6,8 +6,18 @@
6
6
  * workspace root on the user's machine.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.CATEGORY_TO_FILETYPE = exports.LEARNING_PRIORITIES = void 0;
10
+ exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
9
11
  exports.computeEffectiveScore = computeEffectiveScore;
10
12
  exports.buildLearningContextSection = buildLearningContextSection;
13
+ exports.buildTeamContextSection = buildTeamContextSection;
14
+ exports.resolveTeamContextFiles = resolveTeamContextFiles;
15
+ exports.isTeamContextKey = isTeamContextKey;
16
+ exports.resolveTeamContextFile = resolveTeamContextFile;
17
+ exports.countPreservedLearnings = countPreservedLearnings;
18
+ exports.readPreservedLearnings = readPreservedLearnings;
19
+ exports.applyLearningEntryChange = applyLearningEntryChange;
20
+ exports.isTruthyFlag = isTruthyFlag;
11
21
  const fs_1 = require("fs");
12
22
  const path_1 = require("path");
13
23
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -17,6 +27,22 @@ const AGING_HORIZON_DAYS = 7;
17
27
  const MAX_ENTRIES_SCANNED = 200;
18
28
  const BACKLOG_MIN = 5;
19
29
  const OLDEST_AGE_DAYS_TRIGGER = 3;
30
+ // ── Single source of truth for the learning-entry format contract (#533) ──────
31
+ // The `## [P-…] <title>` entry format is a tight contract shared by three sides:
32
+ // 1. EMIT — the synthesis jobs (sleep-on-learnings, organizational-learning-
33
+ // synthesis) instruct the agent to write entries in this shape.
34
+ // 2. SCORE — the decay scorer below reads severity + Last seen + Recurrences to
35
+ // decide which learnings stay active and get injected into agent context.
36
+ // 3. READ — the counters and the Hub (readPreservedLearnings) display them.
37
+ // These previously drifted (each accepted a different priority set / heading
38
+ // level), which silently dropped P-CRITICAL learnings. Everything now derives
39
+ // from LEARNING_PRIORITIES, and a build-time contract test
40
+ // (tests/isolated/test-learning-format-contract.ts) asserts all three sides agree.
41
+ exports.LEARNING_PRIORITIES = ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'];
42
+ /** Canonical entry-heading matcher: `##`..`######` + `[P-X]` + optional title. */
43
+ function learningEntryHeadingRegex(flags = '') {
44
+ return new RegExp(`^#{2,}\\s+\\[(${exports.LEARNING_PRIORITIES.join('|')})\\]\\s*(.*)$`, flags);
45
+ }
20
46
  function getLearningRoots(workspaceRoot) {
21
47
  return {
22
48
  globalPersonalBase: (0, project_fraim_paths_1.getConfiguredPortableLearningsDir)(workspaceRoot),
@@ -152,7 +178,7 @@ function getScoreThreshold(workspaceRoot) {
152
178
  * calculations). Defaults to the current wall clock.
153
179
  */
154
180
  function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date()) {
155
- const baseScore = severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
181
+ const baseScore = severity === 'P-CRITICAL' ? 10 : severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
156
182
  // Mistake patterns decay faster (90d) — they're tied to environments that change.
157
183
  // Preferences, manager-coaching, and validated-patterns express durable judgment (180d half-life).
158
184
  const halfLife = fileType === 'mistake-patterns' ? 90 : 180;
@@ -210,7 +236,7 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
210
236
  for (const line of lines) {
211
237
  if (scanned >= MAX_ENTRIES_SCANNED)
212
238
  break;
213
- const headerMatch = line.match(/^## \[(P-HIGH|P-MED|P-LOW)\]/);
239
+ const headerMatch = line.match(learningEntryHeadingRegex());
214
240
  if (headerMatch) {
215
241
  flush();
216
242
  inEntry = true;
@@ -438,6 +464,416 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
438
464
  }
439
465
  return section;
440
466
  }
467
+ /**
468
+ * Resolve an organization/manager-scope context file. These are user-level and
469
+ * portable across repos, but a repo-local copy (under `fraim/…`) wins when
470
+ * present — mirroring how personal learnings layer (repo-local shadows
471
+ * user-level) in `resolvePersonalLearningFile`.
472
+ */
473
+ function resolveOrgContextFile(workspaceRoot, relativePath) {
474
+ try {
475
+ const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
476
+ if ((0, fs_1.existsSync)(repoPath)) {
477
+ return {
478
+ present: true,
479
+ displayPath: (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`)
480
+ };
481
+ }
482
+ const userPath = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', relativePath);
483
+ if ((0, fs_1.existsSync)(userPath)) {
484
+ return {
485
+ present: true,
486
+ displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`personalized-employee/${relativePath}`)
487
+ };
488
+ }
489
+ }
490
+ catch {
491
+ // Fall through to absent.
492
+ }
493
+ return { present: false, displayPath: '' };
494
+ }
495
+ /**
496
+ * Resolve a project-scope context file. These are repo-local only — they
497
+ * describe a single engagement and never layer up to the user level.
498
+ */
499
+ function resolveProjectContextFile(workspaceRoot, relativePath) {
500
+ try {
501
+ const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
502
+ if ((0, fs_1.existsSync)(repoPath)) {
503
+ return {
504
+ present: true,
505
+ displayPath: (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`)
506
+ };
507
+ }
508
+ }
509
+ catch {
510
+ // Fall through to absent.
511
+ }
512
+ return { present: false, displayPath: '' };
513
+ }
514
+ /**
515
+ * Builds the team-context section pointing the agent at the org/manager/project
516
+ * context files that exist on disk (issue #512, three-layer Context
517
+ * Architecture). Mirrors `buildLearningContextSection`: pure, side-effect-free,
518
+ * lists only present files, and returns '' when none exist.
519
+ */
520
+ function buildTeamContextSection(workspaceRoot, forJob) {
521
+ // Organization layer (user-level, portable; repo-local override wins).
522
+ const orgContext = resolveOrgContextFile(workspaceRoot, 'context/org_context.md');
523
+ const managerContext = resolveOrgContextFile(workspaceRoot, 'context/manager_context.md');
524
+ const orgRules = resolveOrgContextFile(workspaceRoot, 'rules/org_rules.md');
525
+ const managerRules = resolveOrgContextFile(workspaceRoot, 'rules/manager_rules.md');
526
+ // Project layer (repo-local only).
527
+ const projectContext = resolveProjectContextFile(workspaceRoot, 'context/project_context.md');
528
+ const projectBrief = resolveProjectContextFile(workspaceRoot, 'context/project_brief.md');
529
+ const projectRules = resolveProjectContextFile(workspaceRoot, 'rules/project_rules.md');
530
+ const projectQa = resolveProjectContextFile(workspaceRoot, 'context/project_qa.md');
531
+ const hasOrg = orgContext.present || managerContext.present || orgRules.present || managerRules.present;
532
+ const hasProject = projectContext.present || projectBrief.present || projectRules.present || projectQa.present;
533
+ if (!hasOrg && !hasProject)
534
+ return '';
535
+ let section = forJob
536
+ ? '\n\n## Team Context for This Job\n\n'
537
+ : '\n\n## Team Context\n\n';
538
+ if (hasOrg) {
539
+ section += '### Organization\n';
540
+ if (orgContext.present)
541
+ section += `\`${orgContext.displayPath}\`\n`;
542
+ if (managerContext.present)
543
+ section += `\`${managerContext.displayPath}\`\n`;
544
+ if (orgRules.present)
545
+ section += `\`${orgRules.displayPath}\`\n`;
546
+ if (managerRules.present)
547
+ section += `\`${managerRules.displayPath}\`\n`;
548
+ section += '\n';
549
+ }
550
+ if (hasProject) {
551
+ section += '### Project\n';
552
+ if (projectContext.present)
553
+ section += `\`${projectContext.displayPath}\`\n`;
554
+ if (projectBrief.present)
555
+ section += `\`${projectBrief.displayPath}\`\n`;
556
+ if (projectRules.present)
557
+ section += `\`${projectRules.displayPath}\`\n`;
558
+ if (projectQa.present)
559
+ section += `\`${projectQa.displayPath}\`\n`;
560
+ section += '\n';
561
+ }
562
+ section += 'Read the relevant context before doing work.\n';
563
+ return section;
564
+ }
565
+ /**
566
+ * Resolve presence + display paths for every three-layer Team Context file
567
+ * (org/manager/project). Reuses resolveOrgContextFile / resolveProjectContextFile
568
+ * so layering (repo-local shadows user-level for org/manager; project is
569
+ * repo-local only) matches buildTeamContextSection exactly.
570
+ */
571
+ function resolveTeamContextFiles(workspaceRoot) {
572
+ return {
573
+ orgContext: resolveOrgContextFile(workspaceRoot, 'context/org_context.md'),
574
+ managerContext: resolveOrgContextFile(workspaceRoot, 'context/manager_context.md'),
575
+ orgRules: resolveOrgContextFile(workspaceRoot, 'rules/org_rules.md'),
576
+ managerRules: resolveOrgContextFile(workspaceRoot, 'rules/manager_rules.md'),
577
+ projectContext: resolveProjectContextFile(workspaceRoot, 'context/project_context.md'),
578
+ projectBrief: resolveProjectContextFile(workspaceRoot, 'context/project_brief.md'),
579
+ projectQa: resolveProjectContextFile(workspaceRoot, 'context/project_qa.md'),
580
+ projectRules: resolveProjectContextFile(workspaceRoot, 'rules/project_rules.md')
581
+ };
582
+ }
583
+ /** Map a key to its (relativePath, scope). Single source of truth. */
584
+ const TEAM_CONTEXT_FILE_MAP = {
585
+ org: { relativePath: 'context/org_context.md', scope: 'org' },
586
+ manager: { relativePath: 'context/manager_context.md', scope: 'org' },
587
+ orgRules: { relativePath: 'rules/org_rules.md', scope: 'org' },
588
+ managerRules: { relativePath: 'rules/manager_rules.md', scope: 'org' },
589
+ projectContext: { relativePath: 'context/project_context.md', scope: 'project' },
590
+ projectBrief: { relativePath: 'context/project_brief.md', scope: 'project' },
591
+ projectRules: { relativePath: 'rules/project_rules.md', scope: 'project' },
592
+ projectQa: { relativePath: 'context/project_qa.md', scope: 'project' }
593
+ };
594
+ function isTeamContextKey(value) {
595
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(TEAM_CONTEXT_FILE_MAP, value);
596
+ }
597
+ /**
598
+ * Resolve the read + write locations for one editable context file.
599
+ *
600
+ * - Org scope (org/manager/orgRules/managerRules): a repo-local copy under `fraim/…` wins for
601
+ * both read and write when present (so the edit updates the file that actually
602
+ * shadows user-level for this repo); otherwise read AND write target the
603
+ * user-level `~/.fraim/…` path (where org onboarding writes).
604
+ * - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
605
+ */
606
+ function resolveTeamContextFile(workspaceRoot, key) {
607
+ const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
608
+ const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
609
+ const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
610
+ if (scope === 'project') {
611
+ // Repo-local only — read and write are the same path.
612
+ return {
613
+ present: (0, fs_1.existsSync)(repoPath),
614
+ readPath: (0, fs_1.existsSync)(repoPath) ? repoPath : '',
615
+ writePath: repoPath,
616
+ displayPath: repoDisplay,
617
+ scope
618
+ };
619
+ }
620
+ // Org scope: repo-local override wins when present.
621
+ if ((0, fs_1.existsSync)(repoPath)) {
622
+ return {
623
+ present: true,
624
+ readPath: repoPath,
625
+ writePath: repoPath,
626
+ displayPath: repoDisplay,
627
+ scope
628
+ };
629
+ }
630
+ const userPath = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', relativePath);
631
+ const userDisplay = (0, project_fraim_paths_1.getUserFraimDisplayPath)(`personalized-employee/${relativePath}`);
632
+ return {
633
+ present: (0, fs_1.existsSync)(userPath),
634
+ readPath: (0, fs_1.existsSync)(userPath) ? userPath : '',
635
+ // No repo-local override exists → write to the portable user-level path,
636
+ // matching where org onboarding persists these files.
637
+ writePath: userPath,
638
+ displayPath: userDisplay,
639
+ scope
640
+ };
641
+ }
642
+ /**
643
+ * Count the number of `## [P-...]`-style entries inside a preserved learning
644
+ * file. Returns 0 when the file is absent or unreadable. Counts ALL entries
645
+ * (not score-gated) — the Brain summary reports preserved learnings, not the
646
+ * subset above the auto-load threshold.
647
+ */
648
+ function countLearningEntries(filePath) {
649
+ if (!(0, fs_1.existsSync)(filePath))
650
+ return 0;
651
+ let content;
652
+ try {
653
+ content = (0, fs_1.readFileSync)(filePath, 'utf8');
654
+ }
655
+ catch {
656
+ return 0;
657
+ }
658
+ let count = 0;
659
+ const headingRe = learningEntryHeadingRegex();
660
+ for (const line of content.split(/\r?\n/)) {
661
+ if (headingRe.test(line))
662
+ count++;
663
+ }
664
+ return count;
665
+ }
666
+ /**
667
+ * Count preserved learnings by scope for the Brain summary (R14). Organization
668
+ * = L2 org-* files; manager = the personal manager-coaching file (reverse
669
+ * mentoring); project = personal mistake/preferences/validated patterns. Raw =
670
+ * un-dismissed L0 signals still awaiting synthesis. Reuses the same root/file
671
+ * resolution as buildLearningContextSection so the counts line up with what the
672
+ * agent actually auto-loads.
673
+ */
674
+ function countPreservedLearnings(workspaceRoot, userId) {
675
+ const roots = getLearningRoots(workspaceRoot);
676
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
677
+ // L2 organization-scope preserved files (repo-local org-* files).
678
+ const organization = countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-mistake-patterns.md')) +
679
+ countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-preferences.md')) +
680
+ countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-manager-coaching.md')) +
681
+ countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-validated-patterns.md'));
682
+ const resolve = (fileName) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
683
+ // L1 manager-facing reverse-mentoring file.
684
+ const manager = countLearningEntries(resolve(`${resolvedUserId}-manager-coaching.md`).path);
685
+ // L1 personal work patterns (project scope).
686
+ const project = countLearningEntries(resolve(`${resolvedUserId}-mistake-patterns.md`).path) +
687
+ countLearningEntries(resolve(`${resolvedUserId}-preferences.md`).path) +
688
+ countLearningEntries(resolve(`${resolvedUserId}-validated-patterns.md`).path);
689
+ // L0 raw signals still awaiting synthesis (not dismissed).
690
+ let rawSignals = 0;
691
+ const rawDir = (0, path_1.join)(roots.repoLearningsBase, 'raw');
692
+ if ((0, fs_1.existsSync)(rawDir)) {
693
+ try {
694
+ for (const fileName of (0, fs_1.readdirSync)(rawDir)) {
695
+ if (!fileName.endsWith('.md'))
696
+ continue;
697
+ const fm = readFrontmatter((0, fs_1.readFileSync)((0, path_1.join)(rawDir, fileName), 'utf8'));
698
+ if (isTruthyFlag(fm.dismissed))
699
+ continue;
700
+ rawSignals++;
701
+ }
702
+ }
703
+ catch {
704
+ // ignore unreadable raw dir
705
+ }
706
+ }
707
+ return { organization, manager, project, rawSignals };
708
+ }
709
+ exports.CATEGORY_TO_FILETYPE = {
710
+ avoid: 'mistake-patterns',
711
+ preference: 'preferences',
712
+ repeat: 'validated-patterns',
713
+ coaching: 'manager-coaching',
714
+ };
715
+ const FILETYPE_TO_CATEGORY = {
716
+ 'mistake-patterns': 'avoid',
717
+ 'preferences': 'preference',
718
+ 'validated-patterns': 'repeat',
719
+ 'manager-coaching': 'coaching',
720
+ };
721
+ // Parse the `## [P-…] Title` entries (and their body prose) out of one learning
722
+ // file. Score/Last seen/Recurrences metadata lines are dropped — the Hub shows
723
+ // the human-readable learning, not the bookkeeping.
724
+ function parseLearningEntries(filePath, displayPath, category, level) {
725
+ if (!(0, fs_1.existsSync)(filePath))
726
+ return [];
727
+ let content;
728
+ try {
729
+ content = (0, fs_1.readFileSync)(filePath, 'utf8');
730
+ }
731
+ catch {
732
+ return [];
733
+ }
734
+ const out = [];
735
+ let current = null;
736
+ let bodyLines = [];
737
+ const flush = () => {
738
+ if (current) {
739
+ current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
740
+ out.push(current);
741
+ }
742
+ current = null;
743
+ bodyLines = [];
744
+ };
745
+ const headingRe = learningEntryHeadingRegex();
746
+ for (const line of content.split(/\r?\n/)) {
747
+ const header = line.match(headingRe);
748
+ if (header) {
749
+ flush();
750
+ current = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level };
751
+ continue;
752
+ }
753
+ if (!current)
754
+ continue;
755
+ if (line.trim() === '---') {
756
+ flush();
757
+ continue;
758
+ }
759
+ // Drop the bookkeeping lines — the Hub shows the human-readable learning,
760
+ // not the scoring metadata.
761
+ const t = line.trim();
762
+ if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t))
763
+ continue;
764
+ if (/^(First|Last) synthesized:/i.test(t))
765
+ continue;
766
+ bodyLines.push(line);
767
+ }
768
+ flush();
769
+ return out;
770
+ }
771
+ function levelDir(roots, level) {
772
+ if (level === 'machine')
773
+ return { dir: roots.globalPersonalBase, displayBase: roots.globalPersonalDisplayBase.replace(/\/$/, '') };
774
+ return { dir: roots.repoLearningsBase, displayBase: REPO_LEARNINGS_REL };
775
+ }
776
+ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
777
+ const roots = getLearningRoots(workspaceRoot);
778
+ const out = [];
779
+ if (scope === 'org') {
780
+ for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
781
+ const f = `org-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
782
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'org'));
783
+ }
784
+ return out;
785
+ }
786
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
787
+ const { dir, displayBase } = levelDir(roots, level);
788
+ const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
789
+ for (const cat of cats) {
790
+ const f = `${resolvedUserId}-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
791
+ out.push(...parseLearningEntries((0, path_1.join)(dir, f), `${displayBase}/${f}`, cat, level));
792
+ }
793
+ return out;
794
+ }
795
+ function resolveLearningFilePath(workspaceRoot, userId, ref) {
796
+ const roots = getLearningRoots(workspaceRoot);
797
+ const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
798
+ if (ref.scope === 'org')
799
+ return (0, path_1.join)(roots.repoLearningsBase, `org-${fileType}.md`);
800
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
801
+ const base = ref.level === 'project' ? roots.repoLearningsBase : roots.globalPersonalBase;
802
+ return (0, path_1.join)(base, `${resolvedUserId}-${fileType}.md`);
803
+ }
804
+ function titleCaseFromFileType(fileType) {
805
+ return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
806
+ }
807
+ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
808
+ const filePath = resolveLearningFilePath(workspaceRoot, userId, ref);
809
+ const existing = (0, fs_1.existsSync)(filePath) ? (0, fs_1.readFileSync)(filePath, 'utf8') : '';
810
+ const nl = existing.includes('\r\n') ? '\r\n' : '\n';
811
+ const lines = existing.length ? existing.split(/\r?\n/) : [];
812
+ const headingRe = learningEntryHeadingRegex();
813
+ const starts = [];
814
+ lines.forEach((l, i) => { if (headingRe.test(l))
815
+ starts.push(i); });
816
+ const write = (text) => {
817
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
818
+ (0, fs_1.writeFileSync)(filePath, text.replace(/\s*$/, '') + nl, 'utf8');
819
+ };
820
+ if (action === 'add') {
821
+ const sev = (payload.severity || 'P-MED');
822
+ const title = (payload.title || '').trim() || 'Untitled learning';
823
+ const body = String(payload.body || '').trim();
824
+ const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
825
+ const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
826
+ const preamble = existing.trim()
827
+ ? existing.replace(/\s*$/, '')
828
+ : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
829
+ write(preamble + nl + nl + block);
830
+ return { ok: true, path: filePath };
831
+ }
832
+ const targetIdx = starts.findIndex((s) => {
833
+ const m = lines[s].match(headingRe);
834
+ return m && m[2].trim() === (payload.originalTitle || '').trim();
835
+ });
836
+ if (targetIdx === -1)
837
+ throw new Error(`Learning entry not found: "${payload.originalTitle}"`);
838
+ const start = starts[targetIdx];
839
+ const end = targetIdx + 1 < starts.length ? starts[targetIdx + 1] : lines.length;
840
+ if (action === 'delete') {
841
+ const next = [...lines.slice(0, start), ...lines.slice(end)];
842
+ write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
843
+ return { ok: true, path: filePath };
844
+ }
845
+ // edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
846
+ // replace only the heading (priority + title) and the prose body.
847
+ const headerMatch = lines[start].match(headingRe);
848
+ const sev = (payload.severity || headerMatch[1]);
849
+ const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
850
+ const meta = [];
851
+ for (const l of lines.slice(start + 1, end)) {
852
+ const t = l.trim();
853
+ if (t === '---')
854
+ continue;
855
+ if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
856
+ meta.push(l);
857
+ }
858
+ const body = String(payload.body !== undefined ? payload.body : '').trim();
859
+ const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
860
+ const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
861
+ write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
862
+ return { ok: true, path: filePath };
863
+ }
864
+ /**
865
+ * Interpret a frontmatter flag value (string) as a boolean. Mirrors how the
866
+ * synthesis jobs treat `dismissed:`/`blessed:` — only an explicit truthy value
867
+ * counts as set.
868
+ */
869
+ function isTruthyFlag(value) {
870
+ if (value === undefined)
871
+ return false;
872
+ const normalized = value.trim().toLowerCase();
873
+ if (!normalized)
874
+ return false;
875
+ return normalized === 'true' || normalized === 'yes' || normalized === '1';
876
+ }
441
877
  function renderBacklogDetail(oldestAgeDays, agingRisk) {
442
878
  if (oldestAgeDays <= 0 && agingRisk <= 0)
443
879
  return '';
@@ -472,6 +472,33 @@ class FraimLocalMCPServer {
472
472
  }
473
473
  return '';
474
474
  }
475
+ // #533: persist the authenticated user email into ~/.fraim/preferences.json so
476
+ // the AI Hub (a separate process) can resolve personal learnings + show the real
477
+ // profile. Merge-and-write; never throw out of the connect path.
478
+ persistUserEmail(userEmail) {
479
+ try {
480
+ const fs = require('fs');
481
+ const path = require('path');
482
+ const dir = (0, project_fraim_paths_1.getUserFraimDirPath)();
483
+ const prefsPath = path.join(dir, 'preferences.json');
484
+ let prefs = {};
485
+ try {
486
+ prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
487
+ }
488
+ catch {
489
+ prefs = {};
490
+ }
491
+ if (prefs.userEmail === userEmail)
492
+ return; // already current
493
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
494
+ prefs.userEmail = userEmail;
495
+ (0, fs_1.writeFileSync)(prefsPath, JSON.stringify(prefs, null, 2), 'utf8');
496
+ this.log(`Persisted userEmail to ${prefsPath}`);
497
+ }
498
+ catch (e) {
499
+ this.log(`Could not persist userEmail: ${e instanceof Error ? e.message : String(e)}`);
500
+ }
501
+ }
475
502
  log(message) {
476
503
  // Log to stderr (stdout is reserved for MCP protocol)
477
504
  const key = this.apiKey || 'MISSING_API_KEY';
@@ -1199,6 +1226,11 @@ class FraimLocalMCPServer {
1199
1226
  const userEmail = emailMatch[1].trim();
1200
1227
  this.ensureEngine().setUserEmail(userEmail);
1201
1228
  this.log(`[req:${requestId}] Captured user email for template substitution: ${userEmail}`);
1229
+ // #533: persist the authenticated email locally so the AI Hub (a separate
1230
+ // server that never sees fraim_connect) can resolve personal/manager
1231
+ // learnings and show the real identity in its profile card. Merge into
1232
+ // the existing preferences.json rather than overwriting it.
1233
+ this.persistUserEmail(userEmail);
1202
1234
  // Inject learning context from the local workspace (RFC 177: files live on disk, not server).
1203
1235
  const workspaceRoot = this.findProjectRoot() || process.cwd();
1204
1236
  const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, false);
@@ -1206,6 +1238,11 @@ class FraimLocalMCPServer {
1206
1238
  responseText += learningSection;
1207
1239
  this.log(`[req:${requestId}] Injected learning context for ${userEmail} from ${workspaceRoot}`);
1208
1240
  }
1241
+ const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, false);
1242
+ if (teamContextSection) {
1243
+ responseText += teamContextSection;
1244
+ this.log(`[req:${requestId}] Injected team context from ${workspaceRoot}`);
1245
+ }
1209
1246
  }
1210
1247
  if (this.latestConnectSyncWarning) {
1211
1248
  responseText += `\n\n## Local Catalog\n${this.latestConnectSyncWarning}`;
@@ -1220,9 +1257,10 @@ class FraimLocalMCPServer {
1220
1257
  if (typeof text === 'string' && userEmail) {
1221
1258
  const workspaceRoot = this.findProjectRoot() || process.cwd();
1222
1259
  const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
1223
- if (learningSection) {
1224
- finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection;
1225
- this.log(`[req:${requestId}] Injected job-focus learning context for ${userEmail}`);
1260
+ const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
1261
+ if (learningSection || teamContextSection) {
1262
+ finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection + teamContextSection;
1263
+ this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail}`);
1226
1264
  }
1227
1265
  }
1228
1266
  }
@@ -1942,9 +1980,10 @@ class FraimLocalMCPServer {
1942
1980
  if (userEmail) {
1943
1981
  const workspaceRoot = this.findProjectRoot() || process.cwd();
1944
1982
  const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
1945
- if (learningSection) {
1946
- responseText += `\n\n---` + learningSection;
1947
- this.log(`✅ Injected job-focus learning context for ${userEmail} (local override path)`);
1983
+ const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
1984
+ if (learningSection || teamContextSection) {
1985
+ responseText += `\n\n---` + learningSection + teamContextSection;
1986
+ this.log(`✅ Injected job-focus learning/team context for ${userEmail} (local override path)`);
1948
1987
  }
1949
1988
  }
1950
1989
  return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, responseText);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.161",
3
+ "version": "2.0.163",
4
4
  "description": "FRAIM CLI - Framework for Rigor-based AI Management (alias for fraim-framework)",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -9,12 +9,13 @@
9
9
  "scripts": {
10
10
  "dev": "tsx --watch src/fraim-mcp-server.ts > server.log 2>&1",
11
11
  "dev:prod": "npm run build && node dist/src/fraim-mcp-server.js > server.log 2>&1",
12
- "build": "tsx scripts/build-fraim-config-schema-template.ts && npm run typecheck:scripts && tsc && npm run build:stubs && npm run build:fraim-brain && node scripts/copy-registry.js && npm run validate:registry && npm run validate:fraim-pro-assets && npm run validate:employee-catalog && tsx scripts/validate-purity.ts",
12
+ "build": "tsx scripts/build-fraim-config-schema-template.ts && npm run typecheck:scripts && tsc && npm run build:stubs && npm run build:fraim-brain && node scripts/copy-registry.js && npm run validate:registry && npm run validate:fraim-pro-assets && npm run validate:employee-catalog && npm run validate:learning-format-contract && tsx scripts/validate-purity.ts",
13
+ "validate:learning-format-contract": "tsx scripts/validate-learning-format-contract.ts",
13
14
  "build:stubs": "tsx scripts/build-stub-registry.ts",
14
15
  "build:fraim-brain": "node scripts/generate-fraim-brain.js",
15
- "test-all": "npm run test && npm run test:isolated && npm run test:ui",
16
+ "test-all": "npm run test && npm run test:isolated tests/isolated/test-*.ts && npm run test:ui",
16
17
  "test": "node scripts/test-with-server.js",
17
- "test:isolated": "npx tsx --test --test-concurrency=1 --test-reporter=spec tests/isolated/test-*.ts",
18
+ "test:isolated": "node scripts/test-isolated.js",
18
19
  "test:smoke": "node scripts/test-with-server.js --tags=smoke",
19
20
  "test:coverage": "node scripts/test-with-server.js --tags=smoke --coverage",
20
21
  "test:stripe": "node scripts/test-with-server.js tests/test-stripe-payment-complete.ts",