fraim 2.0.208 → 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.
@@ -56,17 +56,20 @@ function buildDeferredToolBootstrapSection(profile) {
56
56
  function buildFraimInvocationBody(profile = 'none') {
57
57
  return `Follow this process:
58
58
 
59
- ${buildDeferredToolBootstrapSection(profile)}1. **If the user did not specify a FRAIM job or topic**:
59
+ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
60
+ Use this process only when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job. For ordinary requests, answer or work normally; do not scan FRAIM stubs first.
61
+
62
+ 2. **If the user did not specify a FRAIM job or topic after activation**:
60
63
  If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
61
64
 
62
- 2. **Find the match**:
63
- Match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no job matches, try a likely FRAIM skill with \`get_fraim_file({ path: "skills/<likely-category>/<argument>.md" })\` and confirm the match with the user.
65
+ 3. **Find the match**:
66
+ If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification. Do not pick the nearest catalog job.
64
67
 
65
- 3. **Load the full content**:
68
+ 4. **Load the full content**:
66
69
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
67
70
  - For skills, use the content returned by \`get_fraim_file(...)\`.
68
71
 
69
- 4. **Execute**:
72
+ 5. **Execute**:
70
73
  - For jobs, follow the phased instructions and use \`seekMentoring\` when the job requires phase transitions.
71
74
  - For skills, apply the skill steps directly to the user's current context.
72
75
  `;
@@ -96,18 +99,18 @@ ${buildFraimInvocationBody('generic-tool-discovery')}
96
99
  `;
97
100
  }
98
101
  function buildCodexSkillContent() {
99
- return `# FRAIM
100
-
102
+ return `# FRAIM
103
+
101
104
  ${buildFraimInvocationBody('codex-tool-search')}`;
102
105
  }
103
106
  function buildGrokSkillContent() {
104
- return `# FRAIM
105
-
107
+ return `# FRAIM
108
+
106
109
  ${buildFraimInvocationBody('generic-tool-discovery')}`;
107
110
  }
108
111
  function buildWindsurfCommandContent() {
109
- return `# FRAIM
110
-
112
+ return `# FRAIM
113
+
111
114
  ${buildFraimInvocationBody('generic-tool-discovery')}`;
112
115
  }
113
116
  function buildKiroCommandContent() {
@@ -69,8 +69,12 @@ This repository uses FRAIM.
69
69
  - Skills under \`${employeeSkillsPath}/\` are reusable capabilities that jobs compose.
70
70
  - Rules under \`${employeeRulesPath}/\` are always-on constraints and conventions.
71
71
  - Repo-specific overrides and learning artifacts live under \`${personalizedRootPath}/\` and take precedence over synced baseline content.
72
- - Before acting on any user request, scan the job stubs under \`${employeeJobsPath}/\` and \`${managerJobsPath}/\` to identify the most appropriate job. Read stub filenames and their Intent/Outcome sections to match the request to the right job.
72
+ - Use FRAIM when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job.
73
+ - For ordinary requests, answer or work normally. Do not scan FRAIM stubs first.
74
+ - If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly.
75
+ - When FRAIM routing is active but the job is not exact, scan the job stubs under \`${employeeJobsPath}/\` and \`${managerJobsPath}/\` to identify the most appropriate job. Read stub filenames and their Intent/Outcome sections before using catalog tools.
73
76
  - Once you identify the relevant job, call \`get_fraim_job({ job: "<job-name>" })\` to get the full phased instructions.
77
+ - If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification.
74
78
  - For deeper capability detail, call \`get_fraim_file({ path: "skills/<category>/<skill-name>.md" })\` or \`get_fraim_file({ path: "rules/<category>/<rule-name>.md" })\`.
75
79
  - Read \`${projectRulesPath}\` if it exists before doing work.
76
80
  - When users ask for next step recommendations, use recommend-next-job skill under \`${employeeSkillsPath}/\` to gather context before suggesting jobs.
@@ -91,7 +95,11 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
91
95
  - FRAIM skills are reusable capabilities jobs compose.
92
96
  - FRAIM rules are always-on constraints and conventions.
93
97
  - Repo-specific overrides and learnings live under \`${personalizedRootPath}/\`.
94
- - Use the stubs to identify which job to invoke before fetching full content with FRAIM MCP tools.
98
+ - Use FRAIM when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job.
99
+ - For ordinary requests, answer or work normally. Do not scan FRAIM stubs first.
100
+ - If the user names an exact FRAIM job, fetch that full job directly with FRAIM MCP tools.
101
+ - When FRAIM routing is active but the job is not exact, use local stubs to identify which job to invoke before fetching full content with FRAIM MCP tools.
102
+ - If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification.
95
103
  - **Job stubs are for discovery only.** Never execute a job from stub content - always call \`get_fraim_job({ job: "<job-name>" })\` first.
96
104
  `);
97
105
  const fraimReadme = `# FRAIM Catalog
@@ -104,7 +112,7 @@ This directory is the repository-visible FRAIM surface.
104
112
  - \`ai-employee/rules/\`: rule stubs
105
113
  - \`personalized-employee/\`: repo-specific overrides and learnings
106
114
 
107
- Use the stubs here to discover which FRAIM job, skill, or rule is relevant, then load the full content through FRAIM MCP tools.
115
+ When FRAIM routing is active and no exact FRAIM job is named, use the stubs here to discover which FRAIM job, skill, or rule is relevant, then load the full content through FRAIM MCP tools. If an exact FRAIM job is named, load it directly. For ordinary requests, do not scan this catalog first.
108
116
  `;
109
117
  const vscodePrompt = `# FRAIM
110
118
 
@@ -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
- // L2 org learnings resolve repo-local first, then the synced org cache (#563).
382
- const l2Mistake = resolveOrgLearningFile(roots.repoLearningsBase, 'org-mistake-patterns.md');
383
- const l2Pref = resolveOrgLearningFile(roots.repoLearningsBase, 'org-preferences.md');
384
- const l2Coach = resolveOrgLearningFile(roots.repoLearningsBase, 'org-manager-coaching.md');
385
- const l2Validated = resolveOrgLearningFile(roots.repoLearningsBase, 'org-validated-patterns.md');
386
- const l2MistakePresent = l2Mistake.present;
387
- const l2PrefPresent = l2Pref.present;
388
- const l2CoachPresent = l2Coach.present;
389
- const l2ValidatedPresent = l2Validated.present;
390
- const l2MistakeStats = l2MistakePresent ? scanMistakePatternFile(l2Mistake.path, threshold, 'mistake-patterns') : null;
391
- const l2ValidatedStats = l2ValidatedPresent ? scanMistakePatternFile(l2Validated.path, threshold, 'validated-patterns') : null;
392
- const l1Mistake = resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, `${resolvedUserId}-mistake-patterns.md`);
393
- const l1Pref = resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, `${resolvedUserId}-preferences.md`);
394
- const l1Coach = resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, `${resolvedUserId}-manager-coaching.md`);
395
- const l1Validated = resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, `${resolvedUserId}-validated-patterns.md`);
396
- const l1MistakeStats = l1Mistake.present ? scanMistakePatternFile(l1Mistake.path, threshold, 'mistake-patterns') : null;
397
- const l1ValidatedStats = l1Validated.present ? scanMistakePatternFile(l1Validated.path, threshold, 'validated-patterns') : null;
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 = l2MistakePresent || l2PrefPresent || l2CoachPresent || l2ValidatedPresent;
402
- const hasL1 = l1Mistake.present || l1Pref.present || l1Coach.present || l1Validated.present;
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
- if (l2MistakePresent)
412
- section += `\`${l2Mistake.displayPath}\` (entries above score threshold)\n`;
413
- if (l2PrefPresent)
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
- if (l1Pref.present)
428
- section += `\`${l1Pref.displayPath}\` (all entries)\n`;
429
- if (l1Coach.present)
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 (l1Coach.present || l2CoachPresent) {
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 (l1Coach.present || l2CoachPresent) {
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 { organization, manager, project, rawSignals };
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 f = `org-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
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 f = `${resolvedUserId}-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
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 learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
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 learningSection = (0, learning_context_builder_js_1.buildLearningContextSection)(workspaceRoot, userEmail, true);
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
- // Structure: jobs/ai-employee/category/name.md
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)
@@ -50,15 +50,17 @@ const FRAIM_PROMPT_TEXT = `You are running in FRAIM mode. Follow this process:
50
50
 
51
51
  0. **Preload deferred FRAIM tools when needed**: If FRAIM MCP tools are unavailable because this host lazily loads deferred tool schemas, call ToolSearch once to load fraim_connect, list_fraim_jobs, get_fraim_job, get_fraim_file, seekMentoring. Do the preload as one batched discovery step, not one search per tool.
52
52
 
53
- 1. **If the user did not specify a FRAIM job or topic**: If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect fraim/personalized-employee/jobs/ for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call list_fraim_jobs() to view the full catalog, including any proxy-discoverable personalized jobs.
53
+ 1. **Confirm FRAIM activation**: Use FRAIM only when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job. For ordinary requests, answer or work normally; do not scan FRAIM stubs first.
54
54
 
55
- 2. **Find the match**: Match the user's request to a FRAIM job from the local stub catalog, fraim/personalized-employee/jobs/, or the full list_fraim_jobs() response. If no job matches, try a likely FRAIM skill with get_fraim_file({ path: "skills/<likely-category>/<argument>.md" }) and confirm the match with the user.
55
+ 2. **If the user did not specify a FRAIM job or topic after activation**: If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect fraim/personalized-employee/jobs/ for local overrides or repo-specific jobs. If local files are missing, you cannot inspect workspace files, the user asks for available jobs, or recommendations require full catalog context, call list_fraim_jobs() to view the catalog.
56
56
 
57
- 3. **Load the full content**:
57
+ 3. **Find the match**: If the user names an exact FRAIM job, call get_fraim_job({ job: "<job-name>" }) directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, fraim/personalized-employee/jobs/, or the full list_fraim_jobs() response. If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification. Do not pick the nearest catalog job.
58
+
59
+ 4. **Load the full content**:
58
60
  - For jobs, call get_fraim_job({ job: "<matched-job-name>" }).
59
61
  - For skills, use the content returned by get_fraim_file(...).
60
62
 
61
- 4. **Execute**:
63
+ 5. **Execute**:
62
64
  - For jobs, follow the phased instructions and use seekMentoring when the job requires phase transitions.
63
65
  - For skills, apply the skill steps directly to the user's current context.`;
64
66
  exports.DEFAULT_LAUNCH_PHRASE_MAPPINGS = {
@@ -183,7 +185,7 @@ class McpService {
183
185
  prompts: [
184
186
  {
185
187
  name: 'fraim',
186
- description: 'Activate a FRAIM job or skill. Scans the job catalog, matches your request, loads full phased instructions, and executes.',
188
+ description: 'Activate a FRAIM job or skill. Use this prompt only for explicit FRAIM work, job recommendations, or known FRAIM job execution.',
187
189
  arguments: [
188
190
  {
189
191
  name: 'task',
@@ -547,10 +549,13 @@ class McpService {
547
549
  `FRAIM session active. Session ID: ${sessionId}`,
548
550
  '',
549
551
  '## Start Here',
550
- '- If local FRAIM job stubs are present under `fraim/ai-employee/jobs/` or `fraim/ai-manager/jobs/`, inspect those first to choose the best job.',
551
- '- Also inspect `fraim/personalized-employee/jobs/` for local job overrides or repo-specific jobs.',
552
- '- If the user already named a job, call `get_fraim_job({ job: "<job-name>" })`.',
553
- '- If local stubs are missing or you cannot inspect workspace files, call `list_fraim_jobs()` to view the full catalog, then pick the best match yourself.',
552
+ '- Use FRAIM when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job.',
553
+ '- For ordinary requests, answer or work normally. Do not scan FRAIM stubs first.',
554
+ '- If the user already named a job, call `get_fraim_job({ job: "<job-name>" })` directly.',
555
+ '- If routing is active and local FRAIM job stubs are present under `fraim/ai-employee/jobs/` or `fraim/ai-manager/jobs/`, inspect those first to choose the best job.',
556
+ '- Also inspect `fraim/personalized-employee/jobs/` for local job overrides or repo-specific jobs when routing is active.',
557
+ '- If local stubs are missing, you cannot inspect workspace files, or the user asks for available jobs, call `list_fraim_jobs()` to view the full catalog.',
558
+ '- If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification.',
554
559
  '- Use `get_fraim_file(...)` only for the specific skills or rules needed for the current task.',
555
560
  '- Once a phased job starts, use `seekMentoring` for phase-specific instructions and transitions.'
556
561
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.208",
3
+ "version": "2.0.210",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {