fraim 2.0.243 → 2.0.245

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.
@@ -53,6 +53,8 @@ class LocalRegistryResolver {
53
53
  this.remoteContentResolver = options.remoteContentResolver;
54
54
  this.parser = new inheritance_parser_1.InheritanceParser(options.maxDepth);
55
55
  this.shouldFilter = options.shouldFilter;
56
+ // Issue #1002: default the manager's writable home to ~/.fraim/personalized-employee/
57
+ this.managerLocalRoot = options.managerLocalRoot ?? (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee');
56
58
  // Issue #869: default manager cache root to ~/.fraim/manager/
57
59
  this.managerCacheRoot = options.managerCacheRoot ?? (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'manager');
58
60
  // Issue #869 Phase 2: default org cache root to ~/.fraim/org/
@@ -87,15 +89,27 @@ class LocalRegistryResolver {
87
89
  const literal = `${dir}/${baseName}`;
88
90
  if (this.hasLocalOverride(literal))
89
91
  return literal;
90
- // Deep search
91
- const fullBaseDir = this.getFraimPath('personalized-employee', dir);
92
- const found = this.searchFileRecursively(fullBaseDir, baseName);
93
- if (found) {
94
- // Convert absolute back to relative
92
+ // Deep search each layer that can hold a categorised capability, in the
93
+ // same precedence order resolveFile uses. Issue #1002: without the manager
94
+ // and org roots here, a manager-level job resolved to the uncategorised
95
+ // fallback `jobs/<name>.md`, which never matches the real overlay path
96
+ // `jobs/<category>/<name>.md`, so the job was reported not found even
97
+ // though the file existed.
98
+ const searchRoots = [
99
+ this.getFraimPath('personalized-employee', dir),
100
+ (0, path_1.join)(this.managerLocalRoot, dir),
101
+ (0, path_1.join)(this.managerCacheRoot, dir),
102
+ (0, path_1.join)(this.orgCacheRoot, dir),
103
+ ];
104
+ for (const root of searchRoots) {
105
+ const found = this.searchFileRecursively(root, baseName);
106
+ if (!found)
107
+ continue;
108
+ // Convert absolute back to a registry-relative path.
95
109
  const rel = found.replace(/\\/g, '/');
96
110
  const dirMarker = `/${dir}/`;
97
111
  if (rel.includes(dirMarker)) {
98
- return rel.substring(rel.indexOf(dirMarker) + 1);
112
+ return rel.substring(rel.lastIndexOf(dirMarker) + 1);
99
113
  }
100
114
  }
101
115
  }
@@ -229,6 +243,48 @@ class LocalRegistryResolver {
229
243
  return null;
230
244
  }
231
245
  }
246
+ /**
247
+ * Issue #1002: read a capability file from the manager's WRITABLE home
248
+ * (~/.fraim/personalized-employee/<type>/…). Returns null if not present or
249
+ * filtered.
250
+ *
251
+ * Precedence: project > MANAGER LOCAL > manager synced cache > org cache >
252
+ * synced baseline > registry > remote. Local beats the cache so a capability
253
+ * the manager just authored resolves immediately, before any publish or sync,
254
+ * and resolves at all under the `single-machine` backend where the cache is
255
+ * never populated (spec #1002 D-A).
256
+ *
257
+ * Security: same canonical-path containment guard as the other overlay
258
+ * readers, so a crafted path can never escape the home directory.
259
+ */
260
+ readManagerLocalOverlayFile(path) {
261
+ return this.readContainedOverlayFile(this.managerLocalRoot, path);
262
+ }
263
+ /**
264
+ * Shared overlay read with a canonical-path containment guard. Extracted so
265
+ * every overlay root uses one implementation of the guard rather than each
266
+ * repeating it (the manager cache and org cache readers below delegate here).
267
+ */
268
+ readContainedOverlayFile(root, path) {
269
+ const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
270
+ const destination = (0, path_1.join)(root, ...normalized.split('/'));
271
+ const resolved = (0, path_1.resolve)(destination);
272
+ const rootResolved = (0, path_1.resolve)(root);
273
+ if (!resolved.startsWith(rootResolved + path_1.sep) && resolved !== rootResolved) {
274
+ return null;
275
+ }
276
+ if (!(0, fs_1.existsSync)(destination))
277
+ return null;
278
+ try {
279
+ const content = (0, fs_1.readFileSync)(destination, 'utf-8');
280
+ if (this.shouldFilter && this.shouldFilter(content))
281
+ return null;
282
+ return content;
283
+ }
284
+ catch {
285
+ return null;
286
+ }
287
+ }
232
288
  /**
233
289
  * Issue #869: read a capability file from the manager overlay cache
234
290
  * (~/.fraim/manager/<type>/…). Returns null if not present or filtered.
@@ -451,7 +507,20 @@ class LocalRegistryResolver {
451
507
  const stripMcpHeader = options.stripMcpHeader ?? false;
452
508
  // Check for local override
453
509
  if (!this.hasLocalOverride(path)) {
454
- // Issue #869: manager overlay (precedence 2 — between project override and synced baseline)
510
+ // Issue #1002: the manager's writable home (precedence 2 — above the
511
+ // synced manager cache, so a just-authored capability resolves before any
512
+ // publish or sync and resolves at all under `single-machine`).
513
+ const managerLocalContent = this.readManagerLocalOverlayFile(path);
514
+ if (managerLocalContent !== null) {
515
+ return {
516
+ content: managerLocalContent,
517
+ source: 'local',
518
+ personalized: true,
519
+ inherited: false,
520
+ scope: 'manager'
521
+ };
522
+ }
523
+ // Issue #869: manager overlay (precedence 3 — the synced cross-machine copy)
455
524
  const managerOverlayContent = this.readManagerOverlayFile(path);
456
525
  if (managerOverlayContent !== null) {
457
526
  return {
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
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
+ exports.mergeLearningEntriesAcrossTiers = mergeLearningEntriesAcrossTiers;
12
13
  exports.buildLearningContextSection = buildLearningContextSection;
13
14
  exports.buildTeamContextSection = buildTeamContextSection;
14
15
  exports.resolveTeamContextFiles = resolveTeamContextFiles;
@@ -83,6 +84,46 @@ function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDis
83
84
  }
84
85
  return { present: false, path: globalPath, displayPath: `${globalDisplayBase.replace(/\/$/, '')}/${fileName}` };
85
86
  }
87
+ /**
88
+ * Issue #1002 R4: resolve EVERY tier that holds this personal family, lowest
89
+ * precedence first, instead of only the winning one.
90
+ *
91
+ * The old behavior was a family-level override: a repo-local
92
+ * `<user>-preferences.md` shadowed the whole manager family, so promoting one
93
+ * entry to the project hid every other preference the manager had. Verified
94
+ * before the change, and the same shadowing applied to company families.
95
+ *
96
+ * Returning all tiers lets the context block list each present path and lets the
97
+ * Hub merge entries with the project entry winning only on a title collision.
98
+ */
99
+ function resolvePersonalLearningFileTiers(repoBase, managerCacheBase, managerCacheDisplayBase, globalBase, globalDisplayBase, fileName) {
100
+ const tiers = [];
101
+ const cachePath = (0, path_1.join)(managerCacheBase, fileName);
102
+ if ((0, fs_1.existsSync)(cachePath)) {
103
+ tiers.push({
104
+ level: 'manager',
105
+ path: cachePath,
106
+ displayPath: `${managerCacheDisplayBase.replace(/\/$/, '')}/${fileName}`
107
+ });
108
+ }
109
+ const globalPath = (0, path_1.join)(globalBase, fileName);
110
+ if ((0, fs_1.existsSync)(globalPath)) {
111
+ tiers.push({
112
+ level: 'manager',
113
+ path: globalPath,
114
+ displayPath: `${globalDisplayBase.replace(/\/$/, '')}/${fileName}`
115
+ });
116
+ }
117
+ const repoPath = (0, path_1.join)(repoBase, fileName);
118
+ if ((0, fs_1.existsSync)(repoPath)) {
119
+ tiers.push({
120
+ level: 'project',
121
+ path: repoPath,
122
+ displayPath: `${REPO_LEARNINGS_REL}/${fileName}`
123
+ });
124
+ }
125
+ return tiers;
126
+ }
86
127
  function buildUserIdCandidates(userId) {
87
128
  const candidates = new Set();
88
129
  const trimmed = userId.trim();
@@ -103,40 +144,6 @@ function countMatchingFilesByPrefix(dirPath, matcher) {
103
144
  return 0;
104
145
  }
105
146
  }
106
- function collectAvailableUserPrefixes(workspaceRoot, personalRoots) {
107
- const prefixes = new Set();
108
- const collect = (dirPath, extractor) => {
109
- if (!(0, fs_1.existsSync)(dirPath))
110
- return;
111
- try {
112
- for (const fileName of (0, fs_1.readdirSync)(dirPath)) {
113
- const prefix = extractor(fileName);
114
- if (prefix)
115
- prefixes.add(prefix);
116
- }
117
- }
118
- catch {
119
- // Ignore unreadable directories.
120
- }
121
- };
122
- for (const personalRoot of personalRoots) {
123
- collect(personalRoot, (fileName) => {
124
- if (!fileName.endsWith('.md') || fileName.startsWith('org-'))
125
- return null;
126
- const match = fileName.match(/^(.*?)-(preferences|manager-coaching|mistake-patterns|validated-patterns)\.md$/);
127
- return match ? match[1] : null;
128
- });
129
- }
130
- collect((0, path_1.join)(personalRoots[0], 'raw'), (fileName) => {
131
- const match = fileName.match(/^(.*?)-\d{4}-\d{2}-\d{2}-.*\.md$/);
132
- return match ? match[1] : null;
133
- });
134
- collect((0, path_1.join)(workspaceRoot, 'docs', 'retrospectives'), (fileName) => {
135
- const match = fileName.match(/^(.*?)-\d{4}-\d{2}-\d{2}-.*\.md$/);
136
- return match ? match[1] : null;
137
- });
138
- return prefixes;
139
- }
140
147
  function resolveLearningUserId(workspaceRoot, userId, roots) {
141
148
  const candidates = buildUserIdCandidates(userId);
142
149
  let bestCandidate = candidates[0] || userId;
@@ -163,10 +170,18 @@ function resolveLearningUserId(workspaceRoot, userId, roots) {
163
170
  }
164
171
  if (bestScore > 0)
165
172
  return bestCandidate;
166
- const availablePrefixes = collectAvailableUserPrefixes(workspaceRoot, [roots.repoLearningsBase, roots.managerCacheBase, roots.globalPersonalBase]);
167
- if (availablePrefixes.size === 1) {
168
- return Array.from(availablePrefixes)[0];
169
- }
173
+ // Issue #1002 R4.1: an unresolved identity resolves to the current user's own
174
+ // prefix and therefore to an EMPTY personal section. It must never fall back
175
+ // to "whichever single prefix happens to exist on disk".
176
+ //
177
+ // That fallback used to live here, and it leaked: on a project holding only
178
+ // user A's personal learning files, user B with no files of their own resolved
179
+ // to A's prefix and loaded A's personal learnings, including entries A had
180
+ // deliberately kept at the project level. Verified before removal.
181
+ //
182
+ // Personal learnings are per-user by construction (the filename carries the
183
+ // owner). A learning that genuinely should reach everyone on a project belongs
184
+ // in the company-scope family, which is not keyed by user at all.
170
185
  return bestCandidate;
171
186
  }
172
187
  function readWorkspaceConfig(workspaceRoot) {
@@ -376,6 +391,46 @@ function resolveOrgLearningFile(repoLearningsBase, fileName) {
376
391
  }
377
392
  return { present: false, path: repoPath, displayPath: `${REPO_LEARNINGS_REL}/${fileName}` };
378
393
  }
394
+ /**
395
+ * Issue #1002 R4: the company equivalent of the above. A repo-local `org-*.md`
396
+ * used to shadow the synced company family entirely, so a project-scoped company
397
+ * lesson made every company-wide lesson in that family disappear. Verified.
398
+ */
399
+ function resolveOrgLearningFileTiers(repoLearningsBase, fileName) {
400
+ const tiers = [];
401
+ const cachePath = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'org', 'learnings', fileName);
402
+ if ((0, fs_1.existsSync)(cachePath)) {
403
+ tiers.push({
404
+ level: 'org',
405
+ path: cachePath,
406
+ displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`org/learnings/${fileName}`)
407
+ });
408
+ }
409
+ const repoPath = (0, path_1.join)(repoLearningsBase, fileName);
410
+ if ((0, fs_1.existsSync)(repoPath)) {
411
+ tiers.push({
412
+ level: 'org',
413
+ path: repoPath,
414
+ displayPath: `${REPO_LEARNINGS_REL}/${fileName}`
415
+ });
416
+ }
417
+ return tiers;
418
+ }
419
+ /**
420
+ * Issue #1002 R4: merge entries across tiers. Lowest precedence first in, so a
421
+ * later tier overwrites on an identical title and nothing is duplicated. Entries
422
+ * whose titles do not collide all survive, which is the whole point: promoting
423
+ * one entry must not hide the rest of its family.
424
+ */
425
+ function mergeLearningEntriesAcrossTiers(tiers) {
426
+ const byTitle = new Map();
427
+ for (const tier of tiers) {
428
+ for (const entry of tier.entries) {
429
+ byTitle.set(entry.title.trim().toLowerCase(), entry);
430
+ }
431
+ }
432
+ return [...byTitle.values()];
433
+ }
379
434
  function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
