fraim 2.0.209 → 2.0.210
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.
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Learning domains (issue #806).
|
|
4
|
+
*
|
|
5
|
+
* A learning's DOMAIN is the fourth axis of the learning system, alongside:
|
|
6
|
+
* - tier (L0 raw / L1 personal / L2 org),
|
|
7
|
+
* - category (mistake-patterns / preferences / validated-patterns / manager-coaching),
|
|
8
|
+
* - scope (machine-global `~/.fraim` vs repo-local `fraim/`).
|
|
9
|
+
*
|
|
10
|
+
* A learning is either `global` (applies to every job) or scoped to exactly one
|
|
11
|
+
* coarse job-domain. At `get_fraim_job`, the loader injects global learnings plus
|
|
12
|
+
* the current job's-domain learnings only, so a marketing job no longer loads
|
|
13
|
+
* engineering learnings.
|
|
14
|
+
*
|
|
15
|
+
* This module is the single source of truth for the domain list, the
|
|
16
|
+
* registry-category -> domain map, the per-domain authoring voice (R3), and the
|
|
17
|
+
* resolver used at the injection sites. It is pure (no I/O) and mirrors the
|
|
18
|
+
* config-module convention of `src/config/portfolio-slug-overrides.ts`.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.DOMAIN_VOICE = exports.GLOBAL_JOB_CATEGORIES = exports.JOB_DOMAIN_MAP = exports.LEARNING_DOMAINS = void 0;
|
|
22
|
+
exports.isLearningDomain = isLearningDomain;
|
|
23
|
+
exports.jobCategoryFromRegistryPath = jobCategoryFromRegistryPath;
|
|
24
|
+
exports.resolveJobDomain = resolveJobDomain;
|
|
25
|
+
exports.domainFromJobFrontmatter = domainFromJobFrontmatter;
|
|
26
|
+
exports.resolveDomainForJob = resolveDomainForJob;
|
|
27
|
+
/** The coarse learning domains. `global` is represented by the absence of a domain. */
|
|
28
|
+
exports.LEARNING_DOMAINS = [
|
|
29
|
+
'engineering',
|
|
30
|
+
'product',
|
|
31
|
+
'go-to-market',
|
|
32
|
+
'people-ops',
|
|
33
|
+
'finance',
|
|
34
|
+
'legal',
|
|
35
|
+
'compliance',
|
|
36
|
+
'customer',
|
|
37
|
+
'company-building',
|
|
38
|
+
'operations',
|
|
39
|
+
];
|
|
40
|
+
function isLearningDomain(value) {
|
|
41
|
+
return typeof value === 'string' && exports.LEARNING_DOMAINS.includes(value);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Registry job category -> learning domain. Seeded from the registry taxonomy and
|
|
45
|
+
* the `PERSONA_CAPABILITY_BUNDLES` groupings. Every domain-scoped category maps
|
|
46
|
+
* here; cross-cutting categories are listed in `GLOBAL_JOB_CATEGORIES` instead.
|
|
47
|
+
*
|
|
48
|
+
* A build-time test (`tests/test-unit-learning-domains.ts`) asserts that every
|
|
49
|
+
* registry job category (from `registry/jobs/**`) is covered by exactly one of
|
|
50
|
+
* this map or `GLOBAL_JOB_CATEGORIES`, so the taxonomy cannot silently drift as
|
|
51
|
+
* new job categories are added.
|
|
52
|
+
*/
|
|
53
|
+
exports.JOB_DOMAIN_MAP = {
|
|
54
|
+
// engineering
|
|
55
|
+
'product-building': 'engineering',
|
|
56
|
+
'dev-ops': 'engineering',
|
|
57
|
+
'quality-assurance': 'engineering',
|
|
58
|
+
'migration': 'engineering',
|
|
59
|
+
'replicate': 'engineering',
|
|
60
|
+
'reviewer': 'engineering',
|
|
61
|
+
'security': 'engineering',
|
|
62
|
+
'delivery-ops': 'engineering',
|
|
63
|
+
'salesforce': 'engineering',
|
|
64
|
+
// product
|
|
65
|
+
'product-management': 'product',
|
|
66
|
+
'customer-development': 'product',
|
|
67
|
+
'brainstorming': 'product',
|
|
68
|
+
// go-to-market
|
|
69
|
+
'marketing': 'go-to-market',
|
|
70
|
+
'branding': 'go-to-market',
|
|
71
|
+
'sales': 'go-to-market',
|
|
72
|
+
'go-to-market': 'go-to-market',
|
|
73
|
+
'business-development': 'go-to-market',
|
|
74
|
+
'commercial-ops': 'go-to-market',
|
|
75
|
+
// people-ops
|
|
76
|
+
'people-ops': 'people-ops',
|
|
77
|
+
'inclusion-ops': 'people-ops',
|
|
78
|
+
'career': 'people-ops',
|
|
79
|
+
// finance
|
|
80
|
+
'finance': 'finance',
|
|
81
|
+
'invoicing': 'finance',
|
|
82
|
+
'banking': 'finance',
|
|
83
|
+
// legal
|
|
84
|
+
'legal': 'legal',
|
|
85
|
+
// compliance
|
|
86
|
+
'compliance': 'compliance',
|
|
87
|
+
// customer
|
|
88
|
+
'customer-success': 'customer',
|
|
89
|
+
'customer-communication': 'customer',
|
|
90
|
+
// company-building
|
|
91
|
+
'company-creation': 'company-building',
|
|
92
|
+
'bootstrap': 'company-building',
|
|
93
|
+
'fundraising': 'company-building',
|
|
94
|
+
// operations
|
|
95
|
+
'business-ops': 'operations',
|
|
96
|
+
'procurement': 'operations',
|
|
97
|
+
'project-management': 'operations',
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Cross-cutting job categories that intentionally resolve to `global` (no domain).
|
|
101
|
+
* Management and framework-meta learnings apply regardless of the work domain, so
|
|
102
|
+
* they are never hidden from any job. Listed explicitly so the completeness test
|
|
103
|
+
* can tell an intentional global category apart from a forgotten mapping.
|
|
104
|
+
*/
|
|
105
|
+
exports.GLOBAL_JOB_CATEGORIES = new Set([
|
|
106
|
+
// ai-employee cross-cutting
|
|
107
|
+
'manager',
|
|
108
|
+
'fraim',
|
|
109
|
+
// ai-manager (all management categories)
|
|
110
|
+
'1-1',
|
|
111
|
+
'coaching',
|
|
112
|
+
'delegation',
|
|
113
|
+
'hiring',
|
|
114
|
+
'productivity',
|
|
115
|
+
'project-setup',
|
|
116
|
+
'setup-guardrails',
|
|
117
|
+
'team-learning',
|
|
118
|
+
'verification',
|
|
119
|
+
]);
|
|
120
|
+
/**
|
|
121
|
+
* One-line authoring voice per domain (R3). Consumed as documentation and by the
|
|
122
|
+
* synthesis jobs/skills (`sleep-on-learnings`, `organizational-learning-synthesis`,
|
|
123
|
+
* `l1-signal-classification`) so a domain-specific learning is written in that
|
|
124
|
+
* domain's language and stays human-auditable.
|
|
125
|
+
*/
|
|
126
|
+
exports.DOMAIN_VOICE = {
|
|
127
|
+
'engineering': 'Precise and technical; file paths, commands, and test names are appropriate.',
|
|
128
|
+
'product': 'Product and user-outcome language; behavior and flow, not code symbols.',
|
|
129
|
+
'go-to-market': 'Marketing and sales language; campaigns, funnels, messaging, audience.',
|
|
130
|
+
'people-ops': 'Plain HR and recruiting language; no engineering jargon.',
|
|
131
|
+
'finance': 'Finance and accounting language; figures, close, forecasts.',
|
|
132
|
+
'legal': 'Contract and IP language; obligations, clauses, risk.',
|
|
133
|
+
'compliance': 'Controls, evidence, and regulation language.',
|
|
134
|
+
'customer': 'Customer-relationship and support language.',
|
|
135
|
+
'company-building': 'Founder and operations language; setup, entity, funding.',
|
|
136
|
+
'operations': 'Business-operations language; process, vendors, planning, coordination.',
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Extract the registry job category from a job's registry path.
|
|
140
|
+
*
|
|
141
|
+
* Handles both `jobs/ai-employee/<category>/<name>.md` (and `ai-manager`,
|
|
142
|
+
* `personalized-employee`) and the flatter `jobs/<category>/<name>.md`, with or
|
|
143
|
+
* without a leading `registry/` prefix and with either slash style. Returns null
|
|
144
|
+
* when the input is not a job path (for example a bare job name with no directory).
|
|
145
|
+
*
|
|
146
|
+
* Mirrors the jobs branch of `UsageCollector.parseComponentName` so both derive
|
|
147
|
+
* the category the same way.
|
|
148
|
+
*/
|
|
149
|
+
function jobCategoryFromRegistryPath(registryPath) {
|
|
150
|
+
if (!registryPath || typeof registryPath !== 'string')
|
|
151
|
+
return null;
|
|
152
|
+
let clean = registryPath.replace(/\\/g, '/').replace(/^registry\//, '');
|
|
153
|
+
if (clean.startsWith('/'))
|
|
154
|
+
clean = clean.slice(1);
|
|
155
|
+
const parts = clean.split('/');
|
|
156
|
+
if (parts[0] !== 'jobs')
|
|
157
|
+
return null;
|
|
158
|
+
if (parts[1] === 'ai-employee' || parts[1] === 'ai-manager' || parts[1] === 'personalized-employee') {
|
|
159
|
+
return parts.length > 3 ? parts[2] : null; // jobs/ai-employee/<category>/<name>.md
|
|
160
|
+
}
|
|
161
|
+
return parts.length > 2 ? parts[1] : null; // jobs/<category>/<name>.md
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Resolve the learning domain for a job from its registry path.
|
|
165
|
+
*
|
|
166
|
+
* Returns a `LearningDomain` for domain-scoped categories, or `null` for
|
|
167
|
+
* cross-cutting categories, unmapped categories, and unparseable inputs. `null`
|
|
168
|
+
* means "load global learnings only" at the injection site: global is never
|
|
169
|
+
* hidden and an unknown category never guesses a wrong domain. The completeness
|
|
170
|
+
* test is what distinguishes an intentional global from a missing mapping.
|
|
171
|
+
*/
|
|
172
|
+
function resolveJobDomain(registryPath) {
|
|
173
|
+
const category = jobCategoryFromRegistryPath(registryPath);
|
|
174
|
+
if (!category)
|
|
175
|
+
return null;
|
|
176
|
+
return exports.JOB_DOMAIN_MAP[category] ?? null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Read an explicit learning domain a job declares in its own frontmatter, if any.
|
|
180
|
+
*
|
|
181
|
+
* This is the extensibility hook (issue #806 review): a job can self-declare
|
|
182
|
+
* `domain` so a newly-taught job with a new category is handled without editing
|
|
183
|
+
* `JOB_DOMAIN_MAP`. Supports the JSON frontmatter block FRAIM jobs use
|
|
184
|
+
* (`---\n{ ..., "domain": "people-ops" }\n---`) and a plain `domain: <value>`
|
|
185
|
+
* line. Returns null when there is no frontmatter, no `domain`, or an unknown
|
|
186
|
+
* domain - callers then fall back to the category map.
|
|
187
|
+
*/
|
|
188
|
+
function domainFromJobFrontmatter(jobFileContent) {
|
|
189
|
+
if (!jobFileContent || typeof jobFileContent !== 'string')
|
|
190
|
+
return null;
|
|
191
|
+
const fm = jobFileContent.match(/^?---\r?\n([\s\S]*?)\r?\n---/);
|
|
192
|
+
if (!fm)
|
|
193
|
+
return null;
|
|
194
|
+
const block = fm[1];
|
|
195
|
+
const trimmed = block.trim();
|
|
196
|
+
if (trimmed.startsWith('{')) {
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(trimmed);
|
|
199
|
+
return isLearningDomain(parsed?.domain) ? parsed.domain : null;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// fall through to line parsing
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const line = block.match(/^\s*domain:\s*["']?([a-z][a-z-]*)["']?\s*$/m);
|
|
206
|
+
return line && isLearningDomain(line[1]) ? line[1] : null;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Resolve the learning domain for a job from its registry path and (optional)
|
|
210
|
+
* file content. A domain the job self-declares in frontmatter overrides the
|
|
211
|
+
* category map; otherwise the map applies; a null/unparseable path yields null
|
|
212
|
+
* (load global only). Pure and side-effect-free: callers do the I/O (reading the
|
|
213
|
+
* registry path and job file) and pass the results here, which keeps this
|
|
214
|
+
* decision logic directly testable with mocked inputs.
|
|
215
|
+
*/
|
|
216
|
+
function resolveDomainForJob(registryPath, jobFileContent) {
|
|
217
|
+
if (!registryPath)
|
|
218
|
+
return null;
|
|
219
|
+
const declared = jobFileContent ? domainFromJobFrontmatter(jobFileContent) : null;
|
|
220
|
+
return declared ?? resolveJobDomain(registryPath);
|
|
221
|
+
}
|
|
@@ -24,6 +24,7 @@ const fs_1 = require("fs");
|
|
|
24
24
|
const path_1 = require("path");
|
|
25
25
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
26
26
|
const brand_store_1 = require("../core/brand-store");
|
|
27
|
+
const learning_domains_1 = require("../config/learning-domains");
|
|
27
28
|
const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/learnings').replace(/\/$/, '');
|
|
28
29
|
const DEFAULT_THRESHOLD = 3.0;
|
|
29
30
|
const AGING_HORIZON_DAYS = 7;
|
|
@@ -374,32 +375,58 @@ function resolveOrgLearningFile(repoLearningsBase, fileName) {
|
|
|
374
375
|
}
|
|
375
376
|
return { present: false, path: repoPath, displayPath: `${REPO_LEARNINGS_REL}/${fileName}` };
|
|
376
377
|
}
|
|
377
|
-
function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
378
|
+
function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
378
379
|
const roots = getLearningRoots(workspaceRoot);
|
|
379
380
|
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
380
381
|
const threshold = getScoreThreshold(workspaceRoot);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
const
|
|
382
|
+
const CAT = {
|
|
383
|
+
mistake: { ft: 'mistake-patterns', gated: true, label: '(entries above score threshold)' },
|
|
384
|
+
pref: { ft: 'preferences', gated: false, label: '(all entries)' },
|
|
385
|
+
coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
|
|
386
|
+
validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
|
|
387
|
+
};
|
|
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'];
|
|
395
|
+
const activeDomain = forJob && domain ? domain : null;
|
|
396
|
+
const dormantOf = (meta, filePath) => meta.gated ? scanMistakePatternFile(filePath, threshold, meta.ft).dormant : 0;
|
|
397
|
+
// Resolve one tier (L2 org or L1 personal) into ordered global + domain files.
|
|
398
|
+
const resolveTier = (cats, resolveFile) => {
|
|
399
|
+
const global = [];
|
|
400
|
+
const domainFiles = [];
|
|
401
|
+
let coachPresent = false;
|
|
402
|
+
for (const key of cats) {
|
|
403
|
+
const meta = CAT[key];
|
|
404
|
+
const g = resolveFile(meta.ft);
|
|
405
|
+
if (g.present) {
|
|
406
|
+
global.push({ displayPath: g.displayPath, label: meta.label, dormant: dormantOf(meta, g.path), isCoach: key === 'coach' });
|
|
407
|
+
if (key === 'coach')
|
|
408
|
+
coachPresent = true;
|
|
409
|
+
}
|
|
410
|
+
if (activeDomain) {
|
|
411
|
+
const d = resolveFile(meta.ft, activeDomain);
|
|
412
|
+
if (d.present) {
|
|
413
|
+
domainFiles.push({ displayPath: d.displayPath, label: meta.label, dormant: dormantOf(meta, d.path), isCoach: key === 'coach' });
|
|
414
|
+
if (key === 'coach')
|
|
415
|
+
coachPresent = true;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { global, domain: domainFiles, coachPresent };
|
|
420
|
+
};
|
|
421
|
+
const l2 = resolveTier(l2Cats, (ft, dom) => resolveOrgLearningFile(roots.repoLearningsBase, dom ? `org-${dom}-${ft}.md` : `org-${ft}.md`));
|
|
422
|
+
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`));
|
|
423
|
+
const l2Files = [...l2.global, ...l2.domain];
|
|
424
|
+
const l1Files = [...l1.global, ...l1.domain];
|
|
398
425
|
const pendingL0Sources = collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots);
|
|
399
426
|
const l0CoachingCount = pendingL0Sources.filter(source => source.kind === 'coaching-moment').length;
|
|
400
427
|
const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
|
|
401
|
-
const hasL2 =
|
|
402
|
-
const hasL1 =
|
|
428
|
+
const hasL2 = l2Files.length > 0;
|
|
429
|
+
const hasL1 = l1Files.length > 0;
|
|
403
430
|
const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0;
|
|
404
431
|
if (!hasContent)
|
|
405
432
|
return '';
|
|
@@ -408,15 +435,9 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
408
435
|
: '\n\n## Learning Context\n\n';
|
|
409
436
|
if (hasL2) {
|
|
410
437
|
section += '### L2 - Org patterns\n';
|
|
411
|
-
|
|
412
|
-
section += `\`${
|
|
413
|
-
|
|
414
|
-
section += `\`${l2Pref.displayPath}\` (all entries)\n`;
|
|
415
|
-
if (l2CoachPresent)
|
|
416
|
-
section += `\`${l2Coach.displayPath}\` (manager-facing; all entries)\n`;
|
|
417
|
-
if (l2ValidatedPresent)
|
|
418
|
-
section += `\`${l2Validated.displayPath}\` (entries above score threshold)\n`;
|
|
419
|
-
const l2DormantTotal = (l2MistakeStats?.dormant || 0) + (l2ValidatedStats?.dormant || 0);
|
|
438
|
+
for (const f of l2Files)
|
|
439
|
+
section += `\`${f.displayPath}\` ${f.label}\n`;
|
|
440
|
+
const l2DormantTotal = l2Files.reduce((sum, f) => sum + f.dormant, 0);
|
|
420
441
|
if (l2DormantTotal > 0) {
|
|
421
442
|
section += `Dormant: ${l2DormantTotal} org pattern${l2DormantTotal !== 1 ? 's' : ''} below threshold\n`;
|
|
422
443
|
}
|
|
@@ -424,15 +445,9 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
424
445
|
}
|
|
425
446
|
if (hasL1) {
|
|
426
447
|
section += '### L1 - Your patterns\n';
|
|
427
|
-
|
|
428
|
-
section += `\`${
|
|
429
|
-
|
|
430
|
-
section += `\`${l1Coach.displayPath}\` (manager-facing; all entries)\n`;
|
|
431
|
-
if (l1Mistake.present)
|
|
432
|
-
section += `\`${l1Mistake.displayPath}\` (entries above score threshold)\n`;
|
|
433
|
-
if (l1Validated.present)
|
|
434
|
-
section += `\`${l1Validated.displayPath}\` (entries above score threshold)\n`;
|
|
435
|
-
const l1DormantTotal = (l1MistakeStats?.dormant || 0) + (l1ValidatedStats?.dormant || 0);
|
|
448
|
+
for (const f of l1Files)
|
|
449
|
+
section += `\`${f.displayPath}\` ${f.label}\n`;
|
|
450
|
+
const l1DormantTotal = l1Files.reduce((sum, f) => sum + f.dormant, 0);
|
|
436
451
|
if (l1DormantTotal > 0) {
|
|
437
452
|
section += `Dormant: ${l1DormantTotal} personal pattern${l1DormantTotal !== 1 ? 's' : ''} below threshold\n`;
|
|
438
453
|
}
|
|
@@ -457,17 +472,18 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
|
|
|
457
472
|
}
|
|
458
473
|
section += '\n';
|
|
459
474
|
}
|
|
475
|
+
const coachPresent = l1.coachPresent || l2.coachPresent;
|
|
460
476
|
if (forJob) {
|
|
461
477
|
if (hasL2 || hasL1) {
|
|
462
478
|
section += 'Use the relevant patterns and preferences in this job.\n';
|
|
463
|
-
if (
|
|
479
|
+
if (coachPresent) {
|
|
464
480
|
section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
|
|
465
481
|
}
|
|
466
482
|
}
|
|
467
483
|
}
|
|
468
484
|
else {
|
|
469
485
|
section += 'Use this synthesized learning context throughout the session.\n';
|
|
470
|
-
if (
|
|
486
|
+
if (coachPresent) {
|
|
471
487
|
section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
|
|
472
488
|
}
|
|
473
489
|
}
|
|
@@ -784,6 +800,23 @@ function countPreservedLearnings(workspaceRoot, userId) {
|
|
|
784
800
|
const project = countLearningEntries(resolve(`${resolvedUserId}-mistake-patterns.md`).path) +
|
|
785
801
|
countLearningEntries(resolve(`${resolvedUserId}-preferences.md`).path) +
|
|
786
802
|
countLearningEntries(resolve(`${resolvedUserId}-validated-patterns.md`).path);
|
|
803
|
+
// #806: fold domain-scoped files into the same per-scope counts so the Brain
|
|
804
|
+
// summary reconciles with what the loader injects for a job.
|
|
805
|
+
let organizationDomain = 0;
|
|
806
|
+
let managerDomain = 0;
|
|
807
|
+
let projectDomain = 0;
|
|
808
|
+
for (const domain of learning_domains_1.LEARNING_DOMAINS) {
|
|
809
|
+
organizationDomain +=
|
|
810
|
+
countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-mistake-patterns.md`).path) +
|
|
811
|
+
countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-preferences.md`).path) +
|
|
812
|
+
countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-manager-coaching.md`).path) +
|
|
813
|
+
countLearningEntries(resolveOrgLearningFile(roots.repoLearningsBase, `org-${domain}-validated-patterns.md`).path);
|
|
814
|
+
managerDomain += countLearningEntries(resolve(`${resolvedUserId}-${domain}-manager-coaching.md`).path);
|
|
815
|
+
projectDomain +=
|
|
816
|
+
countLearningEntries(resolve(`${resolvedUserId}-${domain}-mistake-patterns.md`).path) +
|
|
817
|
+
countLearningEntries(resolve(`${resolvedUserId}-${domain}-preferences.md`).path) +
|
|
818
|
+
countLearningEntries(resolve(`${resolvedUserId}-${domain}-validated-patterns.md`).path);
|
|
819
|
+
}
|
|
787
820
|
// L0 raw signals still awaiting synthesis (not dismissed).
|
|
788
821
|
let rawSignals = 0;
|
|
789
822
|
const rawDir = (0, path_1.join)(roots.repoLearningsBase, 'raw');
|
|
@@ -802,7 +835,12 @@ function countPreservedLearnings(workspaceRoot, userId) {
|
|
|
802
835
|
// ignore unreadable raw dir
|
|
803
836
|
}
|
|
804
837
|
}
|
|
805
|
-
return {
|
|
838
|
+
return {
|
|
839
|
+
organization: organization + organizationDomain,
|
|
840
|
+
manager: manager + managerDomain,
|
|
841
|
+
project: project + projectDomain,
|
|
842
|
+
rawSignals,
|
|
843
|
+
};
|
|
806
844
|
}
|
|
807
845
|
exports.CATEGORY_TO_FILETYPE = {
|
|
808
846
|
avoid: 'mistake-patterns',
|
|
@@ -816,10 +854,26 @@ const FILETYPE_TO_CATEGORY = {
|
|
|
816
854
|
'validated-patterns': 'repeat',
|
|
817
855
|
'manager-coaching': 'coaching',
|
|
818
856
|
};
|
|
857
|
+
/**
|
|
858
|
+
* Enumerate domain-scoped learning files (`<prefix>-<domain>-<filetype>.md`, issue
|
|
859
|
+
* #806) present in a directory, in a deterministic domain order. Used by the Hub
|
|
860
|
+
* read/count paths so domain learnings appear alongside the flat global files.
|
|
861
|
+
*/
|
|
862
|
+
function listDomainLearningFiles(dir, prefix, fileType) {
|
|
863
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
864
|
+
return [];
|
|
865
|
+
const out = [];
|
|
866
|
+
for (const domain of learning_domains_1.LEARNING_DOMAINS) {
|
|
867
|
+
const fileName = `${prefix}-${domain}-${fileType}.md`;
|
|
868
|
+
if ((0, fs_1.existsSync)((0, path_1.join)(dir, fileName)))
|
|
869
|
+
out.push({ fileName, domain });
|
|
870
|
+
}
|
|
871
|
+
return out;
|
|
872
|
+
}
|
|
819
873
|
// Parse the `## [P-…] Title` entries (and their body prose) out of one learning
|
|
820
874
|
// file. Score/Last seen/Recurrences metadata lines are dropped — the Hub shows
|
|
821
875
|
// the human-readable learning, not the bookkeeping.
|
|
822
|
-
function parseLearningEntries(filePath, displayPath, category, level) {
|
|
876
|
+
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global') {
|
|
823
877
|
if (!(0, fs_1.existsSync)(filePath))
|
|
824
878
|
return [];
|
|
825
879
|
let content;
|
|
@@ -845,7 +899,7 @@ function parseLearningEntries(filePath, displayPath, category, level) {
|
|
|
845
899
|
const header = line.match(headingRe);
|
|
846
900
|
if (header) {
|
|
847
901
|
flush();
|
|
848
|
-
current = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level };
|
|
902
|
+
current = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level, domain };
|
|
849
903
|
continue;
|
|
850
904
|
}
|
|
851
905
|
if (!current)
|
|
@@ -877,8 +931,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
877
931
|
const out = [];
|
|
878
932
|
if (scope === 'org') {
|
|
879
933
|
for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
|
|
880
|
-
const
|
|
934
|
+
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
935
|
+
const f = `org-${fileType}.md`;
|
|
881
936
|
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'org'));
|
|
937
|
+
// #806: domain-scoped org learnings alongside the flat global file.
|
|
938
|
+
for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, 'org', fileType)) {
|
|
939
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain));
|
|
940
|
+
}
|
|
882
941
|
}
|
|
883
942
|
return out;
|
|
884
943
|
}
|
|
@@ -886,8 +945,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
886
945
|
const { dir, displayBase } = levelDir(roots, level);
|
|
887
946
|
const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
|
|
888
947
|
for (const cat of cats) {
|
|
889
|
-
const
|
|
948
|
+
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
949
|
+
const f = `${resolvedUserId}-${fileType}.md`;
|
|
890
950
|
out.push(...parseLearningEntries((0, path_1.join)(dir, f), `${displayBase}/${f}`, cat, level));
|
|
951
|
+
// #806: domain-scoped personal learnings alongside the flat global file.
|
|
952
|
+
for (const { fileName, domain } of listDomainLearningFiles(dir, resolvedUserId, fileType)) {
|
|
953
|
+
out.push(...parseLearningEntries((0, path_1.join)(dir, fileName), `${displayBase}/${fileName}`, cat, level, domain));
|
|
954
|
+
}
|
|
891
955
|
}
|
|
892
956
|
return out;
|
|
893
957
|
}
|
|
@@ -68,6 +68,7 @@ const usage_collector_js_1 = require("./usage-collector.js");
|
|
|
68
68
|
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
69
69
|
const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
|
|
70
70
|
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
71
|
+
const learning_domains_js_1 = require("../config/learning-domains.js");
|
|
71
72
|
/**
|
|
72
73
|
* Handle template substitution logic separately for better testability
|
|
73
74
|
*/
|
|
@@ -1371,16 +1372,42 @@ class FraimLocalMCPServer {
|
|
|
1371
1372
|
const userEmail = this.ensureEngine().getUserEmail();
|
|
1372
1373
|
if (typeof text === 'string' && userEmail) {
|
|
1373
1374
|
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
1374
|
-
const
|
|
1375
|
+
const jobDomain = await this.resolveJobDomainForJob(args.job, requestSessionId);
|
|
1376
|
+
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true, jobDomain);
|
|
1375
1377
|
const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
|
|
1376
1378
|
if (learningSection || teamContextSection) {
|
|
1377
1379
|
finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection + teamContextSection;
|
|
1378
|
-
this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail}`);
|
|
1380
|
+
this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail} (domain: ${jobDomain ?? 'global'})`);
|
|
1379
1381
|
}
|
|
1380
1382
|
}
|
|
1381
1383
|
}
|
|
1382
1384
|
return this.processResponseWithHydration(finalizedResponse, requestSessionId);
|
|
1383
1385
|
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Resolve the learning domain for a `get_fraim_job` request (issue #806) from
|
|
1388
|
+
* the job's registry path, so the loader injects global + that domain's
|
|
1389
|
+
* learnings only. Returns null (global-only) on any failure: the learning
|
|
1390
|
+
* context must never break a job response, and an unknown job never guesses a
|
|
1391
|
+
* wrong domain.
|
|
1392
|
+
*/
|
|
1393
|
+
async resolveJobDomainForJob(jobName, requestSessionId) {
|
|
1394
|
+
try {
|
|
1395
|
+
const resolver = this.getRegistryResolver(requestSessionId);
|
|
1396
|
+
if (!resolver)
|
|
1397
|
+
return null;
|
|
1398
|
+
const registryPath = await resolver.findRegistryPath('jobs', jobName);
|
|
1399
|
+
if (!registryPath)
|
|
1400
|
+
return null;
|
|
1401
|
+
// Read the job file so a domain it self-declares in frontmatter can override
|
|
1402
|
+
// the category map (extensible for newly-taught jobs); the pure resolver
|
|
1403
|
+
// owns the decision logic.
|
|
1404
|
+
const resolved = await resolver.resolveFile(registryPath).catch(() => null);
|
|
1405
|
+
return (0, learning_domains_js_1.resolveDomainForJob)(registryPath, resolved?.content ?? null);
|
|
1406
|
+
}
|
|
1407
|
+
catch {
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1384
1411
|
async finalizeLocalToolTextResponse(request, requestSessionId, requestId, text) {
|
|
1385
1412
|
const response = {
|
|
1386
1413
|
jsonrpc: '2.0',
|
|
@@ -2131,11 +2158,12 @@ class FraimLocalMCPServer {
|
|
|
2131
2158
|
const userEmail = this.ensureEngine().getUserEmail();
|
|
2132
2159
|
if (userEmail) {
|
|
2133
2160
|
const workspaceRoot = this.findProjectRoot() || process.cwd();
|
|
2134
|
-
const
|
|
2161
|
+
const jobDomain = await this.resolveJobDomainForJob(name, requestSessionId);
|
|
2162
|
+
const learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true, jobDomain);
|
|
2135
2163
|
const teamContextSection = (0, learning_context_builder_js_1.buildTeamContextSection)(workspaceRoot, true);
|
|
2136
2164
|
if (learningSection || teamContextSection) {
|
|
2137
2165
|
responseText += `\n\n---` + learningSection + teamContextSection;
|
|
2138
|
-
this.log(`✅ Injected job-focus learning/team context for ${userEmail} (local override path)`);
|
|
2166
|
+
this.log(`✅ Injected job-focus learning/team context for ${userEmail} (local override path, domain: ${jobDomain ?? 'global'})`);
|
|
2139
2167
|
}
|
|
2140
2168
|
}
|
|
2141
2169
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, responseText);
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.UsageCollector = void 0;
|
|
7
7
|
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
const learning_domains_js_1 = require("../config/learning-domains.js");
|
|
8
9
|
/**
|
|
9
10
|
* UsageCollector is responsible for collecting usage events from MCP tools
|
|
10
11
|
* and formatting them for the analytics system.
|
|
@@ -317,17 +318,11 @@ class UsageCollector {
|
|
|
317
318
|
const category = parts.length > 2 ? parts[1] : undefined;
|
|
318
319
|
return { type: 'skill', name, category };
|
|
319
320
|
}
|
|
320
|
-
// Match job files
|
|
321
|
+
// Match job files. Category extraction (jobs/ai-employee/<category>/name.md
|
|
322
|
+
// or jobs/<category>/name.md) shares one parser with the learning-domain
|
|
323
|
+
// resolver (issue #806) so both derive the category identically.
|
|
321
324
|
if (typeStr === 'jobs') {
|
|
322
|
-
|
|
323
|
-
// Or: jobs/category/name.md
|
|
324
|
-
let category;
|
|
325
|
-
if (parts[1] === 'ai-employee' || parts[1] === 'ai-manager' || parts[1] === 'personalized-employee') {
|
|
326
|
-
category = parts[2]; // e.g. jobs/ai-employee/product-building/job.md -> product-building
|
|
327
|
-
}
|
|
328
|
-
else if (parts.length > 2) {
|
|
329
|
-
category = parts[1]; // e.g. jobs/legal/job.md -> legal
|
|
330
|
-
}
|
|
325
|
+
const category = (0, learning_domains_js_1.jobCategoryFromRegistryPath)(cleanPath) ?? undefined;
|
|
331
326
|
return { type: 'job', name, category };
|
|
332
327
|
}
|
|
333
328
|
// Match rule files (already defined in UsageEventType)
|