fraim 2.0.342 → 2.0.344

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.
Files changed (30) hide show
  1. package/dist/registry/scripts/artifact-paths.js +108 -0
  2. package/dist/registry/scripts/learning/completion-state.js +205 -5
  3. package/dist/registry/scripts/learning-usage/local-runtime.js +217 -0
  4. package/dist/src/cli/commands/init-project.js +2 -12
  5. package/dist/src/cli/commands/sync.js +36 -162
  6. package/dist/src/cli/setup/ide-invocation-surfaces.js +3 -3
  7. package/dist/src/cli/setup/user-level-sync.js +3 -3
  8. package/dist/src/cli/utils/agent-adapters.js +12 -12
  9. package/dist/src/cli/utils/fraim-gitignore.js +28 -3
  10. package/dist/src/cli/utils/pack-home.js +10 -0
  11. package/dist/src/cli/utils/project-catalog-cleanup.js +62 -0
  12. package/dist/src/cli/utils/remote-sync.js +26 -2
  13. package/dist/src/config/persona-hiring.js +7 -1
  14. package/dist/src/core/ai-mentor.js +42 -11
  15. package/dist/src/core/config-loader.js +4 -0
  16. package/dist/src/core/fraim-config-schema.generated.js +22 -0
  17. package/dist/src/core/handoff-contracts.js +8 -3
  18. package/dist/src/core/utils/local-registry-resolver.js +33 -57
  19. package/dist/src/fraim/db-service.js +127 -118
  20. package/dist/src/local-mcp-server/artifact-retention-cleanup.js +79 -6
  21. package/dist/src/local-mcp-server/learning-context-builder.js +101 -105
  22. package/dist/src/local-mcp-server/learning-source-record.js +122 -0
  23. package/dist/src/local-mcp-server/learning-usage-attestation.js +66 -56
  24. package/dist/src/local-mcp-server/learning-usage-command.js +5 -4
  25. package/dist/src/local-mcp-server/stdio-server.js +10 -7
  26. package/dist/src/local-mcp-server/usage-collector.js +2 -0
  27. package/dist/src/mcp/tool-schemas.js +5 -1
  28. package/dist/src/services/mcp-service.js +6 -4
  29. package/dist/src/services/usage-analytics-service.js +1 -0
  30. package/package.json +3 -1
@@ -45,10 +45,8 @@ const config_loader_1 = require("../../core/config-loader");
45
45
  const version_utils_1 = require("../utils/version-utils");
46
46
  const script_sync_utils_1 = require("../utils/script-sync-utils");
47
47
  const git_utils_1 = require("../../core/utils/git-utils");
48
- const agent_adapters_1 = require("../utils/agent-adapters");
48
+ const project_catalog_cleanup_1 = require("../utils/project-catalog-cleanup");
49
49
  const fraim_gitignore_1 = require("../utils/fraim-gitignore");
50
- const user_config_1 = require("../utils/user-config");
51
- const ide_detector_1 = require("../setup/ide-detector");
52
50
  const remote_sync_1 = require("../utils/remote-sync");
53
51
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
54
52
  function resolveExplicitLocalSyncUrl() {
@@ -113,66 +111,6 @@ function removeLegacyVersionFromConfig(fraimDir) {
113
111
  console.warn(chalk_1.default.yellow('Could not remove legacy version from config.json.'));
114
112
  }
115
113
  }