380
435
  const roots = getLearningRoots(workspaceRoot);
381
436
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
@@ -396,6 +451,29 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
396
451
  const l1Cats = forJob ? jobL1Cats : sessionCats;
397
452
  const activeDomain = forJob && domain ? domain : null;
398
453
  const dormantOf = (meta, filePath) => meta.gated ? scanMistakePatternFile(filePath, threshold, meta.ft).dormant : 0;
454
+ // Issue #1002 R4: same shape as resolveTier below, but a family may resolve to
455
+ // MORE THAN ONE file (one per level that holds it), so each is listed.
456
+ const resolveTierMulti = (cats, resolveFiles) => {
457
+ const global = [];
458
+ const domainFiles = [];
459
+ let coachPresent = false;
460
+ for (const key of cats) {
461
+ const meta = CAT[key];
462
+ for (const g of resolveFiles(meta.ft)) {
463
+ global.push({ displayPath: g.displayPath, label: meta.label, dormant: dormantOf(meta, g.path), isCoach: key === 'coach' });
464
+ if (key === 'coach')
465
+ coachPresent = true;
466
+ }
467
+ if (activeDomain) {
468
+ for (const d of resolveFiles(meta.ft, activeDomain)) {
469
+ domainFiles.push({ displayPath: d.displayPath, label: meta.label, dormant: dormantOf(meta, d.path), isCoach: key === 'coach' });
470
+ if (key === 'coach')
471
+ coachPresent = true;
472
+ }
473
+ }
474
+ }
475
+ return { global, domain: domainFiles, coachPresent };
476
+ };
399
477
  // Resolve one tier (L2 org or L1 personal) into ordered global + domain files.
