fraim 2.0.210 → 2.0.212

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.
@@ -130,7 +130,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
130
130
  personaKey: 'ricardo',
131
131
  bundleId: 'persona-ricardo-core',
132
132
  catalogMetadata: buildCatalogMetadata('ricardo', ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management']),
133
- protectedJobs: ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management', 'interview-coordination', 'offer-and-close-management'],
133
+ protectedJobs: ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management', 'sourcing-funnel-recap', 'interview-coordination', 'offer-and-close-management'],
134
134
  protectedAliases: ['recruiting', 'recruiter', 'hiring'],
135
135
  defaultHireMode: 'job',
136
136
  lockCopy: 'Hire RECardo to unlock full-cycle recruiting work for this request.'
@@ -6,7 +6,7 @@
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;
9
+ exports.CATEGORY_TO_FILETYPE = exports.BRAIN_LEARNING_FILETYPE_TO_REGION = exports.LEARNING_PRIORITIES = void 0;
10
10
  exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
11
11
  exports.computeEffectiveScore = computeEffectiveScore;
12
12
  exports.buildLearningContextSection = buildLearningContextSection;
@@ -17,6 +17,7 @@ exports.resolveTeamContextFile = resolveTeamContextFile;
17
17
  exports.readOrgBrand = readOrgBrand;
18
18
  exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
19
19
  exports.countPreservedLearnings = countPreservedLearnings;
20
+ exports.collectBrainLearningDots = collectBrainLearningDots;
20
21
  exports.readPreservedLearnings = readPreservedLearnings;
21
22
  exports.applyLearningEntryChange = applyLearningEntryChange;
22
23
  exports.isTruthyFlag = isTruthyFlag;
@@ -385,13 +386,14 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
385
386
  coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
386
387
  validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
387
388
  };
388
- // Domain axis (issue #806, Option 1): `fraim_connect` (forJob=false) loads only
389
- // the interaction-preference files + the L0 nudge, since it runs with no job.
390
- // A job (forJob=true) loads every category globally, plus the current job's
391
- // domain files. Category order within a tier is preserved from prior behavior;
392
- // domain files are appended after the global files.
393
- const l2Cats = forJob ? ['mistake', 'pref', 'coach', 'validated'] : ['pref'];
394
- const l1Cats = forJob ? ['pref', 'coach', 'mistake', 'validated'] : ['pref'];
389
+ // Domain axis (issue #806): session startup stays broad enough for stable
390
+ // preferences plus L0, while job contexts load the full global set and may
391
+ // append the current job's domain files below the global files.
392
+ const sessionCats = ['pref'];
393
+ const jobL2Cats = ['mistake', 'pref', 'coach', 'validated'];
394
+ const jobL1Cats = ['pref', 'coach', 'mistake', 'validated'];
395
+ const l2Cats = forJob ? jobL2Cats : sessionCats;
396
+ const l1Cats = forJob ? jobL1Cats : sessionCats;
395
397
  const activeDomain = forJob && domain ? domain : null;
396
398
  const dormantOf = (meta, filePath) => meta.gated ? scanMistakePatternFile(filePath, threshold, meta.ft).dormant : 0;
397
399
  // Resolve one tier (L2 org or L1 personal) into ordered global + domain files.
@@ -475,14 +477,14 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
475
477
  const coachPresent = l1.coachPresent || l2.coachPresent;
476
478
  if (forJob) {
477
479
  if (hasL2 || hasL1) {
478
- section += 'Use the relevant patterns and preferences in this job.\n';
480
+ section += 'Read the listed synthesized learning files before continuing, then apply the relevant patterns and preferences in this job.\n';
479
481
  if (coachPresent) {
480
482
  section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
481
483
  }
482
484
  }
483
485
  }
