fraim-framework 2.0.104 → 2.0.106

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.
@@ -45,6 +45,7 @@ const path_1 = __importDefault(require("path"));
45
45
  const ide_detector_1 = require("../setup/ide-detector");
46
46
  const mcp_config_generator_1 = require("../setup/mcp-config-generator");
47
47
  const codex_local_config_1 = require("../setup/codex-local-config");
48
+ const claude_code_telemetry_1 = require("../setup/claude-code-telemetry");
48
49
  const script_sync_utils_1 = require("../utils/script-sync-utils");
49
50
  const mcp_server_registry_1 = require("../mcp/mcp-server-registry");
50
51
  const get_provider_client_1 = require("../api/get-provider-client");
@@ -249,6 +250,10 @@ const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
249
250
  const status = localResult.created ? 'Created' : localResult.updated ? 'Updated' : 'Verified';
250
251
  console.log(chalk_1.default.green(` ✅ ${status} local ${ide.name} config: ${localResult.path}`));
251
252
  }
253
+ // Enable token telemetry for Claude Code via project-level settings
254
+ if (ide.configType === 'claude-code') {
255
+ (0, claude_code_telemetry_1.ensureClaudeCodeTelemetryEnv)();
256
+ }
252
257
  };
253
258
  const listSupportedIDEs = () => {
254
259
  const allIDEs = (0, ide_detector_1.getAllSupportedIDEs)();
@@ -9,6 +9,7 @@ const chalk_1 = __importDefault(require("chalk"));
9
9
  const prompts_1 = __importDefault(require("prompts"));
10
10
  const fs_1 = __importDefault(require("fs"));
11
11
  const path_1 = __importDefault(require("path"));
12
+ const child_process_1 = require("child_process");
12
13
  const mcp_config_generator_1 = require("../setup/mcp-config-generator");
13
14
  const ide_detector_1 = require("../setup/ide-detector");
14
15
  const script_sync_utils_1 = require("../utils/script-sync-utils");
@@ -163,6 +164,28 @@ const runAddProvider = async (provider, options) => {
163
164
  }
164
165
  }
165
166
  }
