fraim-framework 2.0.161 → 2.0.163

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.
@@ -235,7 +235,13 @@ const runInitProject = async (options = {}) => {
235
235
  }
236
236
  result.warnings.push(`${configDisplayPath} was not created by init-project. The manager \`project-onboarding\` job is the only supported config-writing path.`);
237
237
  }
238
- ['jobs', 'ai-employee/jobs', 'ai-employee/skills', 'ai-manager/jobs', 'personalized-employee'].forEach((dir) => {
238
+ [
239
+ 'jobs',
240
+ 'ai-employee/jobs',
241
+ 'ai-employee/skills',
242
+ 'ai-manager/jobs',
243
+ 'personalized-employee',
244
+ ].forEach((dir) => {
239
245
  const dirPath = path_1.default.join(fraimDir, dir);
240
246
  if (!fs_1.default.existsSync(dirPath)) {
241
247
  fs_1.default.mkdirSync(dirPath, { recursive: true });
@@ -7,7 +7,9 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.runChecks = runChecks;
9
9
  const CHECK_TIMEOUT = 2000; // 2 seconds per check
10
- const MCP_CHECK_TIMEOUT = 10000; // 10 seconds for MCP connectivity checks
10
+ // Issue #532: stdio MCP servers need up to 15s for handshake + npm version resolution
11
+ // on first call via fraim-mcp-latest-launcher; 20s gives a 5s buffer.
12
+ const MCP_CHECK_TIMEOUT = 20000; // 20 seconds for MCP connectivity checks
11
13
  const TOTAL_TIMEOUT = 30000; // 30 seconds total
12
14
  // Simple logger for doctor command (optional, falls back to no-op)
13
15
  const logger = {
@@ -3,6 +3,7 @@
3
3
  * MCP connectivity checks for FRAIM doctor command
4
4
  * Tests FRAIM server connectivity and validates IDE MCP configurations
5
5
  * Issue #144: Enhanced doctor command
6
+ * Issue #532: Stdio MCP runtime connectivity check
6
7
  */
7
8
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
9
  if (k2 === undefined) k2 = k;
@@ -43,6 +44,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
43
44
  Object.defineProperty(exports, "__esModule", { value: true });
44
45
  exports.getNpmMajorVersion = getNpmMajorVersion;
45
46
  exports.diagnoseFraimMcpLaunchPlan = diagnoseFraimMcpLaunchPlan;
47
+ exports.spawnAndHandshake = spawnAndHandshake;
48
+ exports.getStdioMCPRuntimeChecks = getStdioMCPRuntimeChecks;
46
49
  exports.getMCPConnectivityChecks = getMCPConnectivityChecks;
47
50
  const fs_1 = __importDefault(require("fs"));
48
51
  const path_1 = __importDefault(require("path"));
@@ -52,7 +55,16 @@ const axios_1 = __importDefault(require("axios"));
52
55
  const toml = __importStar(require("toml"));
53
56
  const ide_detector_1 = require("../../setup/ide-detector");
54
57
  const fraim_mcp_latest_launcher_1 = require("../../mcp/fraim-mcp-latest-launcher");
58
+ const command_resolution_1 = require("../../mcp/command-resolution");
59
+ // Cache the npm major version so execFileSync is called at most once per process.
60
+ // Without caching, each IDE config check calls diagnoseFraimMcpLaunchPlan which calls
61
+ // getNpmMajorVersion, and execFileSync blocks the event loop for ~2s on Windows.
62
+ // With 8+ IDE checks running via Promise.all the blocking stacks sequentially.
63
+ let _npmMajorVersionCache = undefined;
55
64
  function getNpmMajorVersion() {
65
+ if (_npmMajorVersionCache !== undefined) {
66
+ return _npmMajorVersionCache;
67
+ }
56
68
  try {
57
69
  const command = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'npm';
58
70
  const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'npm --version'] : ['--version'];
@@ -61,11 +73,12 @@ function getNpmMajorVersion() {
61
73
  stdio: ['ignore', 'pipe', 'ignore']
62
74
  });
63
75
  const major = Number.parseInt(output.trim().split('.')[0], 10);
64
- return Number.isFinite(major) ? major : null;
76
+ _npmMajorVersionCache = Number.isFinite(major) ? major : null;
65
77
  }
66
78
  catch {
67
- return null;
79
+ _npmMajorVersionCache = null;
68
80
  }
81
+ return _npmMajorVersionCache;
69
82
  }
70
83
  function diagnoseFraimMcpLaunchPlan(fraimServer, platform = process.platform, npmMajorVersion = getNpmMajorVersion()) {
71
84
  const command = String(fraimServer?.command || '');
@@ -419,6 +432,248 @@ async function validateIDEMCPConfig(ide) {
419
432
  };
420
433
  }
421
434
  }
435
+ // ============================================================
436
+ // Issue #532: Stdio MCP Runtime Connectivity Check
437
+ // ============================================================
438
+ const STDIO_HANDSHAKE_TIMEOUT_MS = 15000;
439
+ /**
440
+ * Spawn a stdio MCP server, perform a two-phase JSON-RPC handshake
441
+ * (initialize then tools/list), and return a structured result.
442
+ *
443
+ * On Windows, wraps the command in cmd.exe /d /s /c so that .cmd wrappers
444
+ * resolve correctly. The same pattern is used by resolveManagedCommand and
445
+ * fraim-mcp-latest-launcher.
446
+ */
447
+ async function spawnAndHandshake(command, args, timeoutMs = STDIO_HANDSHAKE_TIMEOUT_MS) {
448
+ return new Promise((resolve) => {
449
+ const startTime = Date.now();
450
+ let spawnCommand;
451
+ let spawnArgs;
452
+ // spawnOptions is typed loosely so we can set windowsVerbatimArguments below.
453
+ let spawnOptions = {
454
+ stdio: ['pipe', 'pipe', 'pipe'],
455
+ env: process.env
456
+ };
457
+ if (process.platform === 'win32') {
458
+ // On Windows, wrap in cmd.exe so .cmd wrappers are resolved.
459
+ // cmd.exe /d /s /c requires special handling when the command path contains spaces:
460
+ // cmd /c ""path with spaces" arg1 arg2"
461
+ // The outer pair of double-quotes is mandatory when the first token is quoted.
462
+ // We must also pass windowsVerbatimArguments: true so Node's CreateProcess call
463
+ // does not re-escape our already-quoted command string.
464
+ const comSpec = process.env.ComSpec || 'cmd.exe';
465
+ const quotedTokens = [command, ...args].map((a) => {
466
+ const v = String(a);
467
+ return /[\s"&|<>^]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
468
+ });
469
+ const innerCmd = quotedTokens.join(' ');
470
+ // Wrap in outer quotes only when the command contains spaces (i.e. was itself quoted).
471
+ const needsOuterQuote = /[\s"&|<>^]/.test(String(command));
472
+ const cmdString = needsOuterQuote ? `"${innerCmd}"` : innerCmd;
473
+ spawnCommand = comSpec;
474
+ spawnArgs = ['/d', '/s', '/c', cmdString];
475
+ spawnOptions = { ...spawnOptions, windowsVerbatimArguments: true };
476
+ }
477
+ else {
478
+ spawnCommand = command;
479
+ spawnArgs = args;
480
+ }
481
+ let child;
482
+ try {
483
+ child = (0, child_process_1.spawn)(spawnCommand, spawnArgs, spawnOptions);
484
+ }
485
+ catch (spawnErr) {
486
+ resolve({
487
+ status: 'error',
488
+ phase: 'spawn',
489
+ message: `Failed to spawn MCP server: ${spawnErr.message}`,
490
+ suggestion: 'Verify that npx is installed and accessible on PATH.',
491
+ details: { command, args, error: spawnErr.message }
492
+ });
493
+ return;
494
+ }
495
+ const timer = setTimeout(() => {
496
+ try {
497
+ child.kill('SIGKILL');
498
+ }
499
+ catch { /* best effort */ }
500
+ resolve({
501
+ status: 'error',
502
+ phase: 'initialize',
503
+ message: `MCP server did not respond within ${timeoutMs}ms`,
504
+ suggestion: 'The server may be slow to start. Check that the package is installed and npx cache is warm.',
505
+ details: { command, args, timeoutMs, elapsed: Date.now() - startTime }
506
+ });
507
+ }, timeoutMs);
508
+ let stderrBuffer = '';
509
+ let stdoutBuffer = '';
510
+ let spawnError = null;
511
+ child.stderr?.on('data', (d) => {
512
+ stderrBuffer += d.toString();
513
+ });
514
+ child.on('error', (err) => {
515
+ spawnError = err;
516
+ });
517
+ child.stdout?.on('data', (d) => {
518
+ stdoutBuffer += d.toString();
519
+ });
520
+ child.on('close', (code) => {
521
+ clearTimeout(timer);
522
+ if (spawnError) {
523
+ const msg = spawnError.message || '';
524
+ const isEnoent = msg.includes('ENOENT') || msg.includes('not found');
525
+ resolve({
526
+ status: 'error',
527
+ phase: 'spawn',
528
+ message: `Failed to start MCP server: ${msg}`,
529
+ suggestion: isEnoent
530
+ ? 'Verify that npx is installed. Run: npm install -g npm to update npm/npx.'
531
+ : 'Check that the required package can be installed via npx.',
532
+ details: { command, args, error: msg }
533
+ });
534
+ return;
535
+ }
536
+ if (code !== 0 && stdoutBuffer.trim() === '') {
537
+ resolve({
538
+ status: 'error',
539
+ phase: 'spawn',
540
+ message: `MCP server exited with code ${code} before responding`,
541
+ suggestion: 'Run: npx -y <package> manually to check for installation errors.',
542
+ details: { command, args, exitCode: code, stderr: stderrBuffer.slice(0, 500) }
543
+ });
544
+ return;
545
+ }
546
+ // Parse all JSON-RPC messages received from stdout
547
+ const lines = stdoutBuffer.split('\n').map((l) => l.trim()).filter(Boolean);
548
+ let initializeResponse = null;
549
+ let toolsListResponse = null;
550
+ for (const line of lines) {
551
+ try {
552
+ const msg = JSON.parse(line);
553
+ if (msg?.id === 1 && msg?.result !== undefined) {
554
+ initializeResponse = msg;
555
+ }
556
+ if (msg?.id === 2 && msg?.result !== undefined) {
557
+ toolsListResponse = msg;
558
+ }
559
+ }
560
+ catch {
561
+ // Ignore non-JSON lines (e.g., startup log output)
562
+ }
563
+ }
564
+ if (!initializeResponse) {
565
+ resolve({
566
+ status: 'error',
567
+ phase: 'initialize',
568
+ message: 'MCP server did not return a valid initialize response',
569
+ suggestion: 'The server may have crashed during startup. Check stderr for error details.',
570
+ details: { command, args, exitCode: code, stderr: stderrBuffer.slice(0, 500) }
571
+ });
572
+ return;
573
+ }
574
+ if (!toolsListResponse) {
575
+ resolve({
576
+ status: 'error',
577
+ phase: 'tools-list',
578
+ message: 'MCP server did not respond to tools/list',
579
+ suggestion: 'The server initialized but did not expose any tools. Check the package documentation.',
580
+ details: { command, args, exitCode: code }
581
+ });
582
+ return;
583
+ }
584
+ const toolCount = Array.isArray(toolsListResponse.result?.tools)
585
+ ? toolsListResponse.result.tools.length
586
+ : 0;
587
+ const elapsed = Date.now() - startTime;
588
+ resolve({
589
+ status: 'passed',
590
+ message: `MCP server responded with ${toolCount} tool(s) in ${elapsed}ms`,
591
+ details: { command, args, toolCount, elapsed }
592
+ });
593
+ });
594
+ // Send two-phase JSON-RPC handshake
595
+ try {
596
+ const initMsg = JSON.stringify({
597
+ jsonrpc: '2.0',
598
+ id: 1,
599
+ method: 'initialize',
600
+ params: {
601
+ protocolVersion: '2024-11-05',
602
+ capabilities: {},
603
+ clientInfo: { name: 'fraim-doctor', version: '1.0' }
604
+ }
605
+ }) + '\n';
606
+ child.stdin?.write(initMsg);
607
+ const toolsMsg = JSON.stringify({
608
+ jsonrpc: '2.0',
609
+ id: 2,
610
+ method: 'tools/list',
611
+ params: {}
612
+ }) + '\n';
613
+ child.stdin?.write(toolsMsg);
614
+ child.stdin?.end();
615
+ }
616
+ catch {
617
+ // stdin may not be writable if the process exited immediately
618
+ }
619
+ });
620
+ }
621
+ /**
622
+ * Run a connectivity check for a single named stdio MCP server.
623
+ */
624
+ async function testStdioMCPServer(serverName, command, args) {
625
+ if (process.env.NODE_ENV === 'test') {
626
+ return {
627
+ status: 'warning',
628
+ message: `Stdio MCP runtime check for "${serverName}" skipped (test mode)`,
629
+ details: { testMode: true, skipped: true, serverName }
630
+ };
631
+ }
632
+ const result = await spawnAndHandshake(command, args, STDIO_HANDSHAKE_TIMEOUT_MS);
633
+ if (result.status === 'passed') {
634
+ return {
635
+ status: 'passed',
636
+ message: `${serverName} stdio MCP: ${result.message}`,
637
+ details: result.details
638
+ };
639
+ }
640
+ return {
641
+ status: 'error',
642
+ message: `${serverName} stdio MCP connectivity failed (phase: ${result.phase}): ${result.message}`,
643
+ suggestion: result.suggestion,
644
+ details: { ...result.details, phase: result.phase }
645
+ };
646
+ }
647
+ /**
648
+ * Build the 3 stdio MCP runtime checks (git, playwright, fraim).
649
+ * Exported separately so callers can obtain just the runtime checks if needed.
650
+ */
651
+ function getStdioMCPRuntimeChecks() {
652
+ const npx = (0, command_resolution_1.resolveManagedCommand)('npx');
653
+ // For the fraim MCP server we use the latest launcher so the test
654
+ // exercises the same path that IDE configs use.
655
+ const fraimLauncherPath = (0, fraim_mcp_latest_launcher_1.getFraimMcpLatestLauncherPath)();
656
+ return [
657
+ {
658
+ name: 'git stdio runtime check',
659
+ category: 'mcpConnectivity',
660
+ critical: false,
661
+ run: () => testStdioMCPServer('git', npx, ['-y', '@cyanheads/git-mcp-server'])
662
+ },
663
+ {
664
+ name: 'playwright stdio runtime check',
665
+ category: 'mcpConnectivity',
666
+ critical: false,
667
+ run: () => testStdioMCPServer('playwright', npx, ['-y', '@playwright/mcp'])
668
+ },
669
+ {
670
+ name: 'fraim stdio runtime check',
671
+ category: 'mcpConnectivity',
672
+ critical: false,
673
+ run: () => testStdioMCPServer('fraim', process.execPath, [fraimLauncherPath])
674
+ }
675
+ ];
676
+ }
422
677
  /**
423
678
  * Get all MCP connectivity checks
424
679
  */
@@ -441,5 +696,9 @@ function getMCPConnectivityChecks() {
441
696
  run: async () => validateIDEMCPConfig(ide)
442
697
  });
443
698
  }
699
+ // Check 3 (Issue #532): Confirm stdio MCP servers respond at runtime.
700
+ // "Disconnected" in mcp list is expected on-demand-spawn behavior. These checks
701
+ // verify that each server can actually start and complete a JSON-RPC handshake.
702
+ checks.push(...getStdioMCPRuntimeChecks());
444
703
  return checks;
445
704
  }
@@ -71,7 +71,7 @@ This repository uses FRAIM.
71
71
  - When users ask for next step recommendations, use recommend-next-job skill under \`${employeeSkillsPath}/\` to gather context before suggesting jobs.
72
72
 
73
73
  > [!IMPORTANT]
74
- > **Job stubs are for discovery only.** When a user @mentions or references any file under \`${employeeJobsPath}/\` or \`${managerJobsPath}/\`, do NOT attempt to execute the job from the stub content. The stub only shows intent and phase names. Always call \`get_fraim_job({ job: "<job-name>" })\` first to get the full phased instructions before doing any work.
74
+ > **Job stubs are for discovery only.** When a user mentions or references any file under \`${employeeJobsPath}/\` or \`${managerJobsPath}/\`, do NOT attempt to execute the job from the stub content. The stub only shows intent and phase names. Always call \`get_fraim_job({ job: "<job-name>" })\` first to get the full phased instructions before doing any work.
75
75
  `);
76
76
  const cursorManagedBody = buildManagedSection(`
77
77
  # FRAIM
@@ -349,6 +349,51 @@ exports.FRAIM_CONFIG_SCHEMA = {
349
349
  }
350
350
  }
351
351
  },
352
+ "playbooks": {
353
+ "kind": "object",
354
+ "properties": {
355
+ "directory": {
356
+ "kind": "string"
357
+ },
358
+ "decisionCommand": {
359
+ "kind": "string"
360
+ },
361
+ "decisionTimeoutMs": {
362
+ "kind": "number"
363
+ }
364
+ }
365
+ },
366
+ "actionAdapters": {
367
+ "kind": "record",
368
+ "value": {
369
+ "kind": "object",
370
+ "properties": {
371
+ "type": {
372
+ "kind": "enum",
373
+ "values": [
374
+ "command",
375
+ "manual",
376
+ "handoff"
377
+ ]
378
+ },
379
+ "command": {
380
+ "kind": "string"
381
+ },
382
+ "timeoutMs": {
383
+ "kind": "number"
384
+ },
385
+ "targetSystem": {
386
+ "kind": "string"
387
+ },
388
+ "targetIdentityProvider": {
389
+ "kind": "string"
390
+ },
391
+ "instructions": {
392
+ "kind": "string"
393
+ }
394
+ }
395
+ }
396
+ },
352
397
  "queue": {
353
398
  "kind": "object",
354
399
  "properties": {
@@ -407,17 +452,6 @@ exports.FRAIM_CONFIG_SCHEMA = {
407
452
  "requireApprovalState": {
408
453
  "kind": "string"
409
454
  },
410
- "selector": {
411
- "kind": "object",
412
- "properties": {
413
- "shortDescriptionContains": {
414
- "kind": "array",
415
- "element": {
416
- "kind": "string"
417
- }
418
- }
419
- }
420
- },
421
455
  "microsoftPasswordReset": {
422
456
  "kind": "object",
423
457
  "properties": {
@@ -564,6 +598,11 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
564
598
  "automation.support.contextResolver.scriptPath",
565
599
  "automation.support.contextResolver.arguments",
566
600
  "automation.support.contextResolver.timeoutMs",
601
+ "automation.support.playbooks",
602
+ "automation.support.playbooks.directory",
603
+ "automation.support.playbooks.decisionCommand",
604
+ "automation.support.playbooks.decisionTimeoutMs",
605
+ "automation.support.actionAdapters",
567
606
  "automation.support.queue",
568
607
  "automation.support.queue.provider",
569
608
  "automation.support.queue.table",
@@ -579,8 +618,6 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
579
618
  "automation.support.requestTypes.password_reset",
580
619
  "automation.support.requestTypes.password_reset.mode",
581
620
  "automation.support.requestTypes.password_reset.requireApprovalState",
582
- "automation.support.requestTypes.password_reset.selector",
583
- "automation.support.requestTypes.password_reset.selector.shortDescriptionContains",
584
621
  "automation.support.requestTypes.password_reset.microsoftPasswordReset",
585
622
  "automation.support.requestTypes.password_reset.microsoftPasswordReset.resetType",
586
623
  "automation.support.requestTypes.password_reset.microsoftPasswordReset.providedPasswordEnvVar",
@@ -50,6 +50,14 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
50
50
  launchCommand: 'gemini',
51
51
  installPackage: '@google/gemini-cli',
52
52
  },
53
+ {
54
+ id: 'copilot-cli',
55
+ label: 'GitHub Copilot',
56
+ detectAliases: ['copilot'],
57
+ loginCommand: 'copilot login',
58
+ launchCommand: 'copilot',
59
+ installPackage: '@github/copilot',
60
+ },
53
61
  ];
54
62
  /**
55
63
  * The canonical row set, in display order. Each row starts in `pending`;
@@ -86,6 +86,59 @@ exports.AGENT_TOKEN_PRICES = [
86
86
  source: 'https://openai.com/api/pricing/ (gpt-5 row, applied to gpt-5.5 as nearest published rate)',
87
87
  verifiedOn: '2026-04-30',
88
88
  },
89
+ // Google — Gemini CLI. Standard paid-tier text/image/video token rates.
90
+ // Context caching maps to cache reads; the published storage-hour price
91
+ // cannot be derived from the CLI token stats and is intentionally omitted.
92
+ {
93
+ agent: 'gemini',
94
+ model: 'gemini-3.1-flash-lite',
95
+ inputPerMTok: 0.25,
96
+ outputPerMTok: 1.50,
97
+ cacheReadPerMTok: 0.025,
98
+ cacheCreationPerMTok: 0,
99
+ source: 'https://ai.google.dev/gemini-api/docs/pricing',
100
+ verifiedOn: '2026-06-02',
101
+ },
102
+ {
103
+ agent: 'gemini',
104
+ model: 'gemini-3-flash-preview',
105
+ inputPerMTok: 0.50,
106
+ outputPerMTok: 3.00,
107
+ cacheReadPerMTok: 0.05,
108
+ cacheCreationPerMTok: 0,
109
+ source: 'https://ai.google.dev/gemini-api/docs/pricing',
110
+ verifiedOn: '2026-06-02',
111
+ },
112
+ // GitHub Copilot CLI. The CLI's agentic mode uses GPT-4.1 (also branded
113
+ // "gpt-4.1" in the Copilot API stream). GitHub charges for Copilot via
114
+ // premium-request quota rather than per-token billing on most plans; the
115
+ // per-token rates below apply to the Copilot API / enterprise API billing
116
+ // path (pay-per-token, not included-requests) as published at
117
+ // https://docs.github.com/en/copilot/about-github-copilot/plans-for-github-copilot
118
+ // and the underlying OpenAI model pricing for gpt-4.1 at openai.com/api/pricing/.
119
+ // Coverage is 'partial' for Copilot runs where the CLI does not emit a cost
120
+ // field — the table backstops cost computation when a token count is present
121
+ // but no direct costUsd is in the stream.
122
+ {
123
+ agent: 'copilot',
124
+ model: 'gpt-4.1',
125
+ inputPerMTok: 2.00,
126
+ outputPerMTok: 8.00,
127
+ cacheReadPerMTok: 0.50,
128
+ cacheCreationPerMTok: 0,
129
+ source: 'https://openai.com/api/pricing/ (gpt-4.1 row; GitHub Copilot CLI agentic mode)',
130
+ verifiedOn: '2026-06-09',
131
+ },
132
+ {
133
+ agent: 'copilot',
134
+ model: 'gpt-4o',
135
+ inputPerMTok: 2.50,
136
+ outputPerMTok: 10.00,
137
+ cacheReadPerMTok: 1.25,
138
+ cacheCreationPerMTok: 0,
139
+ source: 'https://openai.com/api/pricing/ (gpt-4o row; GitHub Copilot CLI fallback model)',
140
+ verifiedOn: '2026-06-09',
141
+ },
89
142
  ];
90
143
  /**
91
144
  * Look up the price entry for an agent + model. Agent is matched