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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
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 #
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
//
|
|
793
|
-
//
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
842
|
-
manager
|
|
843
|
-
project
|
|
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
|
-
|
|
2225
|
-
|
|
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
|
|
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 +=
|
|
2264
|
-
combinedText += 'These
|
|
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
|
-
|
|
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
|
}
|