400
478
  const resolveTier = (cats, resolveFile) => {
401
479
  const global = [];
@@ -420,8 +498,19 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
420
498
  }
421
499
  return { global, domain: domainFiles, coachPresent };
422
500
  };
423
- const l2 = resolveTier(l2Cats, (ft, dom) => resolveOrgLearningFile(roots.repoLearningsBase, dom ? `org-${dom}-${ft}.md` : `org-${ft}.md`));
424
- const l1 = resolveTier(l1Cats, (ft, dom) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`));
501
+ // Issue #1002 R4: resolve every tier holding each family, not just the winner,
502
+ // so a promoted entry never hides the rest of its family. `multiTier` is true
503
+ // when at least one family exists at more than one level, which is when the
504
+ // collision rule needs stating.
505
+ let multiTier = false;
506
+ const resolveTiersFor = (tiersFor) => (ft, dom) => {
507
+ const tiers = tiersFor(ft, dom);
508
+ if (tiers.length > 1)
509
+ multiTier = true;
510
+ return tiers.map((t) => ({ present: true, path: t.path, displayPath: t.displayPath }));
511
+ };
512
+ const l2 = resolveTierMulti(l2Cats, resolveTiersFor((ft, dom) => resolveOrgLearningFileTiers(roots.repoLearningsBase, dom ? `org-${dom}-${ft}.md` : `org-${ft}.md`)));
513
+ const l1 = resolveTierMulti(l1Cats, resolveTiersFor((ft, dom) => resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`)));
425
514
  const l2Files = [...l2.global, ...l2.domain];