116
- function resolveAllowedConfigTypes() {
117
- const persisted = (0, user_config_1.getInstalledIdes)();
118
- const liveDetected = [
119
- ...(0, ide_detector_1.detectInstalledIDEs)(),
120
- ...(0, ide_detector_1.detectInstalledIDEs)('cli-runnable')
121
- ].map((ide) => (0, ide_detector_1.getAdapterConfigType)(ide));
122
- const liveSet = [...new Set(liveDetected)];
123
- if (!persisted) {
124
- return liveSet.length > 0 ? liveSet : null;
125
- }
126
- const merged = [...new Set([...persisted, ...liveSet])];
127
- return merged.length > 0 ? merged : null;
128
- }
129
- async function cleanupStaleAdapterFiles(projectRoot, allowedConfigTypes) {
130
- if (!allowedConfigTypes) {
131
- return;
132
- }
133
- (0, project_fraim_paths_1.assertWorkspaceFraimRoot)(projectRoot);
134
- const { execFile } = await Promise.resolve().then(() => __importStar(require('child_process')));
135
- const { promisify } = await Promise.resolve().then(() => __importStar(require('util')));
136
- const execFileAsync = promisify(execFile);
137
- for (const [relPath, configType] of Object.entries((0, agent_adapters_1.getAdapterConfigTypes)())) {
138
- const configTypes = Array.isArray(configType) ? configType : [configType];
139
- if (configTypes.includes('standard') || configTypes.some((type) => allowedConfigTypes.includes(type))) {
140
- continue;
141
- }
142
- const fullPath = path_1.default.join(projectRoot, relPath);
143
- if (!fs_1.default.existsSync(fullPath)) {
144
- continue;
145
- }
146
- try {
147
- const content = fs_1.default.readFileSync(fullPath, 'utf8');
148
- if (!content.includes(remote_sync_1.SYNCED_CONTENT_BANNER_MARKER)) {
149
- continue;
150
- }
151
- }
152
- catch {
153
- continue;
154
- }
155
- try {
156
- await execFileAsync('git', ['rm', '--cached', relPath], { cwd: projectRoot });
157
- }
158
- catch {
159
- // Not tracked or git unavailable — still clean up from disk and gitignore.
160
- }
161
- try {
162
- (0, fraim_gitignore_1.addToFraimGitignore)(projectRoot, relPath);
163
- }
164
- catch {
165
- // Best-effort.
166
- }
167
- try {
168
- fs_1.default.unlinkSync(fullPath);
169
- console.log(chalk_1.default.green(`Removed stale FRAIM adapter file: ${relPath}`));
170
- }
171
- catch {
172
- // Best-effort.
173
- }
174
- }
175
- }
176
114
  function failSync(mode, message) {
177
115
  if (mode === 'throw') {
178
116
  throw new Error(message);
@@ -182,10 +120,13 @@ function failSync(mode, message) {
182
120
  const runSync = async (options) => {
183
121
  const failHard = options.failHard ?? 'exit';
184
122
  const projectRoot = options.projectRoot ? path_1.default.resolve(options.projectRoot) : process.cwd();
123
+ const useLocal = options.local || process.env.FRAIM_LOCAL_SYNC === '1';
185
124
  // ─── Credential resolution (before project detection) ─────────────────────
186
125
  // Order: user-level config → env var → project config (only if in a project)
187
- const remoteUrl = process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
188
- let apiKey = loadUserApiKey() || process.env.FRAIM_API_KEY;
126
+ const remoteUrl = useLocal
127
+ ? resolveExplicitLocalSyncUrl() || `http://localhost:${process.env.FRAIM_MCP_PORT || (0, git_utils_1.getPort)()}`
128
+ : process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
129
+ let apiKey = useLocal ? 'local-dev' : loadUserApiKey() || process.env.FRAIM_API_KEY;
189
130
  const hasProject = (0, project_fraim_paths_1.workspaceFraimExists)(projectRoot);
190
131
  if (!apiKey && hasProject) {
191
132
  const config = (0, config_loader_1.loadFraimConfig)((0, project_fraim_paths_1.getWorkspaceConfigPath)(projectRoot));
@@ -272,60 +213,7 @@ const runSync = async (options) => {
272
213
  }
273
214
  catch { /* reporting must never fail a sync */ }
274
215
  };
275
- // ─── Local sync shortcut (dev mode) ───────────────────────────────────────
276
- const useLocal = options.local || process.env.FRAIM_LOCAL_SYNC === '1';
277
- if (useLocal) {
278
- console.log(chalk_1.default.blue('Syncing FRAIM jobs from local server...'));
279
- const localPort = process.env.FRAIM_MCP_PORT ? parseInt(process.env.FRAIM_MCP_PORT) : (0, git_utils_1.getPort)();
280
- const localUrl = resolveExplicitLocalSyncUrl() || `http://localhost:${localPort}`;
281
- const { syncFromRemote } = await Promise.resolve().then(() => __importStar(require('../utils/remote-sync')));
282
- if (!hasProject) {
283
- console.error(chalk_1.default.red('Local sync requires a FRAIM project directory (fraim/ must exist).'));
284
- failSync(failHard, 'Local sync requires a project directory.');
285
- }
286
- const localRegistryFiles = await (0, remote_sync_1.fetchRegistryFiles)(localUrl, 'local-dev');
287
- const result = await syncFromRemote({
288
- remoteUrl: localUrl,
289
- apiKey: 'local-dev',
290
- projectRoot,
291
- skipUpdates: true,
292
- registryFiles: localRegistryFiles
293
- });
294
- if (result.success) {
295
- // Local-dev sync bypasses Layer 1 (machine-level sync) below, but scripts,
296
- // docs, and job stubs still need to land in ~/.fraim/ or the reported counts
297
- // are a lie.
298
- await (0, remote_sync_1.syncScriptsToUserDir)(localRegistryFiles);
299
- await (0, remote_sync_1.syncDocsToUserDir)(localRegistryFiles);
300
- await (0, remote_sync_1.syncJobsToUserDir)(localRegistryFiles);
301
- const { ensureUserLevelDependencies: ensureLocalUserLevelDependencies } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
302
- ensureLocalUserLevelDependencies();
303
- console.log(chalk_1.default.green(`Successfully synced ${result.employeeJobsSynced} ai-employee jobs, ${result.managerJobsSynced} ai-manager jobs, ${result.skillsSynced} skills, ${result.rulesSynced} rules, ${result.scriptsSynced} scripts, and ${result.docsSynced} docs from local server`));
304
- const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
305
- removeLegacyVersionFromConfig(fraimDir);
306
- writeSyncMetadata('local', localUrl);
307
- const ignoreUpdate = (0, fraim_gitignore_1.ensureFraimSyncedContentLocallyExcluded)(projectRoot);
308
- if (ignoreUpdate.gitInfoExcludeUpdated) {
309
- console.log(chalk_1.default.green('Updated .git/info/exclude FRAIM managed block'));
310
- }
311
- if (ignoreUpdate.gitignoreUpdated) {
312
- console.log(chalk_1.default.green('Removed legacy FRAIM sync block from .gitignore'));
313
- }
314
- const allowedLocalTypes = resolveAllowedConfigTypes();
315
- await cleanupStaleAdapterFiles(projectRoot, allowedLocalTypes);
316
- const localAdapterUpdates = (0, agent_adapters_1.ensureAgentAdapterFiles)(projectRoot, allowedLocalTypes);
317
- if (localAdapterUpdates.length > 0) {
318
- console.log(chalk_1.default.green(`Updated FRAIM agent adapter files: ${localAdapterUpdates.join(', ')}`));
319
- }
320
- await refreshOrgCache(localUrl, 'local-dev');
321
- await refreshManagerCache(localUrl, 'local-dev');
322
- return;
323
- }
324
- console.error(chalk_1.default.red(`Local sync failed: ${result.error}`));
325
- console.error(chalk_1.default.yellow('Make sure the FRAIM MCP server is running locally (npm run dev).'));
326
- failSync(failHard, `Local sync failed: ${result.error}`);
327
- }
328
- // ─── Global install notice ────────────────────────────────────────────────
216
+ // Local and remote sources share the same machine-level writers.
329
217
  const isNpx = process.env.npm_config_prefix === undefined || process.env.npm_lifecycle_event === 'npx';
330
218
  const isGlobal = !isNpx && (process.env.npm_config_global === 'true' || process.env.npm_config_prefix);
331
219
  if (isGlobal && !options.skipUpdates) {
@@ -337,7 +225,7 @@ const runSync = async (options) => {
337
225
  // LAYER 1 — Machine-level sync (always runs)
338
226
  // ═══════════════════════════════════════════════════════════════════════════
339
227
  console.log(chalk_1.default.blue('Syncing machine-level FRAIM content (~/.fraim/)...'));
340
- // 1a. Fetch registry files (single API call shared with Layer 2)
228
+ // 1a. Fetch registry files once for every machine-level writer
341
229
  let registryFiles = [];
342
230
  try {
343
231
  registryFiles = await (0, remote_sync_1.fetchRegistryFiles)(remoteUrl, apiKey);
@@ -370,6 +258,10 @@ const runSync = async (options) => {
370
258
  if (machineEmployeeJobsSynced > 0 || machineManagerJobsSynced > 0) {
371
259
  console.log(chalk_1.default.green(` Synced ${machineEmployeeJobsSynced} ai-employee jobs and ${machineManagerJobsSynced} ai-manager jobs to ~/.fraim/`));
372
260
  }
261
+ const { skillsSynced, rulesSynced } = await (0, remote_sync_1.syncSkillsAndRulesToUserDir)(registryFiles);
262
+ if (skillsSynced > 0 || rulesSynced > 0) {
263
+ console.log(chalk_1.default.green(` Synced ${skillsSynced} skills and ${rulesSynced} rules to ~/.fraim/`));
264
+ }
373
265
  // 1c. Refresh org home
374
266
  await refreshOrgCache(remoteUrl, apiKey);
375
267
  // 1d. Refresh manager home
@@ -389,45 +281,34 @@ const runSync = async (options) => {
389
281
  // 1f. Ensure user-level directories and runtime dependencies (Issue #1508:
390
282
  // synced scripts like the DOCX review layer require npm deps such as
391
283
  // adm-zip; a plain sync must install them, not just create directories)
392
- const { ensureUserLevelDirectories, ensureUserLevelDependencies } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
393
- ensureUserLevelDirectories();
394
- ensureUserLevelDependencies();
284
+ const { syncUserLevelArtifacts } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
285
+ await syncUserLevelArtifacts();
395
286
  // 1g. Write sync metadata
396
- writeSyncMetadata('remote', remoteUrl);
287
+ writeSyncMetadata(useLocal ? 'local' : 'remote', remoteUrl);
397
288
  console.log(chalk_1.default.green('✅ Machine-level sync complete.'));
398
289
  // ═══════════════════════════════════════════════════════════════════════════
399
- // LAYER 2 — Project-level sync (conditional)
290
+ // Project maintenance only: no catalog or adapter writes.
400
291
  // ═══════════════════════════════════════════════════════════════════════════
401
- if (options.global) {
402
- console.log(chalk_1.default.gray('--global: skipping project-level sync.'));
403
- return;
404
- }
405
- if (!hasProject) {
406
- console.log(chalk_1.default.cyan('No FRAIM project detected at cwd. Machine-level sync complete.'));
407
- console.log(chalk_1.default.cyan('Run from a project folder or pass --project-root to sync project stubs.'));
292
+ if (options.global || !hasProject)
408
293
  return;
409
- }
410
- console.log(chalk_1.default.blue('Syncing project-level FRAIM content (fraim/)...'));
411
- const { syncFromRemote } = await Promise.resolve().then(() => __importStar(require('../utils/remote-sync')));
412
- const config = (0, config_loader_1.loadFraimConfig)((0, project_fraim_paths_1.getWorkspaceConfigPath)(projectRoot));
413
- const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
414
- const result = await syncFromRemote({
415
- remoteUrl: config.remoteUrl || remoteUrl,
416
- apiKey,
417
- projectRoot,
418
- skipUpdates: options.skipUpdates || false,
419
- registryFiles
420
- });
421
- if (!result.success) {
422
- console.error(chalk_1.default.red(`Project sync failed: ${result.error}`));
423
- if (process.env.TEST_MODE === 'true') {
424
- console.log(chalk_1.default.yellow('TEST_MODE: Continuing without project sync.'));
294
+ if ((0, project_catalog_cleanup_1.hasLegacyProjectCatalogs)(projectRoot)) {
295
+ // An empty/partial response (including TEST_MODE fetch continuation) is
296
+ // not evidence that all generated project content has a replacement.
297
+ const replacementsSynced = machineEmployeeJobsSynced > 0 && machineManagerJobsSynced > 0
298
+ && skillsSynced > 0 && rulesSynced > 0 && docsSynced > 0;
299
+ if (!replacementsSynced) {
300
+ console.warn(chalk_1.default.yellow('Project catalog cleanup skipped: replacement machine catalogs are incomplete. Existing catalogs and exclusions were retained.'));
301
+ return;
302
+ }
303
+ try {
304
+ (0, project_catalog_cleanup_1.removeLegacyProjectCatalogs)(projectRoot);
305
+ }
306
+ catch (error) {
307
+ console.warn(chalk_1.default.yellow(`Project catalog cleanup failed: ${error instanceof Error ? error.message : String(error)}. Remaining catalogs and exclusions were retained; retry sync after resolving the error.`));
425
308
  return;
426
309
  }
427
- failSync(failHard, `Project sync failed: ${result.error}`);
428
310
  }
429
- console.log(chalk_1.default.green(`Successfully synced ${result.employeeJobsSynced} ai-employee jobs, ${result.managerJobsSynced} ai-manager jobs, ${result.skillsSynced} skills, ${result.rulesSynced} rules, and ${result.docsSynced} docs`));
430
- removeLegacyVersionFromConfig(fraimDir);
311
+ removeLegacyVersionFromConfig((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot));
431
312
  const ignoreUpdate = (0, fraim_gitignore_1.ensureFraimSyncedContentLocallyExcluded)(projectRoot);
432
313
  if (ignoreUpdate.gitInfoExcludeUpdated) {
433
314
  console.log(chalk_1.default.green('Updated .git/info/exclude FRAIM managed block'));
@@ -435,21 +316,14 @@ const runSync = async (options) => {
435
316
  if (ignoreUpdate.gitignoreUpdated) {
436
317
  console.log(chalk_1.default.green('Removed legacy FRAIM sync block from .gitignore'));
437
318
  }
438
- const allowedTypes = resolveAllowedConfigTypes();
439
- await cleanupStaleAdapterFiles(projectRoot, allowedTypes);
440
- const adapterUpdates = (0, agent_adapters_1.ensureAgentAdapterFiles)(projectRoot, allowedTypes);
441
- if (adapterUpdates.length > 0) {
442
- console.log(chalk_1.default.green(`Updated FRAIM agent adapter files: ${adapterUpdates.join(', ')}`));
443
- }
444
- console.log(chalk_1.default.green('✅ Project-level sync complete.'));
445
319
  };
446
320
  exports.runSync = runSync;
447
321
  exports.syncCommand = new commander_1.Command('sync')
448
- .description('Sync job stubs from the framework registry')
322
+ .description('Sync machine-level FRAIM catalogs, scripts, docs and agent integration')
449
323
  .option('-f, --force', 'Force sync even if digest matches')
450
324
  .option('--skip-updates', 'Skip checking for CLI updates (legacy)')
451
325
  .option('--local', 'Sync from local development server (port derived from git branch)')
452
- .option('--global', 'Sync machine-level FRAIM content only (~/.fraim/), skip project stubs')
453
- .option('--project-root <path>', 'Explicit project root (enables project-level sync from any cwd)')
454
- .option('--project-adapters', 'Write legacy project-local FRAIM agent adapter files')
326
+ .option('--global', 'Sync machine-level FRAIM content only (~/.fraim/), skip project maintenance')
327
+ .option('--project-root <path>', 'Explicit project root for configuration and local exclusion maintenance')
328
+ .option('--project-adapters', 'Deprecated compatibility option (no effect; use machine-level FRAIM setup)')
455
329
  .action(exports.runSync);
@@ -68,11 +68,11 @@ function buildFraimInvocationBody(profile = 'none') {
68
68
  ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
69
69
  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.
70
70
 
71
- 2. **If the user did not specify a FRAIM job or topic after activation**:
72
- If local FRAIM job stubs are present in the workspace, read that jobs directory's \`INDEX.md\` first (it lists every job's name, one-line intent, and category) and match the request against it; open an individual stub only to confirm a shortlisted candidate, or fall back to scanning the stubs directly if no \`INDEX.md\` is present. Also check \`fraim/personalized-employee/jobs/\` (and its own \`INDEX.md\`, if present) 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.
71
+ 2. **Discover jobs when activation did not name an exact FRAIM job**:
72
+ Read \`~/.fraim/ai-employee/jobs/INDEX.md\` and \`~/.fraim/ai-manager/jobs/INDEX.md\` first (each lists every job's name, one-line intent, and category) and match the request against them; open an individual stub only to confirm a shortlisted candidate, or fall back to scanning stubs in those machine directories if an index is missing or stale. Also check \`fraim/personalized-employee/jobs/INDEX.md\` (or its stubs, if no index is present) for project overrides or repo-specific jobs. Project baseline catalog directories are not required. If machine files are missing or you cannot inspect local files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
- 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, call \`get_fraim_job({ job: "adhoc-prompt" })\` directly and execute it with the user's instructions as the task input — no confirmation question, and do not pick the nearest catalog job.
75
+ 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 machine stub catalog, \`fraim/personalized-employee/jobs/\` (project overrides take precedence), or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, call \`get_fraim_job({ job: "adhoc-prompt" })\` directly and execute it with the user's instructions as the task input — no confirmation question, and do not pick the nearest catalog job.
76
76
 
77
77
  4. **Load the full content**:
78
78
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
@@ -49,9 +49,9 @@ exports.syncUserLevelArtifacts = syncUserLevelArtifacts;
49
49
  * - scripts/ — locally-executed scripts (synced during any fraim sync)
50
50
  * - personalized-employee/ — user-level overrides and learnings
51
51
  *
52
- * Job/skill/rule stubs are NOT synced to user-level. The MCP proxy serves
53
- * those on demand via list_fraim_jobs() and get_fraim_job(). Stubs only exist
54
- * at project-level (fraim/) where CLAUDE.md tells agents to scan them on disk.
52
+ * Sync writes machine-level job/skill/rule discovery stubs separately. These
53
+ * adapters route agents to those catalogs; the MCP proxy serves full executable
54
+ * instructions through get_fraim_job() and get_fraim_file().
55
55
  *
56
56
  * Part of: User-Level FRAIM Artifacts with Local Shadow Semantics
57
57
  */
@@ -52,11 +52,11 @@ function mergeCursorRule(existingContent, managedSection) {
52
52
  return `${ide_invocation_surfaces_1.CURSOR_MDC_FRONTMATTER}\n\n${mergedBody}\n`;
53
53
  }
54
54
  function getAdapterFiles(allowedConfigTypes = null) {
55
- const fraimRoot = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)().replace(/\/$/, '');
56
- const employeeJobsPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('ai-employee/jobs');
57
- const managerJobsPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('ai-manager/jobs');
58
- const employeeSkillsPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('ai-employee/skills');
59
- const employeeRulesPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('ai-employee/rules');
55
+ const fraimRoot = '~/.fraim';
56
+ const employeeJobsPath = `${fraimRoot}/ai-employee/jobs`;
57
+ const managerJobsPath = `${fraimRoot}/ai-manager/jobs`;
58
+ const employeeSkillsPath = `${fraimRoot}/ai-employee/skills`;
59
+ const employeeRulesPath = `${fraimRoot}/ai-employee/rules`;
60
60
  const personalizedRootPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee');
61
61
  const projectRulesPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/rules/project_rules.md');
62
62
  const markdownBody = buildManagedSection(`
@@ -91,7 +91,7 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
91
91
  const copilotBody = buildManagedSection(`
92
92
  ## FRAIM
93
93
 
94
- - Use \`${fraimRoot}/\` as the repository's FRAIM catalog.
94
+ - Use \`${fraimRoot}/\` as the machine-wide FRAIM catalog.
95
95
  - FRAIM jobs are the primary execution units and should be treated like first-class workflows.
96
96
  - FRAIM skills are reusable capabilities jobs compose.
97
97
  - FRAIM rules are always-on constraints and conventions.
@@ -99,19 +99,19 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
99
99
  - 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.
100
100
  - For ordinary requests, answer or work normally. Do not scan FRAIM stubs first.
101
101
  - If the user names an exact FRAIM job, fetch that full job directly with FRAIM MCP tools.
102
- - When FRAIM routing is active but the job is not exact, read the local jobs directory's \`INDEX.md\` first to identify which job to invoke (falling back to the individual stub files if no index is present) before fetching full content with FRAIM MCP tools.
102
+ - When FRAIM routing is active but the job is not exact, read \`${employeeJobsPath}/INDEX.md\` and \`${managerJobsPath}/INDEX.md\` first to identify which job to invoke (falling back to their individual stub files if an index is missing or stale). Also check \`${personalizedRootPath}/jobs/INDEX.md\` or its stubs for project overrides before fetching full content with FRAIM MCP tools.
103
103
  - 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.
104
104
  - **Job stubs are for discovery only.** Never execute a job from stub content - always call \`get_fraim_job({ job: "<job-name>" })\` first.
105
105
  - ${ide_invocation_surfaces_1.FRAIM_MCP_UNAVAILABLE_MANAGER_GUIDANCE}
106
106
  `);
107
107
  const fraimReadme = `# FRAIM Catalog
108
108
 
109
- This directory is the repository-visible FRAIM surface.
109
+ This directory holds project-specific FRAIM content. The shared discovery catalog lives under \`~/.fraim/\`.
110
110
 
111
- - \`ai-employee/jobs/\`: employee job stubs (+ \`INDEX.md\`)
112
- - \`ai-manager/jobs/\`: manager job stubs (+ \`INDEX.md\`)
113
- - \`ai-employee/skills/\`: skill stubs (+ \`INDEX.md\`)
114
- - \`ai-employee/rules/\`: rule stubs
111
+ - \`~/.fraim/ai-employee/jobs/\`: employee job stubs (+ \`INDEX.md\`)
112
+ - \`~/.fraim/ai-manager/jobs/\`: manager job stubs (+ \`INDEX.md\`)
113
+ - \`~/.fraim/ai-employee/skills/\`: skill stubs (+ \`INDEX.md\`)
114
+ - \`~/.fraim/ai-employee/rules/\`: rule stubs
115
115
  - \`personalized-employee/\`: repo-specific overrides and learnings
116
116
 
117
117
  When FRAIM routing is active and no exact FRAIM job is named, read the \`INDEX.md\` in the relevant directory first to discover which FRAIM job, skill, or rule is relevant (falling back to scanning the individual stubs if no index is present), 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.
@@ -7,12 +7,11 @@ exports.ensureFraimSyncedContentLocallyExcluded = exports.removeFraimSyncedConte
7
7
  exports.addToFraimGitignore = addToFraimGitignore;
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
+ const child_process_1 = require("child_process");
11
+ const project_catalog_cleanup_1 = require("./project-catalog-cleanup");
10
12
  exports.FRAIM_SYNC_GITIGNORE_START = '# BEGIN FRAIM SYNCED CONTENT';
11
13
  exports.FRAIM_SYNC_GITIGNORE_END = '# END FRAIM SYNCED CONTENT';
12
14
  exports.FRAIM_SYNC_GITIGNORE_ENTRIES = [
13
- 'fraim/ai-employee/',
14
- 'fraim/ai-manager/',
15
- 'fraim/docs/',
16
15
  'fraim/.sync-metadata.json',
17
16
  ];
18
17
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -57,6 +56,24 @@ function resolveGitInfoExcludePath(projectRoot) {
57
56
  }
58
57
  return path_1.default.join(gitDir, 'info', 'exclude');
59
58
  }
59
+ // info/exclude belongs to the common Git directory, so one migrated worktree
60
+ // must not expose generated files in another worktree that has not synced yet.
61
+ function retainSharedCatalogExclusions(projectRoot, existing) {
62
+ if (!/^fraim\/(?:ai-employee|ai-manager|docs)\/\r?$/m.test(existing))
63
+ return false;
64
+ try {
65
+ const listing = (0, child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain', '-z'], {
66
+ cwd: projectRoot, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
67
+ });
68
+ const roots = listing.split('\0').filter(field => field.startsWith('worktree '))
69
+ .map(field => field.slice('worktree '.length));
70
+ // Unknown worktree state is not evidence that their catalogs are gone.
71
+ return roots.length === 0 || roots.some(root => (0, project_catalog_cleanup_1.hasLegacyProjectCatalogs)(root));
72
+ }
73
+ catch {
74
+ return true;
75
+ }
76
+ }
60
77
  const removeFraimSyncedContentGitignoreBlock = (projectRoot) => {
61
78
  const gitignorePath = path_1.default.join(projectRoot, '.gitignore');
62
79
  if (!fs_1.default.existsSync(gitignorePath)) {
@@ -87,6 +104,11 @@ function addToFraimGitignore(projectRoot, entry) {
87
104
  fs_1.default.writeFileSync(gitignorePath, appended, 'utf8');
88
105
  }
89
106
  const ensureFraimSyncedContentLocallyExcluded = (projectRoot) => {
107
+ // init-project can call this before sync. Keep both legacy exclusion blocks
108
+ // intact until migration succeeds, including on skipped/failed sync or cleanup.
109
+ if ((0, project_catalog_cleanup_1.hasLegacyProjectCatalogs)(projectRoot)) {
110
+ return { gitignoreUpdated: false, gitInfoExcludeUpdated: false };
111
+ }
90
112
  const gitignoreUpdated = (0, exports.removeFraimSyncedContentGitignoreBlock)(projectRoot);
91
113
  const excludePath = resolveGitInfoExcludePath(projectRoot);
92
114
  if (!excludePath) {
@@ -99,6 +121,9 @@ const ensureFraimSyncedContentLocallyExcluded = (projectRoot) => {
99
121
  const existingRaw = fs_1.default.existsSync(excludePath)
100
122
  ? fs_1.default.readFileSync(excludePath, 'utf8')
101
123
  : '';
124
+ if (retainSharedCatalogExclusions(projectRoot, existingRaw)) {
125
+ return { gitignoreUpdated, gitInfoExcludeUpdated: false };
126
+ }
102
127
  const normalized = existingRaw.replace(/\r\n/g, '\n');
103
128
  const cleaned = normalizeWithoutManagedBlock(existingRaw);
104
129
  const managedBlock = buildManagedBlock();
@@ -35,6 +35,7 @@ exports.ensurePackClone = ensurePackClone;
35
35
  const child_process_1 = require("child_process");
36
36
  const fs_1 = __importDefault(require("fs"));
37
37
  const path_1 = __importDefault(require("path"));
38
+ const artifact_paths_1 = require("../../../registry/scripts/artifact-paths");
38
39
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
39
40
  const user_config_1 = require("./user-config");
40
41
  /**
@@ -84,10 +85,19 @@ function migrateOrgFlatContent(orgRoot) {
84
85
  _migratedOrgRoots.add(orgRoot);
85
86
  const dest = path_1.default.join(orgRoot, 'personalized-employee');
86
87
  try {
88
+ // Root-relative output trees are authoritative, even when their names resemble legacy capabilities.
89
+ const artifacts = (0, artifact_paths_1.resolveArtifactPaths)(orgRoot, dest);
90
+ const protectedDirectories = [artifacts.evidence, artifacts.raw, artifacts.retrospectives, path_1.default.dirname(artifacts.archiveRaw)];
91
+ const contains = (root, target) => {
92
+ const relative = path_1.default.relative(root, target);
93
+ return relative === '' || (relative !== '..' && !relative.startsWith('..' + path_1.default.sep) && !path_1.default.isAbsolute(relative));
94
+ };
87
95
  for (const dir of ORG_CONTENT_DIRS) {
88
96
  const src = path_1.default.join(orgRoot, dir);
89
97
  if (!fs_1.default.existsSync(src))
90
98
  continue;
99
+ if (protectedDirectories.some(directory => contains(src, directory) || contains(directory, src)))
100
+ continue;
91
101
  // Skip if already under personalized-employee.
92
102
  const destDir = path_1.default.join(dest, dir);
93
103
  if (fs_1.default.existsSync(destDir))
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.hasLegacyProjectCatalogs = hasLegacyProjectCatalogs;
7
+ exports.removeLegacyProjectCatalogs = removeLegacyProjectCatalogs;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
11
+ // Only generated catalog roots are eligible; adapters and personalization are not.
12
+ const LEGACY_PROJECT_CATALOGS = ['ai-employee', 'ai-manager', 'docs'];
13
+ function hasLegacyProjectCatalogs(projectRoot) {
14
+ return LEGACY_PROJECT_CATALOGS.some(name => fs_1.default.lstatSync(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot), name), { throwIfNoEntry: false }) !== undefined);
15
+ }
16
+ function overlaps(left, right) {
17
+ const within = (parent, child) => {
18
+ const rel = path_1.default.relative(parent, child);
19
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${path_1.default.sep}`) && !path_1.default.isAbsolute(rel));
20
+ };
21
+ return within(left, right) || within(right, left);
22
+ }
23
+ /** Called only after all replacement machine catalogs have been synced. */
24
+ function removeLegacyProjectCatalogs(projectRoot) {
25
+ const project = path_1.default.resolve(projectRoot);
26
+ const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(project);
27
+ if (fs_1.default.lstatSync(fraimDir).isSymbolicLink()) {
28
+ throw new Error('Refusing catalog cleanup through a linked fraim directory.');
29
+ }
30
+ const assertWorkspacePath = (0, project_fraim_paths_1.createWorkspaceFraimPathAsserter)(project);
31
+ const machineDir = fs_1.default.realpathSync((0, project_fraim_paths_1.getUserFraimDirPath)());
32
+ const targets = LEGACY_PROJECT_CATALOGS.map(name => ({ name, target: path_1.default.resolve(fraimDir, name) }))
33
+ .filter(({ target }) => fs_1.default.lstatSync(target, { throwIfNoEntry: false }) !== undefined);
34
+ const assertPlainTree = (entry) => {
35
+ const stat = fs_1.default.lstatSync(entry);
36
+ if (stat.isSymbolicLink())
37
+ throw new Error(`Refusing catalog cleanup through link: ${entry}`);
38
+ assertWorkspacePath(entry);
39
+ if (stat.isDirectory()) {
40
+ for (const child of fs_1.default.readdirSync(entry))
41
+ assertPlainTree(path_1.default.join(entry, child));
42
+ }
43
+ else if (!stat.isFile()) {
44
+ throw new Error(`Refusing catalog cleanup of special file: ${entry}`);
45
+ }
46
+ };
47
+ // Preflight every tree before deleting any: links, overlap, or unreadable paths
48
+ // must not turn a partial inspection into destructive cleanup.
49
+ for (const { name, target } of targets) {
50
+ assertPlainTree(target);
51
+ (0, project_fraim_paths_1.assertExactWorkspaceFraimPath)(project, target, name);
52
+ if (!fs_1.default.lstatSync(target).isDirectory() || overlaps(fs_1.default.realpathSync(target), machineDir)) {
53
+ throw new Error(`Refusing catalog cleanup of non-directory or machine-overlapping path: ${target}`);
54
+ }
55
+ }
56
+ for (const { name, target } of targets) {
57
+ // Recheck immediately before removal; rmSync does not recurse through links.
58
+ assertPlainTree(target);
59
+ (0, project_fraim_paths_1.assertExactWorkspaceFraimPath)(project, target, name);
60
+ fs_1.default.rmSync(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
61
+ }
62
+ }
@@ -16,13 +16,13 @@ exports.fetchRegistryFiles = fetchRegistryFiles;
16
16
  exports.syncScriptsToUserDir = syncScriptsToUserDir;
17
17
  exports.syncDocsToUserDir = syncDocsToUserDir;
18
18
  exports.syncJobsToUserDir = syncJobsToUserDir;
19
+ exports.syncSkillsAndRulesToUserDir = syncSkillsAndRulesToUserDir;
19
20
  exports.syncFromRemote = syncFromRemote;
20
21
  const axios_1 = __importDefault(require("axios"));
21
22
  const fs_1 = require("fs");
22
23
  const path_1 = require("path");
23
24
  const chalk_1 = __importDefault(require("chalk"));
24
25
  const script_sync_utils_1 = require("./script-sync-utils");
25
- const fraim_gitignore_1 = require("./fraim-gitignore");
26
26
  const capability_index_1 = require("../../core/utils/capability-index");
27
27
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
28
28
  const LOCK_SYNCED_CONTENT_ENV = 'FRAIM_LOCK_SYNCED_CONTENT';
@@ -70,7 +70,9 @@ function shouldLockSyncedContent() {
70
70
  return !['0', 'false', 'off', 'no'].includes(normalized);
71
71
  }
72
72
  function getSyncedContentLockTargets(projectRoot) {
73
- return fraim_gitignore_1.FRAIM_SYNC_GITIGNORE_ENTRIES
73
+ // This compatibility writer still materializes project catalogs. Their lock
74
+ // scope must not depend on the current machine-only sync exclusion policy.
75
+ return ['fraim/ai-employee/', 'fraim/ai-manager/', 'fraim/docs/']
74
76
  .filter((entry) => /[\\/]$/.test(entry))
75
77
  .map((entry) => entry.replace(/[\\/]+$/, ''))
76
78
  .map((entry) => (0, path_1.join)(projectRoot, entry));
@@ -288,6 +290,28 @@ async function syncJobsToUserDir(files) {
288
290
  ]);
289
291
  return { employeeJobsSynced, managerJobsSynced };
290
292
  }
293
+ /** Keep skill and rule discovery beside the machine-level job catalogs. */
294
+ async function syncSkillsAndRulesToUserDir(files) {
295
+ const counts = { skillsSynced: 0, rulesSynced: 0 };
296
+ for (const type of ['skill', 'rule']) {
297
+ const directory = `${type}s`;
298
+ const stubs = files.filter(file => file.type === type).map(file => ({
299
+ ...file,
300
+ path: file.path.replace(/\\/g, '/').replace(new RegExp(`^${directory}/`), ''),
301
+ content: applySyncedContentBanner(file),
302
+ }));
303
+ const count = await syncTypedFilesToUserDir(stubs, type, `ai-employee/${directory}`, type);
304
+ if (type === 'skill') {
305
+ counts.skillsSynced = count;
306
+ if (count > 0)
307
+ (0, capability_index_1.writeCapabilityIndex)((0, path_1.join)((0, script_sync_utils_1.getUserFraimDir)(), 'ai-employee', directory), 'skill');
308
+ }
309
+ else {
310
+ counts.rulesSynced = count;
311
+ }
312
+ }
313
+ return counts;
314
+ }
291
315
  /**
292
316
  * Sync jobs and scripts from remote FRAIM server
293
317
  */
@@ -282,7 +282,13 @@ function buildPersonaAvatarUrl(personaKey) {
282
282
  return `https://api.dicebear.com/9.x/${avatar.style}/svg?${params.toString()}`;
283
283
  }
284
284
  function isPersonaHireKey(value) {
285
- return value in exports.PERSONA_HIRE_CATALOG;
285
+ // Own properties only. `in` also answers true for every Object.prototype member,
286
+ // so 'constructor', 'toString', '__proto__' and friends passed this guard and then
287
+ // indexed the catalog to a prototype member rather than a persona. The guard is the
288
+ // input validation in front of the hire-checkout and entitlement reads, where the
289
+ // value is caller-supplied, and (issue #1925) in front of the employee-record reader,
290
+ // where it is file-supplied.
291
+ return Object.prototype.hasOwnProperty.call(exports.PERSONA_HIRE_CATALOG, value);
286
292
  }
287
293
  function getPersonaHireAmountCents(personaKey, mode) {
288
294
  const persona = exports.PERSONA_HIRE_CATALOG[personaKey];