167
+ // Check prerequisites for the provider's MCP server command
168
+ const providerDefCheck = await (0, provider_registry_1.getProvider)(provider);
169
+ if (providerDefCheck?.mcpServer?.type === 'stdio' && providerDefCheck.mcpServer.command && !process.env.FRAIM_SKIP_PREREQ_CHECK) {
170
+ const cmd = providerDefCheck.mcpServer.command;
171
+ // Only check non-standard commands (npx/node are assumed to be available)
172
+ if (!['npx', 'node', 'npm'].includes(cmd)) {
173
+ try {
174
+ (0, child_process_1.execSync)(`${cmd} --version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
175
+ }
176
+ catch {
177
+ const installInstructions = {
178
+ uvx: 'Install uv first: https://docs.astral.sh/uv/getting-started/installation/\n' +
179
+ ' macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n' +
180
+ ' Windows: powershell -c "irm https://astral.sh/uv/install.ps1 | iex"',
181
+ };
182
+ console.log(chalk_1.default.red(`\n❌ "${cmd}" is required for ${providerName} but was not found on your system.\n`));
183
+ console.log(chalk_1.default.yellow(installInstructions[cmd] || `Please install "${cmd}" and try again.`));
184
+ console.log(chalk_1.default.gray('\nAfter installing, restart your terminal and run this command again.'));
185
+ process.exit(1);
186
+ }
187
+ }
188
+ }
166
189
  // Get credentials using generic prompt system
167
190
  try {
168
191
  // Build provided tokens/configs from CLI options
@@ -16,6 +16,7 @@ const platform_detection_1 = require("../utils/platform-detection");
16
16
  const version_utils_1 = require("../utils/version-utils");
17
17
  const ide_detector_1 = require("../setup/ide-detector");
18
18
  const codex_local_config_1 = require("../setup/codex-local-config");
19
+ const claude_code_telemetry_1 = require("../setup/claude-code-telemetry");
19
20
  const provider_registry_1 = require("../providers/provider-registry");
20
21
  const fraim_gitignore_1 = require("../utils/fraim-gitignore");
21
22
  const config_writer_1 = require("../../core/config-writer");
@@ -323,6 +324,11 @@ const runInitProject = async () => {
323
324
  const status = codexLocalResult.created ? 'Created' : codexLocalResult.updated ? 'Updated' : 'Verified';
324
325
  console.log(chalk_1.default.green(`${status} project Codex config at ${codexLocalResult.path}`));
325
326
  }
327
+ // Enable token telemetry for Claude Code (user-level, applies to all projects)
328
+ const claudeCodeAvailable = (0, ide_detector_1.detectInstalledIDEs)().some((ide) => ide.configType === 'claude-code');
329
+ if (claudeCodeAvailable) {
330
+ (0, claude_code_telemetry_1.ensureClaudeCodeTelemetryEnv)();
331
+ }
326
332
  const adapterUpdates = (0, agent_adapters_1.ensureAgentAdapterFiles)(projectRoot);
327
333
  if (adapterUpdates.length > 0) {
328
334
  console.log(chalk_1.default.green(`Updated FRAIM agent adapter files: ${adapterUpdates.join(', ')}`));
@@ -602,6 +602,26 @@ const runSetup = async (options) => {
602
602
  console.log(chalk_1.default.gray(' You can update them manually with: fraim add-ide <ide-name>\n'));
603
603
  }
604
604
  }
605
+ // Sync user-level FRAIM artifacts (always, on both initial and update)
606
+ try {
607
+ const { syncUserLevelArtifacts } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
608
+ console.log(chalk_1.default.blue('\n📦 Syncing user-level FRAIM content...'));
609
+ await syncUserLevelArtifacts();
610
+ }
611
+ catch (e) {
612
+ console.log(chalk_1.default.yellow(`⚠️ User-level content sync encountered issues: ${e.message}`));
613
+ console.log(chalk_1.default.gray(' You can sync later with: fraim sync --global'));
614
+ }
615
+ // Install IDE slash commands and global rules
616
+ try {
617
+ const { installSlashCommands, installGlobalRules } = await Promise.resolve().then(() => __importStar(require('../setup/ide-global-integration')));
618
+ console.log(chalk_1.default.blue('\n🔗 Installing IDE integrations...'));
619
+ await installSlashCommands();
620
+ await installGlobalRules();
621
+ }
622
+ catch (e) {
623
+ console.log(chalk_1.default.yellow(`⚠️ IDE integration encountered issues: ${e.message}`));
624
+ }
605
625
  // Auto-run project init if we're in a git repo (only on initial setup)
606
626
  if (!isUpdate) {
607
627
  if ((0, platform_detection_1.isGitRepository)()) {
@@ -84,6 +84,20 @@ function updateVersionInConfig(fraimDir) {
84
84
  }
85
85
  }
86
86
  const runSync = async (options) => {
87
+ // Handle --global flag: sync to user-level ~/.fraim/ instead of project
88
+ if (options.global) {
89
+ console.log(chalk_1.default.blue('Syncing FRAIM content to user-level directory (~/.fraim/)...'));
90
+ try {
91
+ const { syncUserLevelArtifacts } = await Promise.resolve().then(() => __importStar(require('../setup/user-level-sync')));
92
+ await syncUserLevelArtifacts();
93
+ console.log(chalk_1.default.green('\n✅ User-level FRAIM content sync complete.'));
94
+ }
95
+ catch (error) {
96
+ console.error(chalk_1.default.red(`User-level sync failed: ${error.message}`));
97
+ process.exit(1);
98
+ }
99
+ return;
100
+ }
87
101
  const projectRoot = process.cwd();
88
102
  const config = (0, config_loader_1.loadFraimConfig)();
89
103
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
@@ -171,4 +185,5 @@ exports.syncCommand = new commander_1.Command('sync')
171
185
  .option('-f, --force', 'Force sync even if digest matches')
172
186
  .option('--skip-updates', 'Skip checking for CLI updates (legacy)')
173
187
  .option('--local', 'Sync from local development server (port derived from git branch)')
188
+ .option('--global', 'Sync user-level FRAIM content (~/.fraim/) instead of project')
174
189
  .action(exports.runSync);
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ /**
3
+ * Configures Claude Code user-level settings for FRAIM token telemetry.
4
+ *
5
+ * Writes OTel env vars to ~/.claude/settings.json so ALL Claude Code
6
+ * sessions push metrics to the FRAIM local proxy's OTLP receiver.
7
+ * User-level so it works across all projects without per-project setup.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.ensureClaudeCodeTelemetryEnv = ensureClaudeCodeTelemetryEnv;
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ const os_1 = __importDefault(require("os"));
17
+ const chalk_1 = __importDefault(require("chalk"));
18
+ const TELEMETRY_ENV = {
19
+ CLAUDE_CODE_ENABLE_TELEMETRY: '1',
20
+ OTEL_METRICS_EXPORTER: 'otlp',
21
+ OTEL_EXPORTER_OTLP_PROTOCOL: 'http/json',
22
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318',
23
+ };
24
+ /**
25
+ * Ensure Claude Code user-level settings include OTel env vars for token telemetry.
26
+ * Writes to ~/.claude/settings.json. Merges without overwriting user-set values.
27
+ */
28
+ function ensureClaudeCodeTelemetryEnv() {
29
+ const settingsDir = path_1.default.join(os_1.default.homedir(), '.claude');
30
+ const settingsPath = path_1.default.join(settingsDir, 'settings.json');
31
+ let settings = {};
32
+ if (fs_1.default.existsSync(settingsPath)) {
33
+ try {
34
+ settings = JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'));
35
+ }
36
+ catch {
37
+ // Corrupt file — will overwrite
38
+ }
39
+ }
40
+ else if (!fs_1.default.existsSync(settingsDir)) {
41
+ fs_1.default.mkdirSync(settingsDir, { recursive: true });
42
+ }
43
+ const existingEnv = settings.env || {};
44
+ let added = 0;
45
+ for (const [key, value] of Object.entries(TELEMETRY_ENV)) {
46
+ if (!(key in existingEnv)) {
47
+ existingEnv[key] = value;
48
+ added++;
49
+ }
50
+ }
51
+ if (added > 0) {
52
+ settings.env = existingEnv;
53
+ fs_1.default.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
54
+ console.log(chalk_1.default.green(` ✅ Enabled token telemetry in ~/.claude/settings.json (${added} env vars)`));
55
+ }
56
+ else {
57
+ console.log(chalk_1.default.gray(` ⏭️ Token telemetry already configured in ~/.claude/settings.json`));
58
+ }
59
+ }
@@ -0,0 +1,120 @@
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.installSlashCommands = installSlashCommands;
7
+ exports.installGlobalRules = installGlobalRules;
8
+ /**
9
+ * IDE Global Integration
10
+ *
11
+ * Installs FRAIM slash commands and global rules into user-level IDE
12
+ * configuration directories. These enable FRAIM to be discoverable
13
+ * in any project without requiring fraim init-project.
14
+ *
15
+ * Part of: User-Level FRAIM Artifacts with Local Shadow Semantics
16
+ */
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const chalk_1 = __importDefault(require("chalk"));
21
+ const FRAIM_SLASH_COMMAND_CONTENT = `The user wants to run FRAIM. The requested job or topic is: $ARGUMENTS
22
+
23
+ Follow this process:
24
+
25
+ 1. **If no argument was given** (the line above ends with ": "): call \`list_fraim_jobs()\` to discover available jobs. List each by name and its Intent line. Ask the user which job they want to run, then proceed to step 2.
26
+
27
+ 2. **Find the match**: from the list returned by \`list_fraim_jobs()\`, find the job whose name matches or closely resembles the argument. If no job matches, search for a matching skill by calling \`get_fraim_file({ path: "skills/<likely-category>/<argument>.md" })\` — try common categories like \`engineering/\`, \`marketing/\`, \`business/\`, \`product-management/\`, \`ai-tools/\`. Confirm the match with the user.
28
+
29
+ 3. **Load the full content**:
30
+ - For jobs: call \`get_fraim_job({ job: "<matched-job-name>" })\` — never execute from stub content.
31
+ - For skills: the content from \`get_fraim_file\` is the full skill. Use it directly.
32
+
33
+ 4. **Execute**: for jobs, follow the phased instructions returned by \`get_fraim_job\`, using \`seekMentoring\` at phase transitions where indicated. For skills, apply the skill steps to the user's current context.
34
+ `;
35
+ const FRAIM_CURSOR_RULES_CONTENT = `# FRAIM Rules
36
+
37
+ This workspace uses FRAIM (Framework for Rigor-based AI Management).
38
+
39
+ ## Discovery
40
+ - Before acting on any user request, check if a FRAIM job matches the intent.
41
+ - Call \`list_fraim_jobs()\` to discover available jobs.
42
+ - Call \`get_fraim_job({ job: "<job-name>" })\` to get full phased instructions.
43
+
44
+ ## Execution
45
+ - Jobs are FRAIM's primary execution units — structured, multi-phase workflows.
46
+ - Skills are reusable capabilities that jobs compose.
47
+ - Rules are always-on constraints and conventions.
48
+ - Follow phased instructions and use \`seekMentoring\` at phase transitions.
49
+
50
+ ## Principles
51
+ - Never execute from job stubs — always load full instructions via MCP.
52
+ - Follow the constitution: integrity, correctness, contribution, completeness.
53
+ `;
54
+ /**
55
+ * Install the FRAIM slash command for Claude Code at the user level.
56
+ * Writes to ~/.claude/commands/fraim.md.
57
+ * Does NOT overwrite if the file already exists.
58
+ *
59
+ * @param homeDir - Override for home directory (for testing)
60
+ */
61
+ async function installSlashCommands(homeDir) {
62
+ const home = homeDir || os_1.default.homedir();
63
+ const claudeDir = path_1.default.join(home, '.claude');
64
+ // Only install if Claude Code is installed (indicated by .claude/ existing)
65
+ if (!fs_1.default.existsSync(claudeDir)) {
66
+ return;
67
+ }
68
+ const commandsDir = path_1.default.join(claudeDir, 'commands');
69
+ const slashCommandPath = path_1.default.join(commandsDir, 'fraim.md');
70
+ // Do not overwrite existing file
71
+ if (fs_1.default.existsSync(slashCommandPath)) {
72
+ console.log(chalk_1.default.gray(' Claude slash command already exists — skipping'));
73
+ return;
74
+ }
75
+ fs_1.default.mkdirSync(commandsDir, { recursive: true });
76
+ fs_1.default.writeFileSync(slashCommandPath, FRAIM_SLASH_COMMAND_CONTENT, 'utf8');
77
+ console.log(chalk_1.default.green(' ✅ Installed Claude slash command (~/.claude/commands/fraim.md)'));
78
+ }
79
+ /**
80
+ * Install FRAIM global rules/instructions for supported IDEs.
81
+ * Supports: Cursor, Codex, Windsurf, Kiro
82
+ * Does NOT overwrite if files already exist.
83
+ *
84
+ * @param homeDir - Override for home directory (for testing)
85
+ */
86
+ async function installGlobalRules(homeDir) {
87
+ const home = homeDir || os_1.default.homedir();
88
+ // Cursor: ~/.cursor/rules/fraim-rules.md
89
+ const cursorDir = path_1.default.join(home, '.cursor');
90
+ if (fs_1.default.existsSync(cursorDir)) {
91
+ installRuleFile(path_1.default.join(cursorDir, 'rules', 'fraim-rules.md'), FRAIM_CURSOR_RULES_CONTENT, 'Cursor global rules (~/.cursor/rules/fraim-rules.md)');
92
+ }
93
+ // Codex: ~/.codex/instructions.md
94
+ const codexDir = path_1.default.join(home, '.codex');
95
+ if (fs_1.default.existsSync(codexDir)) {
96
+ installRuleFile(path_1.default.join(codexDir, 'instructions.md'), FRAIM_CURSOR_RULES_CONTENT, 'Codex global instructions (~/.codex/instructions.md)');
97
+ }
98
+ // Windsurf: ~/.codeium/windsurf/rules/fraim-rules.md
99
+ const windsurfDir = path_1.default.join(home, '.codeium', 'windsurf');
100
+ if (fs_1.default.existsSync(windsurfDir)) {
101
+ installRuleFile(path_1.default.join(windsurfDir, 'rules', 'fraim-rules.md'), FRAIM_CURSOR_RULES_CONTENT, 'Windsurf global rules (~/.codeium/windsurf/rules/fraim-rules.md)');
102
+ }
103
+ // Kiro: ~/.kiro/rules/fraim-rules.md
104
+ const kiroDir = path_1.default.join(home, '.kiro');
105
+ if (fs_1.default.existsSync(kiroDir)) {
106
+ installRuleFile(path_1.default.join(kiroDir, 'rules', 'fraim-rules.md'), FRAIM_CURSOR_RULES_CONTENT, 'Kiro global rules (~/.kiro/rules/fraim-rules.md)');
107
+ }
108
+ }
109
+ /**
110
+ * Install a rule file if it doesn't already exist.
111
+ */
112
+ function installRuleFile(filePath, content, displayName) {
113
+ if (fs_1.default.existsSync(filePath)) {
114
+ console.log(chalk_1.default.gray(` ${displayName.split('(')[0].trim()} already exist — skipping`));
115
+ return;
116
+ }
117
+ fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
118
+ fs_1.default.writeFileSync(filePath, content, 'utf8');
119
+ console.log(chalk_1.default.green(` ✅ Installed ${displayName}`));
120
+ }
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.ensureUserLevelDirectories = ensureUserLevelDirectories;
40
+ exports.syncUserLevelArtifacts = syncUserLevelArtifacts;
41
+ /**
42
+ * User-Level FRAIM Artifact Sync
43
+ *
44
+ * Syncs jobs, skills, rules, and docs stubs from the remote FRAIM server
45
+ * to the user-level ~/.fraim/ directory. This makes FRAIM discoverable
46
+ * everywhere without requiring fraim init-project.
47
+ *
48
+ * Part of: User-Level FRAIM Artifacts with Local Shadow Semantics
49
+ */
50
+ const fs_1 = __importDefault(require("fs"));
51
+ const path_1 = __importDefault(require("path"));
52
+ const chalk_1 = __importDefault(require("chalk"));
53
+ const script_sync_utils_1 = require("../utils/script-sync-utils");
54
+ /**
55
+ * Ensure the user-level FRAIM directory structure exists for content sync.
56
+ * Creates directories for synced content and personalized overrides.
57
+ */
58
+ function ensureUserLevelDirectories(userFraimDir) {
59
+ const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
60
+ const dirs = [
61
+ path_1.default.join(baseDir, 'ai-employee', 'jobs'),
62
+ path_1.default.join(baseDir, 'ai-employee', 'skills'),
63
+ path_1.default.join(baseDir, 'ai-employee', 'rules'),
64
+ path_1.default.join(baseDir, 'ai-manager', 'jobs'),
65
+ path_1.default.join(baseDir, 'personalized-employee', 'jobs'),
66
+ path_1.default.join(baseDir, 'personalized-employee', 'skills'),
67
+ path_1.default.join(baseDir, 'personalized-employee', 'rules'),
68
+ path_1.default.join(baseDir, 'personalized-employee', 'learnings'),
69
+ path_1.default.join(baseDir, 'docs'),
70
+ ];
71
+ for (const dir of dirs) {
72
+ if (!fs_1.default.existsSync(dir)) {
73
+ fs_1.default.mkdirSync(dir, { recursive: true });
74
+ }
75
+ }
76
+ }
77
+ /**
78
+ * Sync FRAIM artifacts (jobs, skills, rules, docs) from the remote server
79
+ * to the user-level ~/.fraim/ directory.
80
+ *
81
+ * Uses the same remote sync endpoint as project-level sync, but writes
82
+ * content directly to the user-level directory structure instead of under
83
+ * a project's fraim/ subdirectory.
84
+ *
85
+ * @param userFraimDir - Override for the target directory (for testing)
86
+ */
87
+ async function syncUserLevelArtifacts(userFraimDir) {
88
+ const baseDir = userFraimDir || (0, script_sync_utils_1.getUserFraimDir)();
89
+ console.log(chalk_1.default.blue('📦 Syncing FRAIM content to user-level directory...'));
90
+ console.log(chalk_1.default.gray(` Target: ${baseDir}`));
91
+ // Ensure directory structure exists
92
+ ensureUserLevelDirectories(baseDir);
93
+ // Try to sync from remote. If this fails (no network, no key),
94
+ // we still have the directory structure in place.
95
+ try {
96
+ const { syncFromRemote } = await Promise.resolve().then(() => __importStar(require('../utils/remote-sync')));
97
+ const apiKey = loadApiKeyFromConfig(baseDir);
98
+ if (!apiKey) {
99
+ console.log(chalk_1.default.yellow('⚠️ No API key found. User-level content sync skipped.'));
100
+ console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
101
+ return;
102
+ }
103
+ const result = await syncFromRemote({
104
+ apiKey,
105
+ projectRoot: baseDir,
106
+ targetIsUserLevel: true,
107
+ skipUpdates: true
108
+ });
109
+ if (result.success) {
110
+ console.log(chalk_1.default.green(`✅ Synced ${result.employeeJobsSynced} ai-employee jobs, ` +
111
+ `${result.managerJobsSynced} ai-manager jobs, ` +
112
+ `${result.skillsSynced} skills, ${result.rulesSynced} rules, ` +
113
+ `${result.scriptsSynced} scripts, and ${result.docsSynced} docs to ~/.fraim/`));
114
+ }
115
+ else {
116
+ console.log(chalk_1.default.yellow(`⚠️ User-level content sync failed: ${result.error}`));
117
+ console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
118
+ }
119
+ }
120
+ catch (error) {
121
+ console.log(chalk_1.default.yellow(`⚠️ User-level content sync failed: ${error.message}`));
122
+ console.log(chalk_1.default.gray(' Directory structure created. Content will sync on next fraim sync --global.'));
123
+ }
124
+ }
125
+ /**
126
+ * Load API key from the user-level config file.
127
+ */
128
+ function loadApiKeyFromConfig(userFraimDir) {
129
+ const configPath = path_1.default.join(userFraimDir, 'config.json');
130
+ if (!fs_1.default.existsSync(configPath))
131
+ return undefined;
132
+ try {
133
+ const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
134
+ return config.apiKey;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
@@ -135,8 +135,17 @@ async function syncFromRemote(options) {
135
135
  error: 'No files received'
136
136
  };
137
137
  }
138
- const lockTargets = getSyncedContentLockTargets(options.projectRoot);
139
- if (shouldLockSyncedContent()) {
138
+ // Helper: resolve path within the FRAIM content directory.
139
+ // For user-level sync, write directly to projectRoot (which IS ~/.fraim/).
140
+ // For project-level sync, use getWorkspaceFraimPath which prepends 'fraim/'.
141
+ const resolveFraimPath = (...parts) => {
142
+ if (options.targetIsUserLevel) {
143
+ return (0, path_1.join)(options.projectRoot, ...parts);
144
+ }
145
+ return (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, ...parts);
146
+ };
147
+ const lockTargets = options.targetIsUserLevel ? [] : getSyncedContentLockTargets(options.projectRoot);
148
+ if (!options.targetIsUserLevel && shouldLockSyncedContent()) {
140
149
  // If previous sync locked these paths read-only, temporarily unlock before cleanup/write.
141
150
  for (const target of lockTargets) {
142
151
  setFileWriteLockRecursively(target, false);
@@ -146,7 +155,7 @@ async function syncFromRemote(options) {
146
155
  const allJobFiles = files.filter(f => f.type === 'job');
147
156
  const managerJobFiles = allJobFiles.filter(f => f.path.startsWith('ai-manager/'));
148
157
  const jobFiles = allJobFiles.filter(f => !f.path.startsWith('ai-manager/'));
149
- const employeeJobsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'jobs');
158
+ const employeeJobsDir = resolveFraimPath('ai-employee', 'jobs');
150
159
  if (!(0, fs_1.existsSync)(employeeJobsDir)) {
151
160
  (0, fs_1.mkdirSync)(employeeJobsDir, { recursive: true });
152
161
  }
@@ -166,7 +175,7 @@ async function syncFromRemote(options) {
166
175
  console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`ai-employee/jobs/${relPath}`)}`));
167
176
  }
168
177
  // Sync ai-manager job stubs to fraim/ai-manager/jobs/
169
- const managerJobsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-manager', 'jobs');
178
+ const managerJobsDir = resolveFraimPath('ai-manager', 'jobs');
170
179
  if (!(0, fs_1.existsSync)(managerJobsDir)) {
171
180
  (0, fs_1.mkdirSync)(managerJobsDir, { recursive: true });
172
181
  }
@@ -187,7 +196,7 @@ async function syncFromRemote(options) {
187
196
  }
188
197
  // Sync skill STUBS to fraim/ai-employee/skills/
189
198
  const skillFiles = files.filter(f => f.type === 'skill');
190
- const skillsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'skills');
199
+ const skillsDir = resolveFraimPath('ai-employee', 'skills');
191
200
  if (!(0, fs_1.existsSync)(skillsDir)) {
192
201
  (0, fs_1.mkdirSync)(skillsDir, { recursive: true });
193
202
  }
@@ -207,7 +216,7 @@ async function syncFromRemote(options) {
207
216
  }
208
217
  // Sync rule STUBS to fraim/ai-employee/rules/
209
218
  const ruleFiles = files.filter(f => f.type === 'rule');
210
- const rulesDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'ai-employee', 'rules');
219
+ const rulesDir = resolveFraimPath('ai-employee', 'rules');
211
220
  if (!(0, fs_1.existsSync)(rulesDir)) {
212
221
  (0, fs_1.mkdirSync)(rulesDir, { recursive: true });
213
222
  }
@@ -246,7 +255,7 @@ async function syncFromRemote(options) {
246
255
  }
247
256
  // Sync docs to fraim/docs/
248
257
  const docsFiles = files.filter(f => f.type === 'docs');
249
- const docsDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(options.projectRoot, 'docs');
258
+ const docsDir = resolveFraimPath('docs');
250
259
  if (!(0, fs_1.existsSync)(docsDir)) {
251
260
  (0, fs_1.mkdirSync)(docsDir, { recursive: true });
252
261
  }
@@ -260,7 +269,7 @@ async function syncFromRemote(options) {
260
269
  (0, fs_1.writeFileSync)(filePath, file.content, 'utf8');
261
270
  console.log(chalk_1.default.gray(` + ${(0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`docs/${file.path}`)}`));
262
271
  }
263
- if (shouldLockSyncedContent()) {
272
+ if (!options.targetIsUserLevel && shouldLockSyncedContent()) {
264
273
  for (const target of lockTargets) {
265
274
  setFileWriteLockRecursively(target, true);
266
275
  }
@@ -49,10 +49,22 @@ const project_fraim_paths_1 = require("./project-fraim-paths");
49
49
  class LocalRegistryResolver {
50
50
  constructor(options) {
51
51
  this.workspaceRoot = options.workspaceRoot;
52
+ this.effectiveFraimDir = options.effectiveFraimDir;
52
53
  this.remoteContentResolver = options.remoteContentResolver;
53
54
  this.parser = new inheritance_parser_1.InheritanceParser(options.maxDepth);
54
55
  this.shouldFilter = options.shouldFilter;
55
56
  }
57
+ /**
58
+ * Get a path within the effective FRAIM directory.
59
+ * When effectiveFraimDir is set (user-level mode), joins directly with that dir.
60
+ * Otherwise, uses the standard getWorkspaceFraimPath which prepends 'fraim/'.
61
+ */
62
+ getFraimPath(...parts) {
63
+ if (this.effectiveFraimDir) {
64
+ return (0, path_1.join)(this.effectiveFraimDir, ...parts);
65
+ }
66
+ return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, ...parts);
67
+ }
56
68
  /**
57
69
  * Check if a local override exists for the given path
58
70
  */
@@ -72,7 +84,7 @@ class LocalRegistryResolver {
72
84
  if (this.hasLocalOverride(literal))
73
85
  return literal;
74
86
  // Deep search
75
- const fullBaseDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'personalized-employee', dir);
87
+ const fullBaseDir = this.getFraimPath('personalized-employee', dir);
76
88
  const found = this.searchFileRecursively(fullBaseDir, baseName);
77
89
  if (found) {
78
90
  // Convert absolute back to relative
@@ -116,7 +128,7 @@ class LocalRegistryResolver {
116
128
  const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
117
129
  // Personal overrides are in fraim/personalized-employee/
118
130
  // We don't need a redundant 'registry/' subfolder here as the path already includes type (e.g. jobs/)
119
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'personalized-employee', normalized);
131
+ return this.getFraimPath('personalized-employee', normalized);
120
132
  }
121
133
  /**
122
134
  * Get the full path to a locally synced FRAIM file when available.
@@ -132,18 +144,18 @@ class LocalRegistryResolver {
132
144
  if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
133
145
  const role = parts[1];
134
146
  const subPath = parts.slice(2).join('/');
135
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, role, 'workflows', subPath);
147
+ return this.getFraimPath(role, 'workflows', subPath);
136
148
  }
137
149
  // Fallback: Try ai-employee and ai-manager if no role prefix
138
150
  const subPath = normalizedPath.substring('workflows/'.length);
139
- const employeePath = (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'ai-employee', 'workflows', subPath);
151
+ const employeePath = this.getFraimPath('ai-employee', 'workflows', subPath);
140
152
  if (fs.existsSync(employeePath))
141
153
  return employeePath;
142
- const managerPath = (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'ai-manager', 'workflows', subPath);
154
+ const managerPath = this.getFraimPath('ai-manager', 'workflows', subPath);
143
155
  if (fs.existsSync(managerPath))
144
156
  return managerPath;
145
157
  // Fallback for non-role-prefixed direct workspace paths
146
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, normalizedPath);
158
+ return this.getFraimPath(normalizedPath);
147
159
  }
148
160
  // 2. Jobs: jobs/[role]/path -> fraim/[role]/jobs/path
149
161
  if (normalizedPath.startsWith('jobs/')) {
@@ -151,18 +163,18 @@ class LocalRegistryResolver {
151
163
  if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
152
164
  const role = parts[1];
153
165
  const subPath = parts.slice(2).join('/');
154
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, role, 'jobs', subPath);
166
+ return this.getFraimPath(role, 'jobs', subPath);
155
167
  }
156
168
  // Fallback: Try ai-employee and ai-manager if no role prefix
157
169
  const subPath = normalizedPath.substring('jobs/'.length);
158
- const employeePath = (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'ai-employee', 'jobs', subPath);
170
+ const employeePath = this.getFraimPath('ai-employee', 'jobs', subPath);
159
171
  if (fs.existsSync(employeePath))
160
172
  return employeePath;
161
- const managerPath = (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'ai-manager', 'jobs', subPath);
173
+ const managerPath = this.getFraimPath('ai-manager', 'jobs', subPath);
162
174
  if (fs.existsSync(managerPath))
163
175
  return managerPath;
164
176
  // Fallback
165
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, normalizedPath);
177
+ return this.getFraimPath(normalizedPath);
166
178
  }
167
179
  // 3. Rules: [role]/rules/path -> fraim/[role]/rules/path
168
180
  if (normalizedPath.includes('/rules/')) {
@@ -170,16 +182,16 @@ class LocalRegistryResolver {
170
182
  // Extract the part after "rules/"
171
183
  const rulesIdx = normalizedPath.indexOf('rules/');
172
184
  const subPath = normalizedPath.substring(rulesIdx + 'rules/'.length);
173
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, role, 'rules', subPath);
185
+ return this.getFraimPath(role, 'rules', subPath);
174
186
  }
175
187
  // 4. Skills: skills/path -> fraim/ai-employee/skills/path (default to ai-employee)
176
188
  if (normalizedPath.startsWith('skills/')) {
177
189
  const subPath = normalizedPath.substring('skills/'.length);
178
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'ai-employee', 'skills', subPath);
190
+ return this.getFraimPath('ai-employee', 'skills', subPath);
179
191
  }
180
192
  // 5. Rules: rules/path -> fraim/ai-employee/rules/path (default to ai-employee)
181
193
  if (normalizedPath.startsWith('rules/')) {
182
- return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'ai-employee', normalizedPath);
194
+ return this.getFraimPath('ai-employee', normalizedPath);
183
195
  }
184
196
  return null;
185
197
  }
@@ -580,7 +592,7 @@ class LocalRegistryResolver {
580
592
  const items = [];
581
593
  const dirs = ['jobs'];
582
594
  for (const dir of dirs) {
583
- const localDir = (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, 'personalized-employee', dir);
595
+ const localDir = this.getFraimPath('personalized-employee', dir);
584
596
  if (fs.existsSync(localDir)) {
585
597
  const relPaths = this.collectLocalMarkdownPaths(localDir);
586
598
  for (const rel of relPaths) {
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.WORKSPACE_SYNCED_CONTENT_DIRS = exports.WORKSPACE_FRAIM_DIRNAME = void 0;
4
7
  exports.getWorkspaceFraimDir = getWorkspaceFraimDir;
@@ -6,8 +9,11 @@ exports.workspaceFraimExists = workspaceFraimExists;
6
9
  exports.getWorkspaceConfigPath = getWorkspaceConfigPath;
7
10
  exports.getWorkspaceFraimPath = getWorkspaceFraimPath;
8
11
  exports.getWorkspaceFraimDisplayPath = getWorkspaceFraimDisplayPath;
12
+ exports.getUserFraimDirPath = getUserFraimDirPath;
13
+ exports.getEffectiveFraimDir = getEffectiveFraimDir;
9
14
  const fs_1 = require("fs");
10
15
  const path_1 = require("path");
16
+ const os_1 = __importDefault(require("os"));
11
17
  exports.WORKSPACE_FRAIM_DIRNAME = 'fraim';
12
18
  exports.WORKSPACE_SYNCED_CONTENT_DIRS = [
13
19
  'workflows',
@@ -36,3 +42,34 @@ function getWorkspaceFraimDisplayPath(relativePath = '') {
36
42
  ? `${exports.WORKSPACE_FRAIM_DIRNAME}/${normalized}`
37
43
  : `${exports.WORKSPACE_FRAIM_DIRNAME}/`;
38
44
  }
45
+ /**
46
+ * Get the user-level FRAIM directory (~/.fraim/).
47
+ * Can be overridden with FRAIM_USER_DIR env var for testing.
48
+ */
49
+ function getUserFraimDirPath() {
50
+ return process.env.FRAIM_USER_DIR || (0, path_1.join)(os_1.default.homedir(), '.fraim');
51
+ }
52
+ /**
53
+ * Determine the effective FRAIM content root directory.
54
+ *
55
+ * Shadow semantics: if the project has a local fraim/ directory, it completely
56
+ * shadows the user-level ~/.fraim/ — no mixing, no layering.
57
+ *
58
+ * @param projectRoot - The project/workspace root directory
59
+ * @param userFraimDir - Optional override for the user-level dir (for testing)
60
+ * @returns The effective fraim content root directory path, or '' if neither exists
61
+ */
62
+ function getEffectiveFraimDir(projectRoot = process.cwd(), userFraimDir) {
63
+ // 1. Check for project-level fraim/ directory
64
+ const projectFraimDir = getWorkspaceFraimDir(projectRoot);
65
+ if ((0, fs_1.existsSync)(projectFraimDir)) {
66
+ return projectFraimDir;
67
+ }
68
+ // 2. Fall back to user-level ~/.fraim/
69
+ const userDir = userFraimDir || getUserFraimDirPath();
70
+ if ((0, fs_1.existsSync)(userDir)) {
71
+ return userDir;
72
+ }
73
+ // 3. Neither exists
74
+ return '';
75
+ }
@@ -0,0 +1,262 @@
1
+ "use strict";
2
+ /**
3
+ * Lightweight OTLP HTTP Metrics Receiver
4
+ *
5
+ * Accepts OTLP metric pushes from Claude Code when configured with:
6
+ * OTEL_METRICS_EXPORTER=otlp
7
+ * OTEL_EXPORTER_OTLP_PROTOCOL=http/json
8
+ * OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
9
+ *
10
+ * Multiple Claude Code sessions share one receiver on port 4318.
11
+ * Snapshots are stored per session ID and queryable via HTTP so any
12
+ * FRAIM proxy (not just the one that started the receiver) can fetch
13
+ * its own session's token data.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.parseOtlpMetrics = parseOtlpMetrics;
17
+ exports.fetchSnapshot = fetchSnapshot;
18
+ exports.startOtlpReceiver = startOtlpReceiver;
19
+ exports.stopOtlpReceiver = stopOtlpReceiver;
20
+ const http_1 = require("http");
21
+ const DEFAULT_PORT = 4318;
22
+ /** Stored token snapshots keyed by Claude Code session ID */
23
+ const snapshots = new Map();
24
+ /**
25
+ * Parse OTLP HTTP JSON metrics payload and extract token/cost data.
26
+ *
27
+ * OTLP JSON structure:
28
+ * { resourceMetrics: [{ scopeMetrics: [{ metrics: [{ name, sum: { dataPoints } }] }] }] }
29
+ */
30
+ function parseOtlpMetrics(body) {
31
+ if (!body?.resourceMetrics)
32
+ return null;
33
+ let inputTokens = 0;
34
+ let outputTokens = 0;
35
+ let cacheReadTokens = 0;
36
+ let cacheCreationTokens = 0;
37
+ let costUsd = 0;
38
+ let claudeSessionId = null;
39
+ let model = null;
40
+ let foundTokenMetric = false;
41
+ for (const rm of body.resourceMetrics) {
42
+ for (const sm of rm.scopeMetrics || []) {
43
+ for (const metric of sm.metrics || []) {
44
+ const dataPoints = metric.sum?.dataPoints || metric.gauge?.dataPoints || [];
45
+ if (metric.name === 'claude_code.token.usage') {
46
+ foundTokenMetric = true;
47
+ for (const dp of dataPoints) {
48
+ const attrs = parseAttributes(dp.attributes);
49
+ const value = Number(dp.asInt ?? dp.asDouble ?? dp.value ?? 0);
50
+ switch (attrs['type']) {
51
+ case 'input':
52
+ inputTokens += value;
53
+ break;
54
+ case 'output':
55
+ outputTokens += value;
56
+ break;
57
+ case 'cacheRead':
58
+ cacheReadTokens += value;
59
+ break;
60
+ case 'cacheCreation':
61
+ cacheCreationTokens += value;
62
+ break;
63
+ }
64
+ claudeSessionId = attrs['session.id'] || claudeSessionId;
65
+ model = attrs['model'] || model;
66
+ }
67
+ }
68
+ if (metric.name === 'claude_code.cost.usage') {
69
+ for (const dp of dataPoints) {
70
+ const value = Number(dp.asDouble ?? dp.asInt ?? dp.value ?? 0);
71
+ costUsd += value;
72
+ const attrs = parseAttributes(dp.attributes);
73
+ claudeSessionId = attrs['session.id'] || claudeSessionId;
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ if (!foundTokenMetric)
80
+ return null;
81
+ return {
82
+ inputTokens,
83
+ outputTokens,
84
+ cacheReadTokens,
85
+ cacheCreationTokens,
86
+ costUsd,
87
+ claudeSessionId,
88
+ model,
89
+ capturedAt: new Date(),
90
+ };
91
+ }
92
+ /**
93
+ * Parse OTLP attribute array into a key-value map.
94
+ * Attributes come as: [{ key: "name", value: { stringValue: "x" } }, ...]
95
+ */
96
+ function parseAttributes(attrs) {
97
+ const result = {};
98
+ if (!Array.isArray(attrs))
99
+ return result;
100
+ for (const attr of attrs) {
101
+ if (attr.key && attr.value) {
102
+ result[attr.key] = attr.value.stringValue
103
+ ?? attr.value.intValue
104
+ ?? attr.value.doubleValue
105
+ ?? String(attr.value.value ?? '');
106
+ }
107
+ }
108
+ return result;
109
+ }
110
+ /**
111
+ * Parse URL query string into key-value pairs.
112
+ */
113
+ function parseQuery(url) {
114
+ const idx = url.indexOf('?');
115
+ if (idx < 0)
116
+ return {};
117
+ const params = {};
118
+ for (const part of url.slice(idx + 1).split('&')) {
119
+ const [key, val] = part.split('=');
120
+ if (key)
121
+ params[decodeURIComponent(key)] = decodeURIComponent(val || '');
122
+ }
123
+ return params;
124
+ }
125
+ /**
126
+ * Handle incoming HTTP requests.
127
+ */
128
+ function handleRequest(req, res) {
129
+ const url = req.url || '';
130
+ const pathname = url.split('?')[0];
131
+ // OTLP metrics push endpoint
132
+ if (req.method === 'POST' && pathname === '/v1/metrics') {
133
+ let body = '';
134
+ req.on('data', (chunk) => { body += chunk; });
135
+ req.on('end', () => {
136
+ try {
137
+ const parsed = JSON.parse(body);
138
+ const snapshot = parseOtlpMetrics(parsed);
139
+ if (snapshot && snapshot.claudeSessionId) {
140
+ snapshots.set(snapshot.claudeSessionId, snapshot);
141
+ }
142
+ res.writeHead(200, { 'Content-Type': 'application/json' });
143
+ res.end('{}');
144
+ }
145
+ catch {
146
+ res.writeHead(400);
147
+ res.end('{"error":"invalid json"}');
148
+ }
149
+ });
150
+ return;
151
+ }
152
+ // Query snapshot — used by any FRAIM proxy to fetch token data
153
+ // ?sessionId=X returns that session's snapshot
154
+ // ?latest=true returns the most recently updated snapshot
155
+ if (req.method === 'GET' && pathname === '/v1/snapshot') {
156
+ const query = parseQuery(url);
157
+ let snapshot;
158
+ if (query['sessionId']) {
159
+ snapshot = snapshots.get(query['sessionId']);
160
+ }
161
+ else {
162
+ // Return most recently captured snapshot across all sessions
163
+ let latest;
164
+ for (const s of snapshots.values()) {
165
+ if (!latest || s.capturedAt > latest.capturedAt) {
166
+ latest = s;
167
+ }
168
+ }
169
+ snapshot = latest;
170
+ }
171
+ if (snapshot) {
172
+ res.writeHead(200, { 'Content-Type': 'application/json' });
173
+ res.end(JSON.stringify(snapshot));
174
+ }
175
+ else {
176
+ res.writeHead(404, { 'Content-Type': 'application/json' });
177
+ res.end('{"error":"no snapshot available"}');
178
+ }
179
+ return;
180
+ }
181
+ // Health check
182
+ if (req.method === 'GET' && pathname === '/health') {
183
+ res.writeHead(200, { 'Content-Type': 'application/json' });
184
+ res.end(JSON.stringify({
185
+ status: 'ok',
186
+ sessionCount: snapshots.size,
187
+ sessions: [...snapshots.keys()],
188
+ }));
189
+ return;
190
+ }
191
+ res.writeHead(404);
192
+ res.end('');
193
+ }
194
+ /**
195
+ * Fetch a token snapshot from the OTLP receiver via HTTP.
196
+ * Works whether this process owns the receiver or another proxy does.
197
+ *
198
+ * @param sessionId If provided, fetches that specific session's snapshot.
199
+ * If omitted, fetches the most recently updated snapshot.
200
+ * @param log Optional logging function
201
+ */
202
+ async function fetchSnapshot(sessionId, log) {
203
+ const port = parseInt(process.env.FRAIM_OTLP_PORT || String(DEFAULT_PORT), 10);
204
+ const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
205
+ try {
206
+ const controller = new AbortController();
207
+ const timeout = setTimeout(() => controller.abort(), 2000);
208
+ const resp = await fetch(`http://127.0.0.1:${port}/v1/snapshot${query}`, { signal: controller.signal });
209
+ clearTimeout(timeout);
210
+ if (!resp.ok)
211
+ return null;
212
+ const data = await resp.json();
213
+ if (data.inputTokens !== undefined) {
214
+ return {
215
+ ...data,
216
+ capturedAt: new Date(data.capturedAt),
217
+ };
218
+ }
219
+ return null;
220
+ }
221
+ catch {
222
+ log?.('OTLP receiver not reachable — no token snapshot available');
223
+ return null;
224
+ }
225
+ }
226
+ /**
227
+ * Start the OTLP metrics receiver HTTP server.
228
+ * Only one receiver runs per machine — if port is in use, that's fine;
229
+ * this proxy will query the existing receiver via HTTP.
230
+ *
231
+ * @param log Optional logging function
232
+ * @returns Server info if started, null if port was in use (not an error)
233
+ */
234
+ function startOtlpReceiver(log) {
235
+ const port = parseInt(process.env.FRAIM_OTLP_PORT || String(DEFAULT_PORT), 10);
236
+ try {
237
+ const server = (0, http_1.createServer)(handleRequest);
238
+ server.on('error', (err) => {
239
+ if (err.code === 'EADDRINUSE') {
240
+ log?.(`OTLP receiver: port ${port} in use — will query existing receiver via HTTP`);
241
+ }
242
+ else {
243
+ log?.(`OTLP receiver error: ${err.message}`);
244
+ }
245
+ });
246
+ server.listen(port, '127.0.0.1', () => {
247
+ log?.(`OTLP metrics receiver listening on http://127.0.0.1:${port}/v1/metrics`);
248
+ });
249
+ return { server, port };
250
+ }
251
+ catch (err) {
252
+ log?.(`Failed to start OTLP receiver: ${err.message}`);
253
+ return null;
254
+ }
255
+ }
256
+ /**
257
+ * Stop the OTLP receiver and clear stored snapshots.
258
+ */
259
+ function stopOtlpReceiver(server) {
260
+ server.close();
261
+ snapshots.clear();
262
+ }
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ /**
3
+ * Prometheus Scraper for Claude Code OTel Token Usage Metrics
4
+ *
5
+ * Scrapes Claude Code's local Prometheus endpoint to capture cumulative
6
+ * token usage counters. Returns raw snapshot values — delta computation
7
+ * happens server-side per architecture constraint 15.2.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.parsePrometheusText = parsePrometheusText;
11
+ exports.extractTokenSnapshot = extractTokenSnapshot;
12
+ exports.scrapeTokenSnapshot = scrapeTokenSnapshot;
13
+ /** Candidate Prometheus endpoint ports (OTel SDK defaults) */
14
+ const CANDIDATE_PORTS = [9464, 8888];
15
+ /** Candidate metric names for token usage (OTel dots→underscores, optional suffixes) */
16
+ const TOKEN_METRIC_CANDIDATES = [
17
+ 'claude_code_token_usage_total',
18
+ 'claude_code_token_usage',
19
+ 'claude_code_token_usage_tokens_total',
20
+ 'claude_code_token_usage_tokens',
21
+ ];
22
+ /** Candidate metric names for cost usage */
23
+ const COST_METRIC_CANDIDATES = [
24
+ 'claude_code_cost_usage_total',
25
+ 'claude_code_cost_usage',
26
+ 'claude_code_cost_usage_usd_total',
27
+ 'claude_code_cost_usage_usd',
28
+ ];
29
+ /**
30
+ * Parse Prometheus text exposition format into structured metrics.
31
+ *
32
+ * Format per line: metric_name{label1="val1",label2="val2"} value [timestamp]
33
+ * Lines starting with # are comments (HELP, TYPE). Empty lines are skipped.
34
+ */
35
+ function parsePrometheusText(text) {
36
+ const results = [];
37
+ for (const line of text.split('\n')) {
38
+ if (line.startsWith('#') || line.trim() === '')
39
+ continue;
40
+ // Match: metric_name{labels} value
41
+ const labeledMatch = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}\s+([\d.eE+-]+)/);
42
+ if (labeledMatch) {
43
+ const [, name, labelsStr, valueStr] = labeledMatch;
44
+ const labels = {};
45
+ for (const pair of labelsStr.matchAll(/(\w+)="([^"]*)"/g)) {
46
+ labels[pair[1]] = pair[2];
47
+ }
48
+ results.push({ name, labels, value: parseFloat(valueStr) });
49
+ continue;
50
+ }
51
+ // Match: metric_name value (no labels)
52
+ const noLabelMatch = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([\d.eE+-]+)/);
53
+ if (noLabelMatch) {
54
+ const [, name, valueStr] = noLabelMatch;
55
+ results.push({ name, labels: {}, value: parseFloat(valueStr) });
56
+ }
57
+ }
58
+ return results;
59
+ }
60
+ /**
61
+ * Extract a TokenSnapshot from parsed Prometheus metrics.
62
+ * Tries multiple candidate metric names for resilience to SDK version changes.
63
+ */
64
+ function extractTokenSnapshot(metrics) {
65
+ const snapshot = {
66
+ inputTokens: 0,
67
+ outputTokens: 0,
68
+ cacheReadTokens: 0,
69
+ cacheCreationTokens: 0,
70
+ costUsd: 0,
71
+ claudeSessionId: null,
72
+ model: null,
73
+ capturedAt: new Date(),
74
+ };
75
+ for (const m of metrics) {
76
+ if (TOKEN_METRIC_CANDIDATES.includes(m.name)) {
77
+ const type = m.labels['type'];
78
+ switch (type) {
79
+ case 'input':
80
+ snapshot.inputTokens = m.value;
81
+ break;
82
+ case 'output':
83
+ snapshot.outputTokens = m.value;
84
+ break;
85
+ case 'cacheRead':
86
+ snapshot.cacheReadTokens = m.value;
87
+ break;
88
+ case 'cacheCreation':
89
+ snapshot.cacheCreationTokens = m.value;
90
+ break;
91
+ }
92
+ snapshot.claudeSessionId = m.labels['session_id'] || snapshot.claudeSessionId;
93
+ snapshot.model = m.labels['model'] || snapshot.model;
94
+ }
95
+ if (COST_METRIC_CANDIDATES.includes(m.name)) {
96
+ snapshot.costUsd += m.value;
97
+ snapshot.claudeSessionId = m.labels['session_id'] || snapshot.claudeSessionId;
98
+ }
99
+ }
100
+ return snapshot;
101
+ }
102
+ /**
103
+ * Attempt to fetch Prometheus metrics from a candidate endpoint.
104
+ * Returns response text or null on failure.
105
+ */
106
+ async function tryFetch(port, timeoutMs) {
107
+ try {
108
+ const controller = new AbortController();
109
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
110
+ const resp = await fetch(`http://localhost:${port}/metrics`, {
111
+ signal: controller.signal,
112
+ });
113
+ clearTimeout(timeout);
114
+ if (resp.ok) {
115
+ return await resp.text();
116
+ }
117
+ return null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /**
124
+ * Scrape Claude Code's Prometheus endpoint and return a TokenSnapshot.
125
+ *
126
+ * Returns null if:
127
+ * - Prometheus endpoint is not available (OTel not enabled)
128
+ * - Scrape times out (>2s by default)
129
+ * - No matching token metrics found
130
+ *
131
+ * @param log Optional logging function for debug output
132
+ */
133
+ async function scrapeTokenSnapshot(log) {
134
+ const configuredPort = process.env.FRAIM_PROMETHEUS_PORT;
135
+ const ports = configuredPort ? [parseInt(configuredPort, 10)] : CANDIDATE_PORTS;
136
+ const timeoutMs = 2000;
137
+ for (const port of ports) {
138
+ const text = await tryFetch(port, timeoutMs);
139
+ if (text && text.includes('#')) {
140
+ const metrics = parsePrometheusText(text);
141
+ const snapshot = extractTokenSnapshot(metrics);
142
+ // Only return if we actually found token metrics
143
+ if (snapshot.inputTokens > 0 || snapshot.outputTokens > 0) {
144
+ log?.(`Captured token snapshot from localhost:${port} (input=${snapshot.inputTokens}, output=${snapshot.outputTokens})`);
145
+ return snapshot;
146
+ }
147
+ log?.(`Prometheus endpoint found at localhost:${port} but no token metrics matched`);
148
+ }
149
+ }
150
+ log?.('No Prometheus endpoint available — token snapshot skipped');
151
+ return null;
152
+ }
@@ -31,6 +31,7 @@ const local_registry_resolver_1 = require("../core/utils/local-registry-resolver
31
31
  const ai_mentor_1 = require("../core/ai-mentor");
32
32
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
33
33
  const usage_collector_js_1 = require("./usage-collector.js");
34
+ const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
34
35
  const learning_context_builder_js_1 = require("./learning-context-builder.js");
35
36
  /**
36
37
  * Handle template substitution logic separately for better testability
@@ -398,6 +399,7 @@ class FraimLocalMCPServer {
398
399
  this.machineInfo = null;
399
400
  this.repoInfo = null;
400
401
  this.engine = null;
402
+ this.otlpServer = null;
401
403
  this.writer = writer || process.stdout.write.bind(process.stdout);
402
404
  this.remoteUrl = process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me';
403
405
  this.apiKey = this.loadApiKey();
@@ -415,6 +417,8 @@ class FraimLocalMCPServer {
415
417
  // Initialize usage collector
416
418
  this.usageCollector = new usage_collector_js_1.UsageCollector();
417
419
  this.log('📊 Usage analytics collector initialized');
420
+ // Start OTLP metrics receiver for Claude Code token telemetry
421
+ this.otlpServer = (0, otlp_metrics_receiver_js_1.startOtlpReceiver)((msg) => this.log(`📊 ${msg}`));
418
422
  }
419
423
  /**
420
424
  * Load API key from environment variable or user config file
@@ -1212,8 +1216,16 @@ class FraimLocalMCPServer {
1212
1216
  getRegistryResolver(requestSessionId) {
1213
1217
  const projectRoot = this.findProjectRoot();
1214
1218
  this.log(`🔍 getRegistryResolver: projectRoot = ${projectRoot}`);
1215
- if (!projectRoot) {
1216
- this.log('⚠️ No project root found, override resolution disabled');
1219
+ // Determine effective FRAIM dir using shadow semantics
1220
+ const { getEffectiveFraimDir } = require('../core/utils/project-fraim-paths');
1221
+ const effectiveFraimDir = projectRoot
1222
+ ? getEffectiveFraimDir(projectRoot)
1223
+ : getEffectiveFraimDir(process.cwd());
1224
+ if (effectiveFraimDir) {
1225
+ this.log(`📂 Effective FRAIM dir: ${effectiveFraimDir}`);
1226
+ }
1227
+ if (!projectRoot && !effectiveFraimDir) {
1228
+ this.log('⚠️ No project root or user-level FRAIM found, override resolution disabled');
1217
1229
  // Return a resolver that always falls back to remote
1218
1230
  return new local_registry_resolver_1.LocalRegistryResolver({
1219
1231
  workspaceRoot: process.cwd(),
@@ -1224,8 +1236,15 @@ class FraimLocalMCPServer {
1224
1236
  });
1225
1237
  }
1226
1238
  else {
1239
+ // Determine if we need effectiveFraimDir override.
1240
+ // If the effective dir is user-level (~/.fraim/), it's NOT under projectRoot/fraim/,
1241
+ // so we pass it as effectiveFraimDir to bypass the getWorkspaceFraimPath logic.
1242
+ const workspaceRoot = projectRoot || process.cwd();
1243
+ const projectFraimDir = projectRoot ? (0, path_1.join)(projectRoot, 'fraim') : '';
1244
+ const needsEffectiveDirOverride = effectiveFraimDir && effectiveFraimDir !== projectFraimDir;
1227
1245
  return new local_registry_resolver_1.LocalRegistryResolver({
1228
- workspaceRoot: projectRoot,
1246
+ workspaceRoot,
1247
+ ...(needsEffectiveDirOverride ? { effectiveFraimDir } : {}),
1229
1248
  shouldFilter: (content) => this.isStub(content),
1230
1249
  remoteContentResolver: async (path) => {
1231
1250
  // Fetch parent content from remote for inheritance
@@ -1824,6 +1843,9 @@ class FraimLocalMCPServer {
1824
1843
  const cleanup = () => {
1825
1844
  clearInterval(uploadInterval);
1826
1845
  this.usageCollector.shutdown();
1846
+ if (this.otlpServer?.server) {
1847
+ (0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
1848
+ }
1827
1849
  };
1828
1850
  process.stdin.on('data', async (chunk) => {
1829
1851
  buffer += chunk;
@@ -1843,7 +1865,7 @@ class FraimLocalMCPServer {
1843
1865
  // Only send response if we got one (null means we handled it internally)
1844
1866
  if (response) {
1845
1867
  // Collect usage for all tools/call requests before sending response
1846
- this.collectUsageForResponse(message, response);
1868
+ await this.collectUsageForResponse(message, response);
1847
1869
  process.stdout.write(JSON.stringify(response) + '\n');
1848
1870
  }
1849
1871
  }
@@ -1900,7 +1922,7 @@ class FraimLocalMCPServer {
1900
1922
  /**
1901
1923
  * Collect usage analytics for tools/call requests
1902
1924
  */
1903
- collectUsageForResponse(request, response) {
1925
+ async collectUsageForResponse(request, response) {
1904
1926
  // Only collect usage for tools/call requests
1905
1927
  if (request.method !== 'tools/call') {
1906
1928
  return;
@@ -1919,6 +1941,19 @@ class FraimLocalMCPServer {
1919
1941
  const afterCount = this.usageCollector.getEventCount();
1920
1942
  if (afterCount > beforeCount) {
1921
1943
  this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
1944
+ // Fetch token snapshot from OTLP receiver for seekMentoring calls
1945
+ if (toolName === 'seekMentoring') {
1946
+ try {
1947
+ const snapshot = await (0, otlp_metrics_receiver_js_1.fetchSnapshot)(undefined, (msg) => this.log(`📊 ${msg}`));
1948
+ if (snapshot) {
1949
+ this.usageCollector.attachTokenSnapshot(snapshot);
1950
+ this.log(`📊 🔢 Token snapshot attached (input=${snapshot.inputTokens}, output=${snapshot.outputTokens}, session=${snapshot.claudeSessionId})`);
1951
+ }
1952
+ }
1953
+ catch (err) {
1954
+ this.log(`📊 ⚠️ Token snapshot fetch failed (non-blocking): ${err.message}`);
1955
+ }
1956
+ }
1922
1957
  }
1923
1958
  else {
1924
1959
  this.log(`📊 ⚠️ Event not queued - tool may not be tracked: ${toolName}`);
@@ -163,6 +163,15 @@ class UsageCollector {
163
163
  this.events = [];
164
164
  return eventsToUpload;
165
165
  }
166
+ /**
167
+ * Attach a token snapshot to the most recently queued event.
168
+ * Used to associate Prometheus-scraped token data with seekMentoring events.
169
+ */
170
+ attachTokenSnapshot(snapshot) {
171
+ if (this.events.length > 0) {
172
+ this.events[this.events.length - 1].tokenSnapshot = snapshot;
173
+ }
174
+ }
166
175
  /**
167
176
  * Get current event count
168
177
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.104",
3
+ "version": "2.0.106",
4
4
  "description": "FRAIM v2: Framework for Rigor-based AI Management - Transform from solo developer to AI manager orchestrating production-ready code with enterprise-grade discipline",
5
5
  "main": "index.js",
6
6
  "bin": {