426
515
  const l1Files = [...l1.global, ...l1.domain];
427
516
  const pendingL0Sources = collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots);
@@ -478,6 +567,9 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
478
567
  if (forJob) {
479
568
  if (hasL2 || hasL1) {
480
569
  section += 'Read the listed synthesized learning files before continuing, then apply the relevant patterns and preferences in this job.\n';
570
+ if (multiTier) {
571
+ section += 'A family listed at more than one level is combined, not replaced: apply every entry, and where the same entry title appears at two levels apply the more specific one (project over manager).\n';
572
+ }
481
573
  if (coachPresent) {
482
574
  section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
483
575
  }
@@ -485,6 +577,9 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
485
577
  }
486
578
  else if (hasL2 || hasL1) {
487
579
  section += 'Read the listed synthesized learning files before continuing, then use this synthesized learning context throughout the session.\n';
580
+ if (multiTier) {
581
+ section += 'A family listed at more than one level is combined, not replaced: apply every entry, and where the same entry title appears at two levels apply the more specific one (project over manager).\n';
582
+ }
488
583
  if (coachPresent) {
489
584
  section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
490
585
  }
@@ -779,45 +874,104 @@ function countLearningEntries(filePath) {
779
874
  return count;
780
875
  }
781
876
  /**
782
- * Count preserved learnings by scope for the Brain summary (R14). Organization
783
- * = L2 org-* files; manager = the personal manager-coaching file (reverse
784
- * mentoring); project = personal mistake/preferences/validated patterns. Raw =
785
- * un-dismissed L0 signals still awaiting synthesis. Reuses the same root/file
786
- * resolution as buildLearningContextSection so the counts line up with what the
787
- * agent actually auto-loads.
877
+ * Issue #1002 R4: the distinct entry titles in a learning file. Counting titles
878
+ * rather than raw headings is what lets a family present at two levels be summed
879
+ * without double-counting a promoted entry.
880
+ */
881
+ function readLearningEntryTitles(filePath) {
882
+ if (!(0, fs_1.existsSync)(filePath))
883
+ return [];
884
+ let content;
885
+ try {
886
+ content = (0, fs_1.readFileSync)(filePath, 'utf8');
887
+ }
888
+ catch {
889
+ return [];
890
+ }
891
+ const titles = [];
892
+ const headingRe = learningEntryHeadingRegex();
893
+ for (const line of content.split(/\r?\n/)) {
894
+ const m = line.match(headingRe);
895
+ if (m)
896
+ titles.push(m[2].trim().toLowerCase());
897
+ }
898
+ return titles;
899
+ }
900
+ /**
901
+ * Count preserved learnings BY LEVEL for the Brain summary.
902
+ *
903
+ * Issue #1002: two changes from the original.
904
+ *
905
+ * R4: counts sum across every level that holds a family, deduped by entry title,
906
+ * rather than counting only the winning file. Under the old first-wins read a
907
+ * family present at two levels was counted once, so the Brain under-reported
908
+ * exactly the case this issue introduces.
909
+ *
910
+ * R11: `manager` now means the manager LEVEL and `reverseMentoring` carries the
911
+ * manager-coaching entries that field used to hold, so the tile labelled
912
+ * "Manager" reports the level rather than a category.
788
913
  */
789
914
  function countPreservedLearnings(workspaceRoot, userId) {
790
915
  const roots = getLearningRoots(workspaceRoot);
791
916
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
792
- // L2 organization-scope preserved files: repo-local override, then the
793
- // synced org cache (#563), matching what buildLearningContextSection injects.
794
- const organization = countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, 'org-mistake-patterns.md').path) +
795
- countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, 'org-preferences.md').path) +
796
- countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, 'org-manager-coaching.md').path) +
797
- countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, 'org-validated-patterns.md').path);
798
- const resolve = (fileName) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
799
- // L1 manager-facing reverse-mentoring file.
800
- const manager = countLearningEntries(resolve(`${resolvedUserId}-manager-coaching.md`).path);
801
- // L1 personal work patterns (project scope).
802
- const project = countLearningEntries(resolve(`${resolvedUserId}-mistake-patterns.md`).path) +
803
- countLearningEntries(resolve(`${resolvedUserId}-preferences.md`).path) +
804
- countLearningEntries(resolve(`${resolvedUserId}-validated-patterns.md`).path);
805
- // #806: fold domain-scoped files into the same per-scope counts so the Brain
806
- // summary reconciles with what the loader injects for a job.
807
- let organizationDomain = 0;
808
- let managerDomain = 0;
809
- let projectDomain = 0;
917
+ // Distinct entry titles across every level holding this company family, so a
918
+ // family present at two levels is not counted once (R4) nor twice.
919
+ const countOrgFamily = (fileName) => {
920
+ const titles = new Set();
921
+ for (const tier of resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName)) {
922
+ for (const t of readLearningEntryTitles(tier.path))
923
+ titles.add(t);
924
+ }
925
+ return titles.size;
926
+ };
927
+ // Same for a personal family, and reported per level so the Brain can show
928
+ // where each entry actually lives. A title present at both levels counts
929
+ // once, at the project level, matching which entry the agent applies.
930
+ const countPersonalFamilyByLevel = (fileName) => {
931
+ const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
932
+ const projectTitles = new Set();
933
+ const managerTitles = new Set();
934
+ for (const tier of tiers) {
935
+ for (const t of readLearningEntryTitles(tier.path)) {
936
+ if (tier.level === 'project')
937
+ projectTitles.add(t);
938
+ else
939
+ managerTitles.add(t);
940
+ }
941
+ }
942
+ // A promoted entry lives at the project level; do not also count it as the
943
+ // manager's, or the two tiles would double-report the same lesson.
944
+ for (const t of projectTitles)
945
+ managerTitles.delete(t);
946
+ return { manager: managerTitles.size, project: projectTitles.size };
947
+ };
948
+ const PERSONAL_WORK_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns'];
949
+ const ORG_TYPES = ['mistake-patterns', 'preferences', 'manager-coaching', 'validated-patterns'];
950
+ let organization = 0;
951
+ for (const ft of ORG_TYPES)
952
+ organization += countOrgFamily(`org-${ft}.md`);
953
+ let manager = 0;
954
+ let project = 0;
955
+ for (const ft of PERSONAL_WORK_TYPES) {
956
+ const byLevel = countPersonalFamilyByLevel(`${resolvedUserId}-${ft}.md`);
957
+ manager += byLevel.manager;
958
+ project += byLevel.project;
959
+ }
960
+ // R11: reverse mentoring is its own count, not folded into the manager level.
961
+ const coachByLevel = countPersonalFamilyByLevel(`${resolvedUserId}-manager-coaching.md`);
962
+ let reverseMentoring = coachByLevel.manager + coachByLevel.project;
963
+ // #806: domain-scoped files fold into the same per-level counts so the Brain
964
+ // reconciles with what the loader injects for a job.
810
965
  for (const domain of learning_domains_1.LEARNING_DOMAINS) {
811
- organizationDomain +=
812
- countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-mistake-patterns.md`).path) +
813
- countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-preferences.md`).path) +
814
- countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-manager-coaching.md`).path) +
815
- countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-validated-patterns.md`).path);
816
- managerDomain += countLearningEntries(resolve(`${resolvedUserId}-${domain}-manager-coaching.md`).path);
817
- projectDomain +=
818
- countLearningEntries(resolve(`${resolvedUserId}-${domain}-mistake-patterns.md`).path) +
819
- countLearningEntries(resolve(`${resolvedUserId}-${domain}-preferences.md`).path) +
820
- countLearningEntries(resolve(`${resolvedUserId}-${domain}-validated-patterns.md`).path);
966
+ for (const ft of ORG_TYPES)
967
+ organization += countOrgFamily(`org-${domain}-${ft}.md`);
968
+ for (const ft of PERSONAL_WORK_TYPES) {
969
+ const byLevel = countPersonalFamilyByLevel(`${resolvedUserId}-${domain}-${ft}.md`);
970
+ manager += byLevel.manager;
971
+ project += byLevel.project;
972
+ }
973
+ const domainCoach = countPersonalFamilyByLevel(`${resolvedUserId}-${domain}-manager-coaching.md`);
974
+ reverseMentoring += domainCoach.manager + domainCoach.project;
821
975
  }