484
- else {
485
- section += 'Use this synthesized learning context throughout the session.\n';
486
+ else if (hasL2 || hasL1) {
487
+ section += 'Read the listed synthesized learning files before continuing, then use this synthesized learning context throughout the session.\n';
486
488
  if (coachPresent) {
487
489
  section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
488
490
  }
@@ -842,6 +844,118 @@ function countPreservedLearnings(workspaceRoot, userId) {
842
844
  rawSignals,
843
845
  };
844
846
  }
847
+ // ── Issue #833: Brain learning-dot projection ──────────────────────────────
848
+ // Personal learning signals the FRAIM brain overlays on the SVG. Pending L0
849
+ // signals (raw coaching moments + unsynthesized retros) cluster in the
850
+ // temporal-memory region; processed L1/L2 learnings map to a fixed brain region
851
+ // per learning type (the approved Option A mapping). A read projection over the
852
+ // same files buildLearningContextSection injects — no new storage.
853
+ /** Fixed learning-type → brain-region mapping (issue #833, approved spec). */
854
+ exports.BRAIN_LEARNING_FILETYPE_TO_REGION = {
855
+ 'mistake-patterns': 'prefrontal-cortex', // rule following / constraint enforcement
856
+ 'preferences': 'right-parietal-experience', // experience / audience fit
857
+ 'validated-patterns': 'left-parietal-analysis', // engineering / evaluation judgment
858
+ 'manager-coaching': 'right-frontal-strategy', // strategy / framing (reverse mentoring)
859
+ };
860
+ const BRAIN_LEARNING_FILETYPE_LABEL = {
861
+ 'mistake-patterns': 'Mistake pattern',
862
+ 'preferences': 'Preference',
863
+ 'validated-patterns': 'Validated pattern',
864
+ 'manager-coaching': 'Manager coaching',
865
+ };
866
+ const PENDING_KIND_LABEL = {
867
+ 'coaching-moment': 'Coaching moment',
868
+ 'retrospective': 'Retrospective',
869
+ };
870
+ // Parse `## [P-…] Title` entries out of a learning file WITH their effective
871
+ // score + last-seen (unlike parseLearningEntries, which drops that bookkeeping
872
+ // for the Hub text display). Used only by the brain-dot projection.
873
+ function parseBrainLearningEntries(filePath, fileType) {
874
+ if (!(0, fs_1.existsSync)(filePath))
875
+ return [];
876
+ let content;
877
+ try {
878
+ content = (0, fs_1.readFileSync)(filePath, 'utf8');
879
+ }
880
+ catch {
881
+ return [];
882
+ }
883
+ const out = [];
884
+ const headingRe = learningEntryHeadingRegex();
885
+ const now = new Date();
886
+ let current = null;
887
+ const flush = () => {
888
+ if (!current)
889
+ return;
890
+ const rawScore = current.lastSeen
891
+ ? computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, fileType, now)
892
+ : null;
893
+ out.push({
894
+ title: current.title,
895
+ severity: current.severity,
896
+ score: rawScore === null ? null : Math.round(rawScore * 10) / 10,
897
+ lastSeen: current.lastSeen || null,
898
+ });
899
+ current = null;
900
+ };
901
+ for (const line of content.split(/\r?\n/)) {
902
+ const header = line.match(headingRe);
903
+ if (header) {
904
+ flush();
905
+ current = { severity: header[1], title: header[2].trim(), lastSeen: '', recurrences: 1 };
906
+ continue;
907
+ }
908
+ if (!current)
909
+ continue;
910
+ const lastSeenMatch = line.match(/^\*\*Last seen\*\*:\s*(.+)/);
911
+ if (lastSeenMatch) {
912
+ current.lastSeen = lastSeenMatch[1].trim();
913
+ continue;
914
+ }
915
+ const recurrenceMatch = line.match(/^\*\*Recurrences\*\*:\s*(\d+)/);
916
+ if (recurrenceMatch) {
917
+ current.recurrences = parseInt(recurrenceMatch[1], 10);
918
+ }
919
+ }
920
+ flush();
921
+ return out;
922
+ }
923
+ /**
924
+ * Build the brain learning-dot projection (issue #833) for one user: pending L0
925
+ * signals + processed L1 (personal) and L2 (org) learnings grouped by file type
926
+ * and mapped to a fixed brain region. Reuses the same root/user resolution as
927
+ * buildLearningContextSection so the dots line up with the auto-loaded context.
928
+ */
929
+ function collectBrainLearningDots(workspaceRoot, userId) {
930
+ const roots = getLearningRoots(workspaceRoot);
931
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
932
+ const pendingSignals = collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots).map((s) => ({
933
+ type: s.kind,
934
+ typeLabel: PENDING_KIND_LABEL[s.kind],
935
+ fileName: s.displayPath.split('/').pop() || s.displayPath,
936
+ displayPath: s.displayPath,
937
+ }));
938
+ const resolvePersonal = (fileName) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
939
+ const processed = [];
940
+ const fileTypes = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
941
+ for (const fileType of fileTypes) {
942
+ const region = exports.BRAIN_LEARNING_FILETYPE_TO_REGION[fileType];
943
+ const typeLabel = BRAIN_LEARNING_FILETYPE_LABEL[fileType];
944
+ // L1 personal
945
+ const personal = resolvePersonal(`${resolvedUserId}-${fileType}.md`);
946
+ const personalEntries = parseBrainLearningEntries(personal.path, fileType);
947
+ if (personalEntries.length) {
948
+ processed.push({ type: fileType, typeLabel, region, scope: 'personal', displayPath: personal.displayPath, entries: personalEntries });
949
+ }
950
+ // L2 org
951
+ const org = resolveOrgLearningFile(roots.repoLearningsBase, `org-${fileType}.md`);
952
+ const orgEntries = parseBrainLearningEntries(org.path, fileType);
953
+ if (orgEntries.length) {
954
+ processed.push({ type: fileType, typeLabel, region, scope: 'org', displayPath: org.displayPath, entries: orgEntries });
955
+ }
956
+ }
957
+ return { pending: { count: pendingSignals.length, signals: pendingSignals }, processed };
958
+ }
845
959
  exports.CATEGORY_TO_FILETYPE = {
846
960
  avoid: 'mistake-patterns',
847
961
  preference: 'preferences',
@@ -63,7 +63,7 @@ class TelemetryMiddleware {
63
63
  // session-active gate is exempted.
64
64
  // Issue #701/#744 - hosted AI Hub HTTP APIs are browser/gateway routes,
65
65
  // not MCP tool calls. They are still authenticated by API key/session.
66
- const exemptPaths = ['/health', '/admin', '/api/registry/sync', '/api/registry/github-workflows', '/api/registry/topology/personalized', '/api/registry/topology/usage-heatmap', '/api/org/', '/api/manager/', '/api/ai-hub/', '/api/payment/', '/api/sales/', '/api/signup', '/api/providers', '/api/analytics', '/analytics', '/api/auth/', '/api/account/'];
66
+ const exemptPaths = ['/health', '/admin', '/api/registry/sync', '/api/registry/github-workflows', '/api/registry/topology/personalized', '/api/registry/topology/usage-heatmap', '/api/registry/topology/learnings-dots', '/api/org/', '/api/manager/', '/api/ai-hub/', '/api/payment/', '/api/sales/', '/api/signup', '/api/providers', '/api/analytics', '/analytics', '/api/auth/', '/api/account/'];
67
67
  if (exemptPaths.some(p => req.path.startsWith(p))) {
68
68
  return next();
69
69
  }
@@ -4,7 +4,7 @@ exports.EmailService = void 0;
4
4
  const resend_1 = require("resend");
5
5
  class EmailService {
6
6
  constructor() {
7
- const apiKey = process.env.RESEND_API_KEY;
7
+ const apiKey = process.env.RESEND_API_KEY || (process.env.FRAIM_SUPPRESS_EMAILS === 'true' ? 'test-resend-key' : undefined);
8
8
  if (!apiKey) {
9
9
  throw new Error('RESEND_API_KEY environment variable is required');
10
10
  }
@@ -13,16 +13,20 @@ class EmailService {
13
13
  this.fromEmail = process.env.RESEND_FROM_EMAIL || 'FRAIM <onboarding@resend.dev>';
14
14
  this.baseUrl = (process.env.BASE_URL || 'https://fraimworks.ai').replace(/\/$/, '');
15
15
  }
16
+ static shouldSuppressEmail() {
17
+ return process.env.FRAIM_SUPPRESS_EMAILS === 'true'
18
+ || (process.env.NODE_ENV === 'test' && process.env.ALLOW_REAL_EMAILS_IN_TEST !== 'true');
19
+ }
16
20
  /** Wrapper around resend.emails.send — no-ops in test mode to avoid burning real API quota. */
17
21
  async sendEmail(payload) {
18
- if (process.env.NODE_ENV === 'test') {
22
+ if (EmailService.shouldSuppressEmail()) {
19
23
  console.log(`[TEST] Email suppressed — would send to ${Array.isArray(payload.to) ? payload.to.join(', ') : payload.to}: "${payload.subject}"`);
20
24
  return { data: { id: 'test-suppressed' }, error: null, headers: null };
21
25
  }
22
26
  return this.sendWithResend(payload);
23
27
  }
24
28
  async sendWithResend(payload) {
25
- if (process.env.NODE_ENV === 'test' && process.env.ALLOW_REAL_EMAILS_IN_TEST !== 'true') {
29
+ if (EmailService.shouldSuppressEmail()) {
26
30
  console.log(`[TEST] Email suppressed - would send to ${Array.isArray(payload.to) ? payload.to.join(', ') : payload.to}: "${payload.subject}"`);
27
31
  return { data: { id: 'test-suppressed' }, error: null, headers: null };
28
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.210",
3
+ "version": "2.0.212",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {