fraim-framework 2.0.124 → 2.0.127

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 (46) hide show
  1. package/bin/fraim.js +1 -1
  2. package/dist/src/ai-hub/catalog.js +280 -44
  3. package/dist/src/ai-hub/desktop-main.js +2 -2
  4. package/dist/src/ai-hub/hosts.js +384 -10
  5. package/dist/src/ai-hub/server.js +255 -9
  6. package/dist/src/cli/commands/add-ide.js +4 -3
  7. package/dist/src/cli/commands/first-run.js +61 -0
  8. package/dist/src/cli/commands/hub.js +4 -4
  9. package/dist/src/cli/commands/init-project.js +4 -4
  10. package/dist/src/cli/commands/setup.js +4 -3
  11. package/dist/src/cli/commands/sync.js +21 -2
  12. package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
  13. package/dist/src/cli/fraim.js +2 -0
  14. package/dist/src/cli/mcp/ide-formats.js +29 -1
  15. package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
  16. package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
  17. package/dist/src/cli/setup/ide-detector.js +32 -1
  18. package/dist/src/cli/setup/ide-global-integration.js +5 -1
  19. package/dist/src/cli/setup/ide-invocation-surfaces.js +70 -17
  20. package/dist/src/cli/setup/mcp-config-generator.js +12 -1
  21. package/dist/src/cli/utils/agent-adapters.js +12 -2
  22. package/dist/src/cli/utils/project-bootstrap.js +4 -3
  23. package/dist/src/core/quality-evidence.js +81 -8
  24. package/dist/src/core/utils/git-utils.js +32 -7
  25. package/dist/src/core/utils/job-aliases.js +47 -0
  26. package/dist/src/core/utils/workflow-parser.js +3 -5
  27. package/dist/src/first-run/install-state.js +68 -0
  28. package/dist/src/first-run/server.js +153 -0
  29. package/dist/src/first-run/session-service.js +302 -0
  30. package/dist/src/first-run/types.js +40 -0
  31. package/dist/src/local-mcp-server/agent-token-prices.js +114 -0
  32. package/dist/src/local-mcp-server/codex-token-adapter.js +232 -0
  33. package/dist/src/local-mcp-server/learning-context-builder.js +21 -8
  34. package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
  35. package/dist/src/local-mcp-server/stdio-server.js +70 -17
  36. package/dist/src/local-mcp-server/token-adapter-registry.js +64 -0
  37. package/dist/src/local-mcp-server/usage-collector.js +25 -0
  38. package/index.js +83 -83
  39. package/package.json +7 -1
  40. package/public/ai-hub/index.html +149 -102
  41. package/public/ai-hub/script.js +1154 -271
  42. package/public/ai-hub/styles.css +753 -450
  43. package/public/first-run/index.html +221 -0
  44. package/public/first-run/script.js +361 -0
  45. package/dist/src/cli/services/device-flow-service.js +0 -83
  46. package/dist/src/local-mcp-server/prometheus-scraper.js +0 -152
@@ -1,12 +1,209 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createHubEvent = exports.createHubMessage = exports.FakeHostRuntime = exports.CliHostRuntime = void 0;
3
+ exports.createHubEvent = exports.createHubMessage = exports.ScriptedHostRuntime = exports.FakeHostRuntime = exports.CliHostRuntime = void 0;
4
+ exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
5
+ exports.parseUsageSignal = parseUsageSignal;
6
+ exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
4
7
  exports.detectEmployees = detectEmployees;
5
8
  exports.buildStartPlan = buildStartPlan;
6
9
  exports.buildContinuePlan = buildContinuePlan;
7
10
  exports.parseHostLine = parseHostLine;
8
11
  const crypto_1 = require("crypto");
9
12
  const child_process_1 = require("child_process");
13
+ // Parse a single line of host stdout looking for a seekMentoring tool-use
14
+ // signal. Returns null if the line does not contain one. Supports both
15
+ // hosts FRAIM ships against today:
16
+ // - Claude Code: tool_use blocks inside message.content with name
17
+ // 'mcp__fraim__seekMentoring' (MCP names are prefixed in Claude's
18
+ // stream-json output).
19
+ // - Codex: top-level events of type 'item.started' / 'item.completed'
20
+ // whose item.type === 'mcp_tool_call' and item.tool === 'seekMentoring'
21
+ // (Codex separates the MCP server name from the tool name).
22
+ function parseSeekMentoringSignal(line) {
23
+ if (!line.includes('seekMentoring'))
24
+ return null;
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(line);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ if (typeof parsed !== 'object' || parsed === null)
33
+ return null;
34
+ const obj = parsed;
35
+ // Codex shape: { type: 'item.started' | 'item.completed', item: { type: 'mcp_tool_call', tool: 'seekMentoring', arguments: {...} } }.
36
+ // Match both started + completed; the call carries the same arguments
37
+ // either way, so applying the signal at the earliest is fine.
38
+ if ((obj.type === 'item.started' || obj.type === 'item.completed') &&
39
+ typeof obj.item === 'object' && obj.item !== null) {
40
+ const item = obj.item;
41
+ const itemType = item.type;
42
+ const tool = typeof item.tool === 'string' ? item.tool : null;
43
+ if (itemType === 'mcp_tool_call' &&
44
+ (tool === 'seekMentoring' || tool === 'mcp__fraim__seekMentoring')) {
45
+ const args = item.arguments;
46
+ const sig = extractSignalFromArgs(args);
47
+ if (sig)
48
+ return sig;
49
+ }
50
+ }
51
+ // Claude Code shape: tool_use blocks live inside parsed.message.content.
52
+ // The name is MCP-prefixed (e.g. 'mcp__fraim__seekMentoring').
53
+ const candidates = [obj];
54
+ if (Array.isArray(obj.content))
55
+ candidates.push(...obj.content);
56
+ if (typeof obj.message === 'object' && obj.message !== null) {
57
+ const msg = obj.message;
58
+ if (Array.isArray(msg.content))
59
+ candidates.push(...msg.content);
60
+ }
61
+ for (const candidate of candidates) {
62
+ if (typeof candidate !== 'object' || candidate === null)
63
+ continue;
64
+ const c = candidate;
65
+ const isToolUse = c.type === 'tool_use' || c.type === 'function_call';
66
+ const nameField = typeof c.name === 'string' ? c.name : (typeof c.tool_name === 'string' ? c.tool_name : '');
67
+ const isSeekMentoring = nameField === 'seekMentoring' ||
68
+ nameField === 'mcp__fraim__seekMentoring' ||
69
+ nameField.endsWith('seekMentoring');
70
+ if (!isToolUse || !isSeekMentoring)
71
+ continue;
72
+ const input = (c.input || c.arguments || c.parameters);
73
+ const sig = extractSignalFromArgs(input);
74
+ if (sig)
75
+ return sig;
76
+ }
77
+ return null;
78
+ }
79
+ // Issue #347 — extract per-turn usage from the host's JSON stream.
80
+ // Codex: `{"type":"turn.completed","usage":{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}}`.
81
+ // Claude Code: `{"type":"result", ..., "usage":{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}, "total_cost_usd": ...}`.
82
+ function parseUsageSignal(line) {
83
+ if (!line.includes('usage'))
84
+ return null;
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(line);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ if (typeof parsed !== 'object' || parsed === null)
93
+ return null;
94
+ const obj = parsed;
95
+ // Codex turn.completed shape. Codex's `input_tokens` is cumulative
96
+ // and INCLUDES the cached portion, so normalize by subtracting.
97
+ if (obj.type === 'turn.completed' && typeof obj.usage === 'object' && obj.usage !== null) {
98
+ const u = obj.usage;
99
+ const totalInput = numberOrNull(u.input_tokens);
100
+ const cachedInput = numberOrNull(u.cached_input_tokens) ?? 0;
101
+ const outputTokens = numberOrNull(u.output_tokens);
102
+ if (totalInput === null && outputTokens === null)
103
+ return null;
104
+ const nonCached = Math.max(0, (totalInput ?? 0) - cachedInput);
105
+ return {
106
+ nonCachedInputTokens: nonCached,
107
+ cachedInputTokens: cachedInput,
108
+ cacheCreationTokens: 0, // Codex does not bill cache creation separately
109
+ outputTokens: outputTokens ?? 0,
110
+ reasoningTokens: numberOrNull(u.reasoning_output_tokens) ?? undefined,
111
+ };
112
+ }
113
+ // Claude Code result shape. Anthropic semantics: input_tokens,
114
+ // cache_read_input_tokens, and cache_creation_input_tokens are
115
+ // mutually exclusive — pass each through to its own bucket.
116
+ if (obj.type === 'result' && typeof obj.usage === 'object' && obj.usage !== null) {
117
+ const u = obj.usage;
118
+ const inputTokens = numberOrNull(u.input_tokens);
119
+ const outputTokens = numberOrNull(u.output_tokens);
120
+ if (inputTokens === null && outputTokens === null)
121
+ return null;
122
+ const costUsd = numberOrNull(obj.total_cost_usd);
123
+ return {
124
+ nonCachedInputTokens: inputTokens ?? 0,
125
+ cachedInputTokens: numberOrNull(u.cache_read_input_tokens) ?? 0,
126
+ cacheCreationTokens: numberOrNull(u.cache_creation_input_tokens) ?? 0,
127
+ outputTokens: outputTokens ?? 0,
128
+ costUsd: costUsd ?? undefined,
129
+ };
130
+ }
131
+ return null;
132
+ }
133
+ // Issue #347 — extract agent identity from the fraim_connect tool call.
134
+ // Both hosts pass `agent: { name, model }` in the call's arguments.
135
+ function parseAgentIdentitySignal(line) {
136
+ if (!line.includes('fraim_connect'))
137
+ return null;
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(line);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ if (typeof parsed !== 'object' || parsed === null)
146
+ return null;
147
+ const obj = parsed;
148
+ // Codex shape.
149
+ if ((obj.type === 'item.started' || obj.type === 'item.completed') && typeof obj.item === 'object' && obj.item !== null) {
150
+ const item = obj.item;
151
+ if (item.type === 'mcp_tool_call' && item.tool === 'fraim_connect') {
152
+ return readAgentFromArgs(item.arguments);
153
+ }
154
+ }
155
+ // Claude Code shape.
156
+ const candidates = [obj];
157
+ if (typeof obj.message === 'object' && obj.message !== null) {
158
+ const msg = obj.message;
159
+ if (Array.isArray(msg.content))
160
+ candidates.push(...msg.content);
161
+ }
162
+ for (const candidate of candidates) {
163
+ if (typeof candidate !== 'object' || candidate === null)
164
+ continue;
165
+ const c = candidate;
166
+ if (c.type !== 'tool_use' && c.type !== 'function_call')
167
+ continue;
168
+ const name = typeof c.name === 'string' ? c.name : '';
169
+ if (!name.endsWith('fraim_connect'))
170
+ continue;
171
+ const sig = readAgentFromArgs((c.input || c.arguments));
172
+ if (sig)
173
+ return sig;
174
+ }
175
+ return null;
176
+ }
177
+ function readAgentFromArgs(args) {
178
+ if (!args || typeof args.agent !== 'object' || args.agent === null)
179
+ return null;
180
+ const agent = args.agent;
181
+ const agentName = typeof agent.name === 'string' ? agent.name : null;
182
+ const agentModel = typeof agent.model === 'string' ? agent.model : null;
183
+ if (!agentName || !agentModel)
184
+ return null;
185
+ return { agentName, agentModel };
186
+ }
187
+ function numberOrNull(v) {
188
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
189
+ }
190
+ function extractSignalFromArgs(args) {
191
+ if (!args)
192
+ return null;
193
+ const phaseId = typeof args.currentPhase === 'string' ? args.currentPhase : null;
194
+ const rawStatus = typeof args.status === 'string' ? args.status : null;
195
+ if (!phaseId || !rawStatus)
196
+ return null;
197
+ const phaseStatus = ['starting', 'complete', 'incomplete', 'failure'].includes(rawStatus)
198
+ ? rawStatus
199
+ : 'starting';
200
+ const findings = args.findings;
201
+ const findingsText = findings && typeof findings.summary === 'string' ? findings.summary : undefined;
202
+ const discriminant = typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined;
203
+ const jobName = typeof args.jobName === 'string' ? args.jobName : undefined;
204
+ const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
205
+ return { phaseId, phaseStatus, findingsText, discriminant, jobName, jobId };
206
+ }
10
207
  const EMPLOYEE_LABELS = {
11
208
  codex: 'Codex',
12
209
  claude: 'Claude Code',
@@ -83,39 +280,51 @@ function parseHostLine(hostId, line) {
83
280
  const trimmed = line.trim();
84
281
  if (!trimmed)
85
282
  return {};
283
+ // Issue #347: scan every line for the three signals the Hub UI cares
284
+ // about — seekMentoring (drives the tracker), turn-level usage
285
+ // (drives the totals tokens/cost), and the agent identity from
286
+ // fraim_connect (used to look up cost when the host doesn't emit it).
287
+ const seekMentoring = parseSeekMentoringSignal(trimmed);
288
+ const usage = parseUsageSignal(trimmed);
289
+ const agentIdentity = parseAgentIdentitySignal(trimmed);
290
+ const withSignal = (event) => {
291
+ if (!seekMentoring && !usage && !agentIdentity)
292
+ return event;
293
+ return { ...event, ...(seekMentoring ? { seekMentoring } : {}), ...(usage ? { usage } : {}), ...(agentIdentity ? { agentIdentity } : {}) };
294
+ };
86
295
  if (hostId === 'codex') {
87
296
  try {
88
297
  const parsed = JSON.parse(trimmed);
89
298
  if (parsed.type === 'thread.started' && parsed.thread_id) {
90
- return { sessionId: parsed.thread_id, raw: trimmed };
299
+ return withSignal({ sessionId: parsed.thread_id, raw: trimmed });
91
300
  }
92
301
  if (parsed.type === 'item.completed' && parsed.item?.type === 'agent_message' && parsed.item.text) {
93
- return { message: parsed.item.text, raw: trimmed };
302
+ return withSignal({ message: parsed.item.text, raw: trimmed });
94
303
  }
95
- return { raw: trimmed };
304
+ return withSignal({ raw: trimmed });
96
305
  }
97
306
  catch {
98
- return { raw: trimmed };
307
+ return withSignal({ raw: trimmed });
99
308
  }
100
309
  }
101
310
  try {
102
311
  const parsed = JSON.parse(trimmed);
103
312
  if (parsed.type === 'system' && parsed.session_id) {
104
- return { sessionId: parsed.session_id, raw: trimmed };
313
+ return withSignal({ sessionId: parsed.session_id, raw: trimmed });
105
314
  }
106
315
  if (parsed.type === 'assistant') {
107
316
  const text = parsed.message?.content?.find((entry) => entry.type === 'text')?.text;
108
317
  if (text) {
109
- return { message: text, sessionId: parsed.session_id, raw: trimmed };
318
+ return withSignal({ message: text, sessionId: parsed.session_id, raw: trimmed });
110
319
  }
111
320
  }
112
321
  if (parsed.type === 'result' && parsed.result) {
113
- return { message: parsed.result, sessionId: parsed.session_id, raw: trimmed };
322
+ return withSignal({ message: parsed.result, sessionId: parsed.session_id, raw: trimmed });
114
323
  }
115
- return { raw: trimmed };
324
+ return withSignal({ raw: trimmed });
116
325
  }
117
326
  catch {
118
- return { raw: trimmed };
327
+ return withSignal({ raw: trimmed });
119
328
  }
120
329
  }
121
330
  function wireHostProcess(hostId, child, handlers) {
@@ -225,6 +434,171 @@ class FakeHostRuntime {
225
434
  }
226
435
  }
227
436
  exports.FakeHostRuntime = FakeHostRuntime;
437
+ // Issue #347 — test-only host that lets a test inject seekMentoring
438
+ // signals on a runId-by-runId basis. The Hub server treats it like any
439
+ // HostRuntime; the test holds a reference and calls emitPhase() to
440
+ // drive deterministic phase transitions. Outside tests, prefer
441
+ // FakeHostRuntime (smaller surface, no seekMentoring).
442
+ class ScriptedHostRuntime {
443
+ constructor() {
444
+ this.employees = [
445
+ { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.' },
446
+ { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.' },
447
+ ];
448
+ // Track each active run so the test can emit signals at it. Key is the
449
+ // sessionId we hand back on startRun; mapping sessionId → handlers
450
+ // lets emitPhase() reach the right run's onEvent callback.
451
+ this.handlersBySession = new Map();
452
+ // Tracks runDiscriminant per sessionId so consecutive emitPhase calls
453
+ // can resolve onSuccess routing without each test having to repeat it.
454
+ this.discriminantBySession = new Map();
455
+ }
456
+ detectEmployees() {
457
+ return this.employees;
458
+ }
459
+ startRun(_hostId, _projectPath, _message, handlers) {
460
+ const sessionId = (0, crypto_1.randomUUID)();
461
+ handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
462
+ this.handlersBySession.set(sessionId, handlers);
463
+ return this.spawnDouble();
464
+ }
465
+ continueRun(_hostId, _projectPath, sessionId, _message, handlers) {
466
+ this.handlersBySession.set(sessionId, handlers);
467
+ handlers.onEvent({ sessionId, raw: 'scripted-session-resume' }, 'system');
468
+ return this.spawnDouble();
469
+ }
470
+ // Test API — fire a seekMentoring tool-use event for the most recent
471
+ // active run. The phaseId is the raw FSM phase id (e.g.,
472
+ // 'implement-validate'); the parser will turn it into a friendly
473
+ // tracker label downstream.
474
+ emitPhase(runId, phaseId, status, findingsText) {
475
+ const target = this.resolveSession(runId);
476
+ if (!target)
477
+ return;
478
+ target.handlers.onEvent({
479
+ sessionId: target.sessionId,
480
+ raw: `scripted-seekMentoring:${phaseId}:${status}`,
481
+ seekMentoring: { phaseId, phaseStatus: status, findingsText },
482
+ }, 'stdout');
483
+ }
484
+ // Test API — set the runDiscriminant for a run so onSuccess routing
485
+ // resolves correctly (e.g., 'feature' vs 'bug' for feature-implementation).
486
+ emitDiscriminant(runId, discriminant) {
487
+ const target = this.resolveSession(runId);
488
+ if (!target)
489
+ return;
490
+ this.discriminantBySession.set(target.sessionId, discriminant);
491
+ target.handlers.onEvent({
492
+ sessionId: target.sessionId,
493
+ raw: `scripted-discriminant:${discriminant}`,
494
+ seekMentoring: {
495
+ phaseId: '__discriminant__',
496
+ phaseStatus: 'starting',
497
+ discriminant,
498
+ },
499
+ }, 'system');
500
+ }
501
+ // Test API — emit a seekMentoring signal for a job *other* than the
502
+ // one this run is tracking. Used to verify the cross-job pollution
503
+ // filter in applySeekMentoringSignal.
504
+ emitForeignPhase(runId, foreignJobId, phaseId, status) {
505
+ const target = this.resolveSession(runId);
506
+ if (!target)
507
+ return;
508
+ target.handlers.onEvent({
509
+ sessionId: target.sessionId,
510
+ raw: `scripted-foreign-seekMentoring:${foreignJobId}:${phaseId}`,
511
+ seekMentoring: {
512
+ phaseId,
513
+ phaseStatus: status,
514
+ jobId: foreignJobId,
515
+ jobName: foreignJobId,
516
+ },
517
+ }, 'stdout');
518
+ }
519
+ // Test API — emit a per-turn usage signal in the normalized shape
520
+ // the server expects. Mirrors what parseUsageSignal would produce
521
+ // from a real host's stream.
522
+ emitUsage(runId, usage) {
523
+ const target = this.resolveSession(runId);
524
+ if (!target)
525
+ return;
526
+ target.handlers.onEvent({
527
+ sessionId: target.sessionId,
528
+ raw: `scripted-usage:${JSON.stringify(usage)}`,
529
+ usage: {
530
+ nonCachedInputTokens: usage.nonCachedInputTokens ?? 0,
531
+ cachedInputTokens: usage.cachedInputTokens ?? 0,
532
+ cacheCreationTokens: usage.cacheCreationTokens ?? 0,
533
+ outputTokens: usage.outputTokens ?? 0,
534
+ costUsd: usage.costUsd,
535
+ },
536
+ }, 'stdout');
537
+ }
538
+ // Test API — emit the agent-identity signal that fraim_connect would
539
+ // produce, so applyUsageSignal's price-table lookup has identity to
540
+ // work with.
541
+ emitAgentIdentity(runId, agentName, agentModel) {
542
+ const target = this.resolveSession(runId);
543
+ if (!target)
544
+ return;
545
+ target.handlers.onEvent({
546
+ sessionId: target.sessionId,
547
+ raw: `scripted-agent-identity:${agentName}:${agentModel}`,
548
+ agentIdentity: { agentName, agentModel },
549
+ }, 'stdout');
550
+ }
551
+ // Reset between tests (called from beforeEach).
552
+ reset() {
553
+ this.handlersBySession.clear();
554
+ this.discriminantBySession.clear();
555
+ }
556
+ // The Hub server keeps its own sessionId map keyed off run.sessionId —
557
+ // but at the time the test calls emitPhase(runId), we may only have
558
+ // one handler registered (the latest start). Resolve by sessionId
559
+ // first, fall back to the most recent registered handler.
560
+ resolveSession(_runId) {
561
+ if (this.handlersBySession.size === 0)
562
+ return null;
563
+ const entries = [...this.handlersBySession.entries()];
564
+ const [sessionId, handlers] = entries[entries.length - 1];
565
+ return { sessionId, handlers };
566
+ }
567
+ spawnDouble() {
568
+ return {
569
+ stdout: process.stdout,
570
+ stderr: process.stderr,
571
+ stdin: process.stdin,
572
+ kill: () => true,
573
+ on: () => ({}),
574
+ once: () => ({}),
575
+ emit: () => true,
576
+ addListener: () => ({}),
577
+ removeListener: () => ({}),
578
+ removeAllListeners: () => ({}),
579
+ setMaxListeners: () => ({}),
580
+ getMaxListeners: () => 0,
581
+ listeners: () => [],
582
+ rawListeners: () => [],
583
+ listenerCount: () => 0,
584
+ prependListener: () => ({}),
585
+ prependOnceListener: () => ({}),
586
+ eventNames: () => [],
587
+ pid: 0,
588
+ connected: false,
589
+ disconnect: () => undefined,
590
+ exitCode: 0,
591
+ killed: false,
592
+ signalCode: null,
593
+ spawnargs: [],
594
+ spawnfile: '',
595
+ stdio: [],
596
+ unref: () => undefined,
597
+ ref: () => undefined,
598
+ };
599
+ }
600
+ }
601
+ exports.ScriptedHostRuntime = ScriptedHostRuntime;
228
602
  const createHubMessage = (role, text) => ({
229
603
  id: (0, crypto_1.randomUUID)(),
230
604
  role,