822
976
  // L0 raw signals still awaiting synthesis (not dismissed).
823
977
  let rawSignals = 0;
@@ -838,9 +992,10 @@ function countPreservedLearnings(workspaceRoot, userId) {
838
992
  }
839
993
  }
840
994
  return {
841
- organization: organization + organizationDomain,
842
- manager: manager + managerDomain,
843
- project: project + projectDomain,
995
+ organization,
996
+ manager,
997
+ project,
998
+ reverseMentoring,
844
999
  rawSignals,
845
1000
  };
846
1001
  }
@@ -2221,8 +2221,22 @@ class FraimLocalMCPServer {
2221
2221
  try {
2222
2222
  const projectRoot = this.findProjectRoot();
2223
2223
  const uniqueLocalItems = new Map();
2224
- if (projectRoot) {
2225
- const personalizedJobsDir = (0, path_1.join)(projectRoot, 'fraim', 'personalized-employee', 'jobs');
2224
+ // Issue #1002 R3: list the manager's own jobs too, not only the
2225
+ // project's. A job authored at the manager level resolves by name
2226
+ // from every project, so omitting it here made it invisible in the
2227
+ // one place the manager looks to find out what their employee can do.
2228
+ // Lowest precedence first, so a project job of the same name wins.
2229
+ const { getUserFraimDirPath } = require('../core/utils/project-fraim-paths');
2230
+ const userFraimDir = getUserFraimDirPath();
2231
+ const jobDirsByLevel = [
2232
+ { dir: (0, path_1.join)(userFraimDir, 'manager', 'jobs'), level: 'manager' },
2233
+ { dir: (0, path_1.join)(userFraimDir, 'personalized-employee', 'jobs'), level: 'manager' },
2234
+ ...(projectRoot
2235
+ ? [{ dir: (0, path_1.join)(projectRoot, 'fraim', 'personalized-employee', 'jobs'), level: 'project' }]
2236
+ : []),
2237
+ ];
2238
+ for (const { dir: jobsDir, level } of jobDirsByLevel) {
2239
+ const personalizedJobsDir = jobsDir;
2226
2240
  if ((0, fs_1.existsSync)(personalizedJobsDir)) {
2227
2241
  const collectLocalJobPaths = (dir, currentRel = '') => {
2228
2242
  const results = [];
@@ -2242,16 +2256,19 @@ class FraimLocalMCPServer {
2242
2256
  const localJobPaths = collectLocalJobPaths(personalizedJobsDir);
2243
2257
  for (const relPath of localJobPaths) {
2244
2258
  const normalizedName = relPath.split('/').pop()?.replace(/\.md$/, '').trim() || '';
2245
- if (!normalizedName || uniqueLocalItems.has(normalizedName)) {
2259
+ if (!normalizedName) {
2246
2260
  continue;
2247
2261
  }
2248
2262
  const category = relPath.includes('/')
2249
2263
  ? relPath.split('/').slice(0, -1).join('/') || 'personalized-employee'
2250
2264
  : 'personalized-employee';
2265
+ // A later level overwrites an earlier one on the same job name,
2266
+ // matching the resolver: project wins over manager.
2251
2267
  uniqueLocalItems.set(normalizedName, {
2252
2268
  name: normalizedName,
2253
2269
  path: relPath,
2254
- category
2270
+ category,
2271
+ level
2255
2272
  });
2256
2273
  }
2257
2274
  }
@@ -2260,13 +2277,16 @@ class FraimLocalMCPServer {
2260
2277
  const sortedLocalItems = Array.from(uniqueLocalItems.values())
2261
2278
  .sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
2262
2279
  let combinedText = response.result.content[0].text;
2263
- combinedText += `\n\n## Local Personalized Jobs (${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/jobs/')})\n`;
2264
- combinedText += 'These local jobs override catalog jobs of the same name when you call `get_fraim_job`.\n';
2280
+ combinedText += '\n\n## Your Own Jobs\n';
2281
+ combinedText += 'These override catalog jobs of the same name when you call `get_fraim_job`. The level says who else has each one.\n';
2265
2282
  for (const item of sortedLocalItems) {
2266
2283
  const categorySuffix = item.category && item.category !== 'personalized-employee'
2267
2284
  ? ` (${item.category})`
2268
2285
  : '';
2269
- combinedText += `- **${item.name}**${categorySuffix}\n`;
2286
+ const levelWord = item.level === 'project'
2287
+ ? 'this project only'
2288
+ : 'yours, every project';
2289
+ combinedText += `- **${item.name}**${categorySuffix} - ${levelWord}\n`;
2270
2290
  }
2271
2291
  response.result.content[0].text = combinedText;
2272
2292
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.243",
3
+ "version": "2.0.245",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {