fraim-hub 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.
@@ -22,14 +22,27 @@ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
22
22
  // - <project>/fraim/ai-employee/jobs/<category>/ - synced baseline
23
23
  // - <project>/fraim/personalized-employee/jobs/<category>/ - taught/customized override
24
24
  // Only the personalized-employee layer is "personalized" (issue #566).
25
+ // Issue #1002: the machine-level layers are added here so a capability authored
26
+ // at the manager level is DISCOVERABLE, not merely resolvable by exact name.
27
+ // Order matters and mirrors LocalRegistryResolver.resolveFile precedence
28
+ // inverted, because later entries win on a {categoryId, jobId} collision:
29
+ // synced baseline < company cache < manager synced cache < manager local < project
30
+ // A job that resolves from the manager level must therefore also DISPLAY as the
31
+ // manager's, otherwise the rail would show a baseline job the runtime never runs.
25
32
  const EMPLOYEE_JOB_LAYERS = [
26
33
  { segments: ['ai-employee', 'jobs'], personalized: false },
27
- { segments: ['personalized-employee', 'jobs'], personalized: true },
34
+ { base: 'user', segments: ['org', 'jobs'], personalized: true, scope: 'org' },
35
+ { base: 'user', segments: ['manager', 'jobs'], personalized: true, scope: 'manager' },
36
+ { base: 'user', segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'manager' },
37
+ { segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'project' },
28
38
  ];
29
39
  // Manager templates use the matching layer model.
30
40
  const MANAGER_JOB_LAYERS = [
31
41
  { segments: ['ai-manager', 'jobs'], personalized: false },
32
- { segments: ['personalized-employee', 'manager-jobs'], personalized: true },
42
+ { base: 'user', segments: ['org', 'manager-jobs'], personalized: true, scope: 'org' },
43
+ { base: 'user', segments: ['manager', 'manager-jobs'], personalized: true, scope: 'manager' },
44
+ { base: 'user', segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'manager' },
45
+ { segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'project' },
33
46
  ];
34
47
  const REGISTRY_EMPLOYEE_JOB_LAYERS = [
35
48
  { base: 'project', segments: ['registry', 'jobs', 'ai-employee'], personalized: false },
@@ -93,7 +106,18 @@ function readMarkdownFileNames(dirPath) {
93
106
  .map((entry) => entry.name)
94
107
  .sort((a, b) => a.localeCompare(b));
95
108
  }
96
- function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized) {
109
+ /**
110
+ * Issue #1002: a stub that lives under the machine-level FRAIM home is displayed
111
+ * as `~/.fraim/...` rather than as a long `../../..` walk out of the project,
112
+ * which is what path.relative would otherwise produce for a user-level layer.
113
+ */
114
+ function stubDisplayPath(filePath, projectPath, userLevel) {
115
+ if (!userLevel)
116
+ return toPosix(path_1.default.relative(projectPath, filePath));
117
+ const rel = toPosix(path_1.default.relative((0, project_fraim_paths_1.getUserFraimDirPath)(), filePath));
118
+ return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
119
+ }
120
+ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope, userLevel) {
97
121
  const content = fs_1.default.readFileSync(filePath, 'utf8');
98
122
  const fileName = path_1.default.basename(filePath, '.md');
99
123
  const frontmatter = readJobFrontmatter(filePath);
@@ -107,11 +131,12 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
107
131
  categoryLabel,
108
132
  intent,
109
133
  outcome,
110
- stubPath: toPosix(path_1.default.relative(projectPath, filePath)),
134
+ stubPath: stubDisplayPath(filePath, projectPath, userLevel),
111
135
  personalized: !!personalized,
136
+ ...(scope ? { scope } : {}),
112
137
  };
113
138
  }
114
- function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
139
+ function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, userLevel) {
115
140
  const content = fs_1.default.readFileSync(filePath, 'utf8');
116
141
  const fileName = path_1.default.basename(filePath, '.md');
117
142
  const frontmatter = readJobFrontmatter(filePath);
@@ -123,7 +148,8 @@ function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
123
148
  groupId,
124
149
  groupLabel,
125
150
  intent,
126
- stubPath: toPosix(path_1.default.relative(projectPath, filePath)),
151
+ stubPath: stubDisplayPath(filePath, projectPath, userLevel),
152
+ ...(scope ? { scope } : {}),
127
153
  };
128
154
  }
129
155
  function summarizeProject(projectPath) {
@@ -163,6 +189,8 @@ function summarizeProject(projectPath) {
163
189
  function resolveLayerRoot(projectPath, layer) {
164
190
  if (layer.base === 'project')
165
191
  return path_1.default.join(projectPath, ...layer.segments);
192
+ if (layer.base === 'user')
193
+ return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), ...layer.segments);
166
194
  return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
167
195
  }
168
196
  function discoverLayers(projectPath, layers) {
@@ -175,6 +203,8 @@ function discoverLayers(projectPath, layers) {
175
203
  categoryId: categoryName,
176
204
  categoryDir: path_1.default.join(layerRoot, categoryName),
177
205
  personalized: layer.personalized,
206
+ scope: layer.scope,
207
+ userLevel: layer.base === 'user',
178
208
  });
179
209
  }
180
210
  }
@@ -193,7 +223,7 @@ function discoverEmployeeJobs(projectPath, options = {}) {
193
223
  const categoryLabel = humanizeName(layer.categoryId);
194
224
  for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
195
225
  const filePath = path_1.default.join(layer.categoryDir, fileName);
196
- const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized);
226
+ const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.userLevel);
197
227
  // Later layers override earlier layers on {category, jobId} collision —
198
228
  // personalized-employee wins over the synced ai-employee baseline. The
199
229
  // winning job carries its own layer's `personalized` flag (issue #566).
@@ -219,7 +249,7 @@ function discoverManagerTemplates(projectPath, options = {}) {
219
249
  const groupLabel = humanizeName(layer.categoryId);
220
250
  for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
221
251
  const filePath = path_1.default.join(layer.categoryDir, fileName);
222
- const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath);
252
+ const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.userLevel);
223
253
  templatesByKey.set(`${template.groupId}::${template.id}`, template);
224
254
  }
225
255
  }
@@ -17,63 +17,129 @@ exports.buildCustomEmployeePersona = buildCustomEmployeePersona;
17
17
  // evaluatePersonaAccess) must never call resolveEmployee.
18
18
  const fs_1 = __importDefault(require("fs"));
19
19
  const path_1 = __importDefault(require("path"));
20
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
20
21
  const persona_hiring_1 = require("../config/persona-hiring");
21
22
  const EMPLOYEES_DIR_REL = path_1.default.join('fraim', 'personalized-employee', 'employees');
22
23
  function employeesDir(projectDir) {
23
24
  return path_1.default.join(projectDir, EMPLOYEES_DIR_REL);
24
25
  }
25
- function slugFor(key) {
26
- // key is 'custom:<slug>' file is named '<slug>.json'
27
- return key.replace(/^custom:/, '');
28
- }
29
- function safeSlug(key) {
30
- // Reject keys whose slug component contains path separators or traversal sequences.
31
- const slug = slugFor(key);
32
- if (!slug || /[/\\]|\.\./.test(slug))
33
- return null;
34
- return slug;
35
- }
36
- function filePath(projectDir, key) {
37
- const slug = safeSlug(key);
38
- if (!slug)
39
- return null;
40
- return path_1.default.join(employeesDir(projectDir), `${slug}.json`);
26
+ // Issue #1002: the manager level is the default home for a custom employee, so
27
+ // an employee the manager created is available in every project on the machine.
28
+ // Two roots, matching capability precedence: the writable home wins over the
29
+ // synced cache, so a just-created employee appears before any publish or sync.
30
+ function managerEmployeesDirs() {
31
+ return {
32
+ local: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'employees'),
33
+ cache: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'manager', 'employees'),
34
+ };
41
35
  }
