fraim-framework 2.0.124 → 2.0.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ "use strict";
2
+ /**
3
+ * Codex Token Adapter — Issue #330 / R1.3
4
+ *
5
+ * Reads Codex CLI's per-session JSONL log at
6
+ * `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`, sums
7
+ * cumulative per-turn token counts, and converts to USD via the static
8
+ * price table.
9
+ *
10
+ * Verified against Codex CLI 0.125.0 session files. Token counts live at
11
+ * `event_msg.payload.info.total_token_usage.{input_tokens, output_tokens,
12
+ * cached_input_tokens, reasoning_output_tokens}`. Model lives on
13
+ * `turn_context.payload.model` (latest turn wins).
14
+ *
15
+ * Compliance: only counter values + model identifier exit this module.
16
+ * Prompt content, file paths, and other JSONL fields are explicitly
17
+ * dropped so PII never enters telemetry (C1).
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.captureCodexSnapshot = captureCodexSnapshot;
54
+ const fs = __importStar(require("node:fs"));
55
+ const os = __importStar(require("node:os"));
56
+ const path = __importStar(require("node:path"));
57
+ const agent_token_prices_js_1 = require("./agent-token-prices.js");
58
+ /**
59
+ * Read the Codex session file and produce a TokenSnapshot. Returns null
60
+ * (with optional log warning) if the file is missing, unreadable, or
61
+ * contains no token_count events.
62
+ */
63
+ async function captureCodexSnapshot(opts = {}) {
64
+ const timeoutMs = opts.timeoutMs ?? 250;
65
+ const start = Date.now();
66
+ const deadline = start + timeoutMs;
67
+ const file = opts.sessionFile ?? findLatestSessionFile(opts.sessionsRoot);
68
+ if (!file) {
69
+ opts.log?.('codex adapter: no session file found in ~/.codex/sessions');
70
+ return null;
71
+ }
72
+ let contents;
73
+ try {
74
+ contents = await fs.promises.readFile(file, 'utf8');
75
+ }
76
+ catch (err) {
77
+ opts.log?.(`codex adapter: cannot read ${file}: ${err.message}`);
78
+ return null;
79
+ }
80
+ let model = null;
81
+ let sessionId = null;
82
+ // Codex `total_token_usage` is cumulative; we use the highest seen
83
+ // (= most-recent cumulative) so partial reads during an active write
84
+ // don't regress.
85
+ let lastInput = 0;
86
+ let lastOutput = 0;
87
+ let lastCacheRead = 0;
88
+ let lastReasoningOutput = 0;
89
+ let foundAny = false;
90
+ const lines = contents.split('\n');
91
+ for (const line of lines) {
92
+ if (Date.now() > deadline) {
93
+ opts.log?.(`codex adapter: timeout after ${timeoutMs}ms; partial result`);
94
+ break;
95
+ }
96
+ if (!line.trim())
97
+ continue;
98
+ let row;
99
+ try {
100
+ row = JSON.parse(line);
101
+ }
102
+ catch {
103
+ continue; // skip malformed lines
104
+ }
105
+ if (!row || typeof row !== 'object')
106
+ continue;
107
+ // session_meta carries session id (model is not on this event).
108
+ if (row.type === 'session_meta' && row.payload && typeof row.payload === 'object') {
109
+ if (typeof row.payload.id === 'string')
110
+ sessionId = row.payload.id;
111
+ continue;
112
+ }
113
+ // turn_context carries the active model. Latest turn wins.
114
+ if (row.type === 'turn_context' && row.payload && typeof row.payload === 'object') {
115
+ if (typeof row.payload.model === 'string')
116
+ model = row.payload.model;
117
+ continue;
118
+ }
119
+ // event_msg with payload.type === 'token_count' carries cumulative usage.
120
+ if (row.type === 'event_msg' && row.payload && row.payload.type === 'token_count') {
121
+ const info = row.payload.info;
122
+ if (info && typeof info === 'object' && info.total_token_usage && typeof info.total_token_usage === 'object') {
123
+ const t = info.total_token_usage;
124
+ const i = numberOrZero(t.input_tokens);
125
+ const o = numberOrZero(t.output_tokens);
126
+ const cr = numberOrZero(t.cached_input_tokens);
127
+ const r = numberOrZero(t.reasoning_output_tokens);
128
+ if (i > lastInput)
129
+ lastInput = i;
130
+ if (o > lastOutput)
131
+ lastOutput = o;
132
+ if (cr > lastCacheRead)
133
+ lastCacheRead = cr;
134
+ if (r > lastReasoningOutput)
135
+ lastReasoningOutput = r;
136
+ foundAny = true;
137
+ }
138
+ }
139
+ }
140
+ if (!foundAny)
141
+ return null;
142
+ // Reasoning-output tokens are billed as output tokens by OpenAI today;
143
+ // fold them into the output count so the cost computation lines up
144
+ // with the user's bill.
145
+ const counts = {
146
+ inputTokens: lastInput,
147
+ outputTokens: lastOutput + lastReasoningOutput,
148
+ cacheReadTokens: lastCacheRead,
149
+ cacheCreationTokens: 0,
150
+ };
151
+ const costUsd = (0, agent_token_prices_js_1.computeCostUsd)('codex', model ?? undefined, counts);
152
+ // C1 — only the listed fields exit this module. No spread of row data,
153
+ // no prompt/content fields, no file paths.
154
+ return {
155
+ inputTokens: counts.inputTokens,
156
+ outputTokens: counts.outputTokens,
157
+ cacheReadTokens: counts.cacheReadTokens,
158
+ cacheCreationTokens: counts.cacheCreationTokens,
159
+ costUsd,
160
+ claudeSessionId: sessionId,
161
+ model,
162
+ capturedAt: new Date(),
163
+ };
164
+ }
165
+ function numberOrZero(v) {
166
+ return typeof v === 'number' && isFinite(v) ? v : 0;
167
+ }
168
+ /**
169
+ * Find the most recently modified `rollout-*.jsonl` under
170
+ * `~/.codex/sessions/YYYY/MM/DD/`. Returns null if the directory does not
171
+ * exist or is empty.
172
+ */
173
+ function findLatestSessionFile(sessionsRoot) {
174
+ try {
175
+ const root = sessionsRoot ?? path.join(os.homedir(), '.codex', 'sessions');
176
+ if (!fs.existsSync(root))
177
+ return null;
178
+ let latestPath = null;
179
+ let latestMtime = 0;
180
+ // Walk YYYY / MM / DD / rollout-*.jsonl. Bounded to three levels so
181
+ // a stray symlink can't blow up the scan.
182
+ const years = safeReaddir(root);
183
+ for (const year of years) {
184
+ const yearDir = path.join(root, year);
185
+ if (!isDirectory(yearDir))
186
+ continue;
187
+ for (const month of safeReaddir(yearDir)) {
188
+ const monthDir = path.join(yearDir, month);
189
+ if (!isDirectory(monthDir))
190
+ continue;
191
+ for (const day of safeReaddir(monthDir)) {
192
+ const dayDir = path.join(monthDir, day);
193
+ if (!isDirectory(dayDir))
194
+ continue;
195
+ for (const entry of safeReaddir(dayDir)) {
196
+ if (!entry.endsWith('.jsonl'))
197
+ continue;
198
+ const full = path.join(dayDir, entry);
199
+ try {
200
+ const stat = fs.statSync(full);
201
+ if (stat.mtimeMs > latestMtime) {
202
+ latestMtime = stat.mtimeMs;
203
+ latestPath = full;
204
+ }
205
+ }
206
+ catch { /* skip unreadable */ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return latestPath;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ function safeReaddir(dir) {
218
+ try {
219
+ return fs.readdirSync(dir);
220
+ }
221
+ catch {
222
+ return [];
223
+ }
224
+ }
225
+ function isDirectory(p) {
226
+ try {
227
+ return fs.statSync(p).isDirectory();
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
@@ -222,12 +222,19 @@ function readFrontmatter(content) {
222
222
  }
223
223
  return frontmatter;
224
224
  }
225
+ function isRetrospectiveSynthesized(value) {
226
+ if (value === undefined)
227
+ return false;
228
+ const normalized = value.trim().toLowerCase();
229
+ if (!normalized)
230
+ return false;
231
+ return normalized !== 'false' && normalized !== 'no' && normalized !== '0' && normalized !== 'null';
232
+ }
225
233
  function isUnsynthesizedRetrospective(filePath) {
226
234
  try {
227
235
  const content = (0, fs_1.readFileSync)(filePath, 'utf8');
228
236
  const frontmatter = readFrontmatter(content);
229
- const synthesized = frontmatter.synthesized?.toLowerCase();
230
- return synthesized !== 'true' && synthesized !== 'yes';
237
+ return !isRetrospectiveSynthesized(frontmatter.synthesized);
231
238
  }
232
239
  catch {
233
240
  return false;
@@ -340,7 +347,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
340
347
  if (l2PrefPresent)
341
348
  section += `\`${LEARNINGS_REL}/org-preferences.md\` (all entries)\n`;
342
349
  if (l2CoachPresent)
343
- section += `\`${LEARNINGS_REL}/org-manager-coaching.md\` (all entries)\n`;
350
+ section += `\`${LEARNINGS_REL}/org-manager-coaching.md\` (manager-facing; all entries)\n`;
344
351
  if (l2ValidatedPresent)
345
352
  section += `\`${LEARNINGS_REL}/org-validated-patterns.md\` (entries above score threshold)\n`;
346
353
  const l2DormantTotal = (l2MistakeStats?.dormant || 0) + (l2ValidatedStats?.dormant || 0);
@@ -354,7 +361,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
354
361
  if (l1PrefPresent)
355
362
  section += `\`${LEARNINGS_REL}/${resolvedUserId}-preferences.md\` (all entries)\n`;
356
363
  if (l1CoachPresent)
357
- section += `\`${LEARNINGS_REL}/${resolvedUserId}-manager-coaching.md\` (all entries)\n`;
364
+ section += `\`${LEARNINGS_REL}/${resolvedUserId}-manager-coaching.md\` (manager-facing; all entries)\n`;
358
365
  if (l1MistakePresent)
359
366
  section += `\`${LEARNINGS_REL}/${resolvedUserId}-mistake-patterns.md\` (entries above score threshold)\n`;
360
367
  if (l1ValidatedPresent)
@@ -371,7 +378,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
371
378
  section += `${l0CoachingCount} coaching moment${l0CoachingCount !== 1 ? 's' : ''} in \`${LEARNINGS_REL}/raw/${resolvedUserId}-*\`\n`;
372
379
  }
373
380
  if (l0RetroCount > 0) {
374
- section += `${l0RetroCount} retrospective${l0RetroCount !== 1 ? 's' : ''} in \`docs/retrospectives/${resolvedUserId}-*\` with \`synthesized: false\` or missing\n`;
381
+ section += `${l0RetroCount} retrospective${l0RetroCount !== 1 ? 's' : ''} in \`docs/retrospectives/${resolvedUserId}-*\` with unsynthesized or missing \`synthesized\` frontmatter\n`;
375
382
  }
376
383
  section += '\n';
377
384
  }
@@ -381,20 +388,26 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
381
388
  const backlogTriggered = totalL0 >= BACKLOG_MIN || (oldestAgeDays >= OLDEST_AGE_DAYS_TRIGGER && totalL0 > 0);
382
389
  if (forJob) {
383
390
  if (hasL2 || hasL1) {
384
- section += 'Use the relevant patterns, preferences, and coaching signals in this job.\n';
391
+ section += 'Use the relevant patterns and preferences in this job.\n';
392
+ if (l1CoachPresent || l2CoachPresent) {
393
+ section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
394
+ }
385
395
  }
386
396
  if (backlogTriggered) {
387
397
  section += '\n';
388
- section += `Warning: ${totalL0} unprocessed signals pending. Consider running \`end-of-day-debrief\` before starting today's work.\n`;
398
+ section += `Warning: ${totalL0} unprocessed signals pending. Consider running \`sleep-on-learnings\` before starting today's work.\n`;
389
399
  section += renderBacklogDetail(oldestAgeDays, agingRisk);
390
400
  }
391
401
  }
392
402
  else {
393
403
  section += 'Use this synthesized learning context throughout the session.\n';
404
+ if (l1CoachPresent || l2CoachPresent) {
405
+ section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
406
+ }
394
407
  if (backlogTriggered) {
395
408
  section += '\n';
396
409
  section += `Warning: synthesis overdue with ${totalL0} unprocessed signals.\n`;
397
- section += 'Run `end-of-day-debrief` before starting today\'s work.\n';
410
+ section += 'Run `sleep-on-learnings` before starting today\'s work.\n';
398
411
  section += renderBacklogDetail(oldestAgeDays, agingRisk);
399
412
  }
400
413
  }
@@ -33,6 +33,7 @@ const quality_evidence_1 = require("../core/quality-evidence");
33
33
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
34
34
  const usage_collector_js_1 = require("./usage-collector.js");
35
35
  const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
36
+ const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
36
37
  const learning_context_builder_js_1 = require("./learning-context-builder.js");
37
38
  /**
38
39
  * Handle template substitution logic separately for better testability
@@ -617,9 +618,10 @@ class FraimLocalMCPServer {
617
618
  // Fall back to config if git fails
618
619
  repoUrl = this.config?.repository?.url || '';
619
620
  }
621
+ const workspaceLabel = (0, path_1.parse)(projectDir).base || 'workspace';
620
622
  if (!repoUrl) {
621
- this.log('No git repository found and no config available');
622
- return null;
623
+ this.log(`No git repository found; falling back to workspace folder label: ${workspaceLabel}`);
624
+ repoUrl = workspaceLabel;
623
625
  }
624
626
  // Parse repository identity from URL
625
627
  let name = '';
@@ -646,6 +648,9 @@ class FraimLocalMCPServer {
646
648
  name = segments[segments.length - 1] || '';
647
649
  namespace = segments.slice(0, -1).join('/');
648
650
  }
651
+ else if (!repoUrl.includes('/') && !repoUrl.includes('\\') && !repoUrl.includes('://')) {
652
+ name = repoUrl;
653
+ }
649
654
  else if (this.config?.repository) {
650
655
  // Fall back to config if URL parsing fails
651
656
  owner = this.config.repository.owner || '';
@@ -1503,6 +1508,11 @@ class FraimLocalMCPServer {
1503
1508
  this.engine.setMachineInfo(this.machineInfo);
1504
1509
  this.engine.setRepoInfo(this.repoInfo);
1505
1510
  }
1511
+ // Issue #330: capture agent identity on the usage collector so every
1512
+ // queued event is attributed without re-reading args downstream.
1513
+ if (this.usageCollector && this.agentInfo) {
1514
+ this.usageCollector.setAgent(this.agentInfo.name ?? null, this.agentInfo.model ?? null);
1515
+ }
1506
1516
  // In a proxy setup, the remote server resolves the API key ID during event upload.
1507
1517
  // No local resolution needed.
1508
1518
  // Update the request with injected info
@@ -1989,17 +1999,28 @@ class FraimLocalMCPServer {
1989
1999
  const afterCount = this.usageCollector.getEventCount();
1990
2000
  if (afterCount > beforeCount) {
1991
2001
  this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
1992
- // Fetch token snapshot from OTLP receiver for seekMentoring calls
2002
+ // Issue #330: pluggable token capture by agent.
1993
2003
  if (toolName === 'seekMentoring') {
1994
2004
  try {
1995
- const snapshot = await (0, otlp_metrics_receiver_js_1.fetchSnapshot)(undefined, (msg) => this.log(`📊 ${msg}`));
1996
- if (snapshot) {
1997
- this.usageCollector.attachTokenSnapshot(snapshot);
1998
- this.log(`📊 🔢 Token snapshot attached (input=${snapshot.inputTokens}, output=${snapshot.outputTokens}, session=${snapshot.claudeSessionId})`);
2005
+ const agentName = this.agentInfo?.name;
2006
+ const agentModel = this.agentInfo?.model;
2007
+ const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
2008
+ const result = await adapter.capture({
2009
+ agent: agentName,
2010
+ model: agentModel,
2011
+ log: (msg) => this.log(`📊 ${msg}`),
2012
+ });
2013
+ if (result.snapshot) {
2014
+ this.usageCollector.attachTokenSnapshot(result.snapshot);
2015
+ this.log(`📊 🔢 Token snapshot attached via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
2016
+ }
2017
+ else if (result.reason) {
2018
+ this.usageCollector.attachCaptureUnavailable(result.reason);
2019
+ this.log(`📊 ℹ️ Token capture unavailable (${result.reason}) for adapter ${adapter.id}`);
1999
2020
  }
2000
2021
  }
2001
2022
  catch (err) {
2002
- this.log(`📊 ⚠️ Token snapshot fetch failed (non-blocking): ${err.message}`);
2023
+ this.log(`📊 ⚠️ Token snapshot capture failed (non-blocking): ${err.message}`);
2003
2024
  }
2004
2025
  }
2005
2026
  }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * Token Adapter Registry — Issue #330 / R1.1, R1.4, R1.5
4
+ *
5
+ * Pluggable dispatch by agent name. Each adapter implements
6
+ * `capture(ctx)` returning either a `TokenSnapshot` or `null` plus a
7
+ * typed reason (`tokenCaptureUnavailableReason`) so the UI can explain
8
+ * coverage gaps.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.resolveTokenAdapter = resolveTokenAdapter;
12
+ const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
13
+ const codex_token_adapter_js_1 = require("./codex-token-adapter.js");
14
+ const claudeCodeAdapter = {
15
+ id: 'claude-code',
16
+ async capture(ctx) {
17
+ const snap = await (0, otlp_metrics_receiver_js_1.fetchSnapshot)(ctx.agentSessionId, ctx.log);
18
+ if (snap)
19
+ return { snapshot: snap };
20
+ return { snapshot: null, reason: 'otlp_receiver_unreachable' };
21
+ },
22
+ };
23
+ const codexAdapter = {
24
+ id: 'codex',
25
+ async capture(ctx) {
26
+ try {
27
+ const snap = await (0, codex_token_adapter_js_1.captureCodexSnapshot)({ log: ctx.log });
28
+ if (snap)
29
+ return { snapshot: snap };
30
+ return { snapshot: null, reason: 'codex_session_log_not_found' };
31
+ }
32
+ catch (err) {
33
+ ctx.log?.(`codex adapter threw: ${err.message}`);
34
+ return { snapshot: null, reason: 'codex_adapter_error' };
35
+ }
36
+ },
37
+ };
38
+ const nullAdapterFor = (canonicalAgent) => ({
39
+ id: 'null',
40
+ async capture() {
41
+ return { snapshot: null, reason: `agent_not_supported:${canonicalAgent}` };
42
+ },
43
+ });
44
+ /**
45
+ * Map of canonical agent ids to adapters. Keys are lower-cased.
46
+ * Aliases (e.g. "claude" → claude-code) are handled in the resolver below.
47
+ */
48
+ const ADAPTERS = {
49
+ 'claude-code': claudeCodeAdapter,
50
+ 'claude': claudeCodeAdapter,
51
+ 'codex': codexAdapter,
52
+ };
53
+ /**
54
+ * Resolve an adapter by agent.name (case-insensitive). Always returns
55
+ * an adapter — falls back to a null adapter that reports
56
+ * "agent_not_supported:<name>".
57
+ */
58
+ function resolveTokenAdapter(agentName) {
59
+ const canonical = (agentName || 'unknown').toLowerCase().trim();
60
+ const adapter = ADAPTERS[canonical];
61
+ if (adapter)
62
+ return adapter;
63
+ return nullAdapterFor(canonical || 'unknown');
64
+ }
@@ -14,6 +14,9 @@ class UsageCollector {
14
14
  this.events = [];
15
15
  this.userId = null;
16
16
  this.repoIdentifier = null;
17
+ // Issue #330: per-session agent attribution captured at fraim_connect.
18
+ this.agentName = null;
19
+ this.agentModel = null;
17
20
  }
18
21
  static resolveMentoringJobName(args) {
19
22
  if (!args || typeof args !== 'object') {
@@ -40,6 +43,16 @@ class UsageCollector {
40
43
  setRepoIdentifier(repoIdentifier) {
41
44
  this.repoIdentifier = repoIdentifier;
42
45
  }
46
+ /**
47
+ * Capture the agent identity at fraim_connect time so subsequent events
48
+ * can be attributed to the right agent without re-reading args.
49
+ */
50
+ setAgent(name, model) {
51
+ this.agentName = name ? name.toLowerCase() : null;
52
+ this.agentModel = model || null;
53
+ }
54
+ getAgentName() { return this.agentName; }
55
+ getAgentModel() { return this.agentModel; }
43
56
  /**
44
57
  * Collect MCP tool call event
45
58
  */
@@ -67,6 +80,8 @@ class UsageCollector {
67
80
  success,
68
81
  category: parsed.category,
69
82
  repoIdentifier: this.repoIdentifier || undefined,
83
+ agentName: this.agentName || undefined,
84
+ agentModel: this.agentModel || undefined,
70
85
  args: Object.keys(analyticsArgs).length > 0 ? analyticsArgs : undefined
71
86
  };
72
87
  this.events.push(event);
@@ -178,6 +193,16 @@ class UsageCollector {
178
193
  this.events[this.events.length - 1].tokenSnapshot = snapshot;
179
194
  }
180
195
  }
196
+ /**
197
+ * Issue #330: when a token capture adapter returns null, record the
198
+ * typed reason on the most recently queued event so the dashboard
199
+ * can explain coverage gaps to the user.
200
+ */
201
+ attachCaptureUnavailable(reason) {
202
+ if (this.events.length > 0) {
203
+ this.events[this.events.length - 1].tokenCaptureUnavailableReason = reason;
204
+ }
205
+ }
181
206
  /**
182
207
  * Get current event count
183
208
  */
package/index.js CHANGED
@@ -1,85 +1,85 @@
1
1
  #!/usr/bin/env node
2
-
3
- /**
4
- * FRAIM Framework - Smart Entry Point
5
- * This file handles both production (dist/) and development (src/) environments.
6
- */
7
-
8
- const path = require('path');
9
- const fs = require('fs');
10
- const { spawnSync } = require('child_process');
11
-
12
- /**
13
- * Runs the CLI using either the compiled JS or the source TS via tsx
14
- */
15
- function runCLI() {
16
- const distPath = path.join(__dirname, 'dist', 'src', 'cli', 'fraim.js');
17
- const srcPath = path.join(__dirname, 'src', 'cli', 'fraim.ts');
18
-
19
- // 1. Check if we have a compiled version (Production / CI)
20
- if (fs.existsSync(distPath)) {
21
- require(distPath);
22
- return;
23
- }
24
-
25
- // 2. Explicitly fail in production if dist/ is missing
26
- if (process.env.NODE_ENV === 'production') {
27
- console.error('❌ FRAIM Error: Production build (dist/) not found.');
28
- console.error('In production environments, you must run the compiled version.');
29
- console.error(`Expected: ${distPath}`);
30
- process.exit(1);
31
- }
32
-
33
- // 3. Fallback to source version using tsx (Development)
34
- if (fs.existsSync(srcPath)) {
35
- // We use spawnSync to run tsx so we don't have to require it in memory
36
- // if it's not needed, and it handles the process arguments correctly.
37
- //
38
- // IMPORTANT FIX: Directory names with spaces and dashes (e.g., "FRAIM - Issue 166")
39
- // cause argument parsing issues on Windows when shell: true is used.
40
- // Without quoting, a path like "C:\...\FRAIM - Issue 166\src\cli\fraim.ts" gets
41
- // split into multiple arguments, with the dash interpreted as a command flag,
42
- // resulting in "error: unknown command '-'".
43
- //
44
- // Solution: On Windows with shell: true, quote paths containing spaces.
45
- // On Unix with shell: false, pass the path unquoted (spawnSync handles it correctly).
46
- const isWindows = process.platform === 'win32';
47
-
48
- // On Windows with shell, quote paths with spaces to prevent shell misinterpretation
49
- // On Unix without shell, pass path as-is (spawnSync handles spaces correctly)
50
- const processedSrcPath = (isWindows && srcPath.includes(' '))
51
- ? `"${srcPath}"`
52
- : srcPath;
53
-
54
- const result = spawnSync(
55
- 'npx',
56
- ['tsx', processedSrcPath, ...process.argv.slice(2)],
57
- {
58
- stdio: 'inherit',
59
- shell: isWindows, // Windows needs shell for npx, Unix doesn't
60
- windowsHide: true
61
- }
62
- );
63
- process.exit(result.status || 0);
64
- }
65
-
66
- console.error('❌ FRAIM Error: Could not find CLI entry point.');
67
- console.error('Expected one of:');
68
- console.error(` - ${distPath}`);
69
- console.error(` - ${srcPath}`);
70
- process.exit(1);
71
- }
72
-
73
- // Global programmatic exports
74
- module.exports = {
75
- FRAIM_INFO: {
76
- name: 'FRAIM',
77
- version: '2.0.98',
78
- repository: 'https://github.com/mathursrus/FRAIM'
79
- }
80
- };
81
-
82
- // If this file is run directly (via npx or global link), run the CLI
83
- if (require.main === module) {
84
- runCLI();
2
+
3
+ /**
4
+ * FRAIM Framework - Smart Entry Point
5
+ * This file handles both production (dist/) and development (src/) environments.
6
+ */
7
+
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const { spawnSync } = require('child_process');
11
+
12
+ /**
13
+ * Runs the CLI using either the compiled JS or the source TS via tsx
14
+ */
15
+ function runCLI() {
16
+ const distPath = path.join(__dirname, 'dist', 'src', 'cli', 'fraim.js');
17
+ const srcPath = path.join(__dirname, 'src', 'cli', 'fraim.ts');
18
+
19
+ // 1. Check if we have a compiled version (Production / CI)
20
+ if (fs.existsSync(distPath)) {
21
+ require(distPath);
22
+ return;
23
+ }
24
+
25
+ // 2. Explicitly fail in production if dist/ is missing
26
+ if (process.env.NODE_ENV === 'production') {
27
+ console.error('❌ FRAIM Error: Production build (dist/) not found.');
28
+ console.error('In production environments, you must run the compiled version.');
29
+ console.error(`Expected: ${distPath}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ // 3. Fallback to source version using tsx (Development)
34
+ if (fs.existsSync(srcPath)) {
35
+ // We use spawnSync to run tsx so we don't have to require it in memory
36
+ // if it's not needed, and it handles the process arguments correctly.
37
+ //
38
+ // IMPORTANT FIX: Directory names with spaces and dashes (e.g., "FRAIM - Issue 166")
39
+ // cause argument parsing issues on Windows when shell: true is used.
40
+ // Without quoting, a path like "C:\...\FRAIM - Issue 166\src\cli\fraim.ts" gets
41
+ // split into multiple arguments, with the dash interpreted as a command flag,
42
+ // resulting in "error: unknown command '-'".
43
+ //
44
+ // Solution: On Windows with shell: true, quote paths containing spaces.
45
+ // On Unix with shell: false, pass the path unquoted (spawnSync handles it correctly).
46
+ const isWindows = process.platform === 'win32';
47
+
48
+ // On Windows with shell, quote paths with spaces to prevent shell misinterpretation
49
+ // On Unix without shell, pass path as-is (spawnSync handles spaces correctly)
50
+ const processedSrcPath = (isWindows && srcPath.includes(' '))
51
+ ? `"${srcPath}"`
52
+ : srcPath;
53
+
54
+ const result = spawnSync(
55
+ 'npx',
56
+ ['tsx', processedSrcPath, ...process.argv.slice(2)],
57
+ {
58
+ stdio: 'inherit',
59
+ shell: isWindows, // Windows needs shell for npx, Unix doesn't
60
+ windowsHide: true
61
+ }
62
+ );
63
+ process.exit(result.status || 0);
64
+ }
65
+
66
+ console.error('❌ FRAIM Error: Could not find CLI entry point.');
67
+ console.error('Expected one of:');
68
+ console.error(` - ${distPath}`);
69
+ console.error(` - ${srcPath}`);
70
+ process.exit(1);
71
+ }
72
+
73
+ // Global programmatic exports
74
+ module.exports = {
75
+ FRAIM_INFO: {
76
+ name: 'FRAIM',
77
+ version: '2.0.98',
78
+ repository: 'https://github.com/mathursrus/FRAIM'
79
+ }
80
+ };
81
+
82
+ // If this file is run directly (via npx or global link), run the CLI
83
+ if (require.main === module) {
84
+ runCLI();
85
85
  }