42
- function readCustomEmployees(projectDir) {
43
- const dir = employeesDir(projectDir);
36
+ function readEmployeesFromDir(dir, scope) {
44
37
  if (!fs_1.default.existsSync(dir))
45
38
  return [];
46
- const results = [];
47
- for (const file of fs_1.default.readdirSync(dir)) {
39
+ let names;
40
+ try {
41
+ names = fs_1.default.readdirSync(dir);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const out = [];
47
+ for (const file of names) {
48
48
  if (!file.endsWith('.json'))
49
49
  continue;
50
50
  try {
51
- const raw = fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8');
52
- const parsed = JSON.parse(raw);
51
+ const parsed = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8'));
53
52
  if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
54
- results.push(parsed);
53
+ // The record's own scope is authoritative when present; otherwise the
54
+ // directory it was found in names its level. A record written before this
55
+ // change carries scope 'project' and is found in the project dir, so both
56
+ // agree and nothing is reinterpreted.
57
+ out.push({ ...parsed, scope: parsed.scope ?? scope });
55
58
  }
56
59
  }
57
60
  catch {
58
61
  // silently skip malformed files
59
62
  }
60
63
  }
61
- return results;
64
+ return out;
65
+ }
66
+ function slugFor(key) {
67
+ // key is 'custom:<slug>' — file is named '<slug>.json'
68
+ return key.replace(/^custom:/, '');
62
69
  }
70
+ function safeSlug(key) {
71
+ // Reject keys whose slug component contains path separators or traversal sequences.
72
+ const slug = slugFor(key);
73
+ if (!slug || /[/\\]|\.\./.test(slug))
74
+ return null;
75
+ return slug;
76
+ }
77
+ /**
78
+ * Read every custom employee visible from this project, across levels.
79
+ *
80
+ * Issue #1002: precedence matches capability resolution. The project copy wins
81
+ * on a key collision, then the manager's writable home, then the synced manager
82
+ * cache. Reading the manager level here is what makes the manager-level default
83
+ * usable: without it an employee created at the manager level would exist on
84
+ * disk and appear in no roster.
85
+ */
86
+ function readCustomEmployees(projectDir) {
87
+ const dirs = managerEmployeesDirs();
88
+ const byKey = new Map();
89
+ // Lowest precedence first, so a later write overwrites on key collision.
90
+ for (const emp of readEmployeesFromDir(dirs.cache, 'manager'))
91
+ byKey.set(emp.key, emp);
92
+ for (const emp of readEmployeesFromDir(dirs.local, 'manager'))
93
+ byKey.set(emp.key, emp);
94
+ for (const emp of readEmployeesFromDir(employeesDir(projectDir), 'project'))
95
+ byKey.set(emp.key, emp);
96
+ return [...byKey.values()];
97
+ }
98
+ /**
99
+ * Write a custom employee at its declared level.
100
+ *
101
+ * Issue #1002 R1: the manager level is the default, so a record with no scope,
102
+ * or scope 'manager', is written to the manager's writable home and is available
103
+ * in every project. Only scope 'project' writes into the repository.
104
+ */
63
105
  function writeCustomEmployee(projectDir, employee) {
64
- const fp = filePath(projectDir, employee.key);
65
- if (!fp)
66
- throw new Error(`Invalid employee key: ${employee.key}`);
67
- const dir = employeesDir(projectDir);
106
+ const scope = employee.scope ?? 'manager';
107
+ const record = { ...employee, scope };
108
+ const slug = safeSlug(record.key);
109
+ if (!slug)
110
+ throw new Error(`Invalid employee key: ${record.key}`);
111
+ const dir = scope === 'project' ? employeesDir(projectDir) : managerEmployeesDirs().local;
68
112
  fs_1.default.mkdirSync(dir, { recursive: true });
69
- fs_1.default.writeFileSync(fp, JSON.stringify(employee, null, 2), 'utf8');
113
+ fs_1.default.writeFileSync(path_1.default.join(dir, `${slug}.json`), JSON.stringify(record, null, 2), 'utf8');
70
114
  }
115
+ /**
116
+ * Delete a custom employee wherever it lives. Issue #1002: a manager-level
117
+ * employee is not in the project directory, so deleting only there would report
118
+ * success while leaving the record live in every other project.
119
+ */
71
120
  function deleteCustomEmployee(projectDir, key) {
72
- const fp = filePath(projectDir, key);
73
- if (!fp || !fs_1.default.existsSync(fp))
121
+ const slug = safeSlug(key);
122
+ if (!slug)
74
123
  return false;
75
- fs_1.default.unlinkSync(fp);
76
- return true;
124
+ const dirs = managerEmployeesDirs();
125
+ const candidates = [
126
+ path_1.default.join(employeesDir(projectDir), `${slug}.json`),
127
+ path_1.default.join(dirs.local, `${slug}.json`),
128
+ path_1.default.join(dirs.cache, `${slug}.json`),
129
+ ];
130
+ let removed = false;
131
+ for (const fp of candidates) {
132
+ if (!fs_1.default.existsSync(fp))
133
+ continue;
134
+ try {
135
+ fs_1.default.unlinkSync(fp);
136
+ removed = true;
137
+ }
138
+ catch {
139
+ // keep going, and report whether anything was removed
140
+ }
141
+ }
142
+ return removed;
77
143
  }
78
144
  function slugifyDisplayName(displayName, existingKeys = []) {
79
145
  const base = displayName
@@ -3188,8 +3188,12 @@ class AiHubServer {
3188
3188
  computeBrain(projectPath, jobCount, userEmail) {
3189
3189
  const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
3190
3190
  if (!userEmail) {
3191
+ // Issue #1002 R11: reverseMentoring is individual-keyed like manager and
3192
+ // project, so it is zeroed on the same condition. Missing it would leak a
3193
+ // count for a user whose identity was never resolved.
3191
3194
  learnings.manager = 0;
3192
3195
  learnings.project = 0;
3196
+ learnings.reverseMentoring = 0;
3193
3197
  }
3194
3198
  return {
3195
3199
  learnings,