fraim-framework 2.0.162 → 2.0.164

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 (33) hide show
  1. package/dist/src/ai-hub/desktop-main.js +4 -1
  2. package/dist/src/ai-hub/hosts.js +97 -12
  3. package/dist/src/ai-hub/preferences.js +1 -1
  4. package/dist/src/ai-hub/server.js +49 -123
  5. package/dist/src/cli/commands/init-project.js +15 -14
  6. package/dist/src/cli/commands/sync.js +38 -0
  7. package/dist/src/cli/doctor/check-runner.js +3 -1
  8. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +261 -2
  9. package/dist/src/cli/utils/git-org-sync.js +56 -0
  10. package/dist/src/cli/utils/org-migration.js +50 -0
  11. package/dist/src/cli/utils/org-pack-sync.js +208 -0
  12. package/dist/src/cli/utils/project-bootstrap.js +20 -7
  13. package/dist/src/cli/utils/user-config.js +68 -0
  14. package/dist/src/core/fraim-config-schema.generated.js +10 -0
  15. package/dist/src/first-run/types.js +8 -0
  16. package/dist/src/local-mcp-server/agent-token-prices.js +30 -0
  17. package/dist/src/local-mcp-server/learning-context-builder.js +78 -29
  18. package/dist/src/local-mcp-server/stdio-server.js +30 -0
  19. package/index.js +1 -1
  20. package/package.json +2 -3
  21. package/public/ai-hub/index.html +5 -5
  22. package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
  23. package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
  24. package/public/ai-hub/review.css +15 -15
  25. package/public/ai-hub/script.js +254 -195
  26. package/public/ai-hub/styles.css +206 -16
  27. package/public/first-run/styles.css +73 -73
  28. package/dist/src/ai-hub/word-sideload.js +0 -95
  29. package/dist/src/cli/commands/test-mcp.js +0 -171
  30. package/dist/src/cli/setup/first-run.js +0 -242
  31. package/dist/src/core/config-writer.js +0 -75
  32. package/dist/src/core/utils/job-aliases.js +0 -47
  33. package/dist/src/core/utils/workflow-parser.js +0 -174
@@ -273,7 +273,10 @@ async function bootstrap() {
273
273
  const options = parseArgs(process.argv.slice(2));
274
274
  // Single-instance lock — if another instance is already running, focus it
275
275
  // and exit rather than spawning a second server + window.
276
- const gotLock = electron_1.app.requestSingleInstanceLock();
276
+ // Skip when FRAIM_AI_HUB_FAKE_HOST=1 (test mode) so Playwright can launch
277
+ // a test instance alongside the real desktop app without the lock killing it.
278
+ const skipSingleInstance = process.env.FRAIM_AI_HUB_FAKE_HOST === '1';
279
+ const gotLock = skipSingleInstance || electron_1.app.requestSingleInstanceLock();
277
280
  if (!gotLock) {
278
281
  electron_1.app.quit();
279
282
  return;
@@ -319,7 +319,13 @@ const EMPLOYEE_LABELS = {
319
319
  codex: 'Codex',
320
320
  claude: 'Claude Code',
321
321
  gemini: 'Gemini CLI',
322
+ copilot: 'GitHub Copilot CLI',
322
323
  };
324
+ // GitHub Copilot CLI binary name after `npm install -g @github/copilot`.
325
+ // The @github/copilot package installs a binary named `copilot` on PATH.
326
+ // Note: the package name is @github/copilot (NOT @github/copilot-cli which
327
+ // does not exist on npm). The binary is `copilot` (NOT `github-copilot-cli`).
328
+ const COPILOT_BINARY = 'copilot';
323
329
  const executableName = (command) => command;
324
330
  function quoteWindowsArg(value) {
325
331
  if (value.length === 0) {
@@ -348,9 +354,15 @@ const availableByVersionProbe = (command) => {
348
354
  });
349
355
  return result.status === 0;
350
356
  };
357
+ // Resolve the binary name for each agent tool.
358
+ function agentBinaryName(id) {
359
+ if (id === 'copilot')
360
+ return COPILOT_BINARY;
361
+ return executableName(id);
362
+ }
351
363
  function detectEmployees() {
352
364
  return Object.keys(EMPLOYEE_LABELS).map((id) => {
353
- const available = availableByVersionProbe(executableName(id));
365
+ const available = availableByVersionProbe(agentBinaryName(id));
354
366
  return {
355
367
  id,
356
368
  label: EMPLOYEE_LABELS[id],
@@ -424,17 +436,10 @@ function machineLevelStorageGuard(jobId) {
424
436
  '- If the exact machine-level paths cannot be written, fail the phase and report the concrete filesystem error.',
425
437
  ].join('\n');
426
438
  }
427
- if (normalized === 'organization-onboarding') {
428
- const orgContext = path_1.default.join(userFraim, 'personalized-employee', 'context', 'org_context.md');
429
- const orgRules = path_1.default.join(userFraim, 'personalized-employee', 'rules', 'org_rules.md');
430
- return [
431
- 'Storage scope guardrail:',
432
- '- Organization onboarding artifacts are machine-level, not repo-level.',
433
- `- Required write targets: ${orgContext} and ${orgRules}.`,
434
- '- Do not write, validate, call canonical, commit, or open a PR for repo-local fraim/personalized-employee/context/org_context.md or fraim/personalized-employee/rules/org_rules.md as substitutes.',
435
- '- If the exact machine-level paths cannot be written, fail the phase and report the concrete filesystem error.',
436
- ].join('\n');
437
- }
439
+ // organization-onboarding intentionally has no guardrail here: the job's
440
+ // submit phase is backend-aware and owns the write-path procedure (git PR /
441
+ // cloud publish / machine-level fallback), so duplicating it in runtime
442
+ // prompt text would be redundant (issue #563 review).
438
443
  return null;
439
444
  }
440
445
  // If ~/.gemini/settings.json has a wrong/test FRAIM_API_KEY, patch it with the
@@ -560,6 +565,15 @@ function sharedBrowserHostConfig(hostId, env = process.env) {
560
565
  }
561
566
  return { args: [], env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: file } };
562
567
  }
568
+ if (hostId === 'copilot') {
569
+ // GitHub Copilot CLI does not yet publish a documented per-invocation
570
+ // settings-file env var analogous to GEMINI_CLI_SYSTEM_SETTINGS_PATH.
571
+ // If one is discovered in a future release, write the ephemeral file here
572
+ // and return { args: [], env: { <COPILOT_SETTINGS_ENV_VAR>: file } }.
573
+ // Until then, return the Option-B no-op per spec R5.2 — the Hub's
574
+ // start-payload builder will inject a browser-guidance note instead.
575
+ return { args: [] };
576
+ }
563
577
  return { args: [] };
564
578
  }
565
579
  function buildStartPlan(hostId, message, sessionId) {
@@ -586,6 +600,22 @@ function buildStartPlan(hostId, message, sessionId) {
586
600
  env: browser.env,
587
601
  };
588
602
  }
603
+ if (hostId === 'copilot') {
604
+ // GitHub Copilot CLI headless invocation.
605
+ // --yolo auto-approves all tool permissions (analogous to
606
+ // --dangerously-skip-permissions for Claude Code). The task is provided
607
+ // via stdin; -p/--prompt requires inline text which is cumbersome for
608
+ // multi-line FRAIM instructions. The session id is self-assigned by the
609
+ // binary on first run; Hub captures it from the stream output
610
+ // (parseHostLine 'copilot' branch).
611
+ const browser = sharedBrowserHostConfig('copilot');
612
+ return {
613
+ command: COPILOT_BINARY,
614
+ args: ['--yolo', ...browser.args],
615
+ stdin: transformHeadlessFraimMessage(message, 'start'),
616
+ env: browser.env,
617
+ };
618
+ }
589
619
  const browser = sharedBrowserHostConfig('claude');
590
620
  return {
591
621
  command: executableName('claude'),
@@ -615,6 +645,17 @@ function buildContinuePlan(hostId, sessionId, message) {
615
645
  env: browser.env,
616
646
  };
617
647
  }
648
+ if (hostId === 'copilot') {
649
+ // Resume an existing GitHub Copilot CLI session.
650
+ // --resume <sessionId> accepts the session id returned on the first run.
651
+ const browser = sharedBrowserHostConfig('copilot');
652
+ return {
653
+ command: COPILOT_BINARY,
654
+ args: ['--yolo', '--resume', sessionId, ...browser.args],
655
+ stdin: transformHeadlessFraimMessage(message, 'continue'),
656
+ env: browser.env,
657
+ };
658
+ }
618
659
  const browser = sharedBrowserHostConfig('claude');
619
660
  return {
620
661
  command: executableName('claude'),
@@ -666,6 +707,14 @@ function buildDirectStartPlan(hostId, message, sessionId) {
666
707
  stdin: DIRECT_PREAMBLE + message,
667
708
  };
668
709
  }
710
+ if (hostId === 'copilot') {
711
+ // Direct (A/B) mode for Copilot: headless, no FRAIM MCP wiring.
712
+ return {
713
+ command: COPILOT_BINARY,
714
+ args: ['--yolo'],
715
+ stdin: DIRECT_PREAMBLE + message,
716
+ };
717
+ }
669
718
  return {
670
719
  command: executableName('claude'),
671
720
  args: [
@@ -696,6 +745,14 @@ function buildDirectContinuePlan(hostId, sessionId, message) {
696
745
  stdin: DIRECT_PREAMBLE + message,
697
746
  };
698
747
  }
748
+ if (hostId === 'copilot') {
749
+ // Direct continue mode for Copilot: resume session, no FRAIM MCP wiring.
750
+ return {
751
+ command: COPILOT_BINARY,
752
+ args: ['--yolo', '--resume', sessionId],
753
+ stdin: DIRECT_PREAMBLE + message,
754
+ };
755
+ }
699
756
  return {
700
757
  command: executableName('claude'),
701
758
  args: [
@@ -764,6 +821,32 @@ function parseHostLine(hostId, line) {
764
821
  return withSignal({ message: trimmed, raw: trimmed });
765
822
  }
766
823
  }
824
+ // GitHub Copilot CLI output: JSON stream where each event carries a `type`
825
+ // field. Known event shapes (from the agentic CLI stream):
826
+ // { "type": "session.started", "session_id": "..." } — session id
827
+ // { "type": "message", "role": "assistant", "content": "..." } — reply text
828
+ // { "type": "turn.completed", "usage": { ... } } — token usage (same shape as Codex)
829
+ // For any JSON event not matching the above, signal scanning (seekMentoring,
830
+ // agent identity) still runs because withSignal is applied to every parsed result.
831
+ // Non-JSON lines from Copilot are treated as plain-text employee messages.
832
+ if (hostId === 'copilot') {
833
+ try {
834
+ const parsed = JSON.parse(trimmed);
835
+ if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
836
+ return withSignal({ sessionId: parsed.session_id, raw: trimmed });
837
+ }
838
+ if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
839
+ return withSignal({ message: parsed.content, raw: trimmed });
840
+ }
841
+ // All other JSON events: apply signal scanning and surface as raw.
842
+ return withSignal({ raw: trimmed });
843
+ }
844
+ catch {
845
+ // Non-JSON line from Copilot: treat as a plain-text employee message,
846
+ // same pattern as Gemini CLI's non-JSON output.
847
+ return withSignal({ message: trimmed, raw: trimmed });
848
+ }
849
+ }
767
850
  try {
768
851
  const parsed = JSON.parse(trimmed);
769
852
  if (parsed.type === 'system' && parsed.session_id) {
@@ -950,6 +1033,7 @@ class FakeHostRuntime {
950
1033
  { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
951
1034
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
952
1035
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
1036
+ { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
953
1037
  ];
954
1038
  }
955
1039
  detectEmployees() {
@@ -1032,6 +1116,7 @@ class ScriptedHostRuntime {
1032
1116
  { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
1033
1117
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
1034
1118
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1119
+ { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1035
1120
  ];
1036
1121
  // Track each active run so the test can emit signals at it. Key is the
1037
1122
  // sessionId we hand back on startRun; mapping sessionId → handlers
@@ -29,7 +29,7 @@ class AiHubPreferencesStore {
29
29
  const raw = JSON.parse(fs_1.default.readFileSync(this.stateFilePath, 'utf8'));
30
30
  return {
31
31
  projectPath: raw.projectPath || projectPath,
32
- employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex') ? raw.employeeId : DEFAULT_EMPLOYEE,
32
+ employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot') ? raw.employeeId : DEFAULT_EMPLOYEE,
33
33
  categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
34
34
  recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
35
35
  recentJobInstructions: (typeof raw.recentJobInstructions === 'object' && raw.recentJobInstructions !== null && !Array.isArray(raw.recentJobInstructions))
@@ -50,30 +50,7 @@ const https_1 = __importDefault(require("https"));
50
50
  const types_1 = require("../first-run/types");
51
51
  const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
52
52
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
53
- const PERSONA_AVATAR_SEEDS = {
54
- maestro: { seed: 'MAESTRO-founder-mode', bg: 'fde68a' },
55
- beza: { seed: 'BEZA-strategist', bg: 'c7d2fe' },
56
- pam: { seed: 'PAM-product', bg: 'ddd6fe' },
57
- swen: { seed: 'SWEN-engineer', bg: 'bfdbfe' },
58
- qasm: { seed: 'QASM-quality', bg: 'a7f3d0' },
59
- huxley: { seed: 'HUXLEY-design', bg: 'fbcfe8' },
60
- gautam: { seed: 'Gautam-marketing', bg: 'fed7aa' },
61
- cela: { seed: 'CELA-legal', bg: 'cbd5e1' },
62
- sekhar: { seed: 'SEKHAR-security', bg: 'fecaca' },
63
- ashley: { seed: 'Ashley-assistant', bg: 'fde68a' },
64
- mandy: { seed: 'MANDY-manager', bg: 'ede9fe' },
65
- hari: { seed: 'HARI-hr', bg: 'ccfbf1' },
66
- careena: { seed: 'CAREENA-career-coach', bg: 'e0f2fe' },
67
- ricardo: { seed: 'RICARDO-recruiter', bg: 'ede9fe' },
68
- sade: { seed: 'SADE-salesforce-dev', bg: 'bae6fd' },
69
- sam: { seed: 'SAM-sales-manager', bg: 'bbf7d0' },
70
- casey: { seed: 'CASEY-customer-cx', bg: 'fecdd3' },
71
- };
72
- function buildPersonaAvatarUrl(personaKey) {
73
- const data = PERSONA_AVATAR_SEEDS[personaKey] ?? { seed: personaKey, bg: 'f1f5f9' };
74
- const params = new URLSearchParams({ seed: data.seed, backgroundColor: data.bg, radius: '50' });
75
- return `https://api.dicebear.com/9.x/notionists/svg?${params.toString()}`;
76
- }
53
+ const persona_hiring_1 = require("../config/persona-hiring");
77
54
  const catalog_1 = require("./catalog");
78
55
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
79
56
  const hosts_1 = require("./hosts");
@@ -516,6 +493,7 @@ const HUB_TO_FIRST_RUN_ID = {
516
493
  claude: 'claude-code',
517
494
  codex: 'codex',
518
495
  gemini: 'gemini-cli',
496
+ copilot: 'copilot-cli',
519
497
  };
520
498
  function hubAgentOption(hubId) {
521
499
  const frId = HUB_TO_FIRST_RUN_ID[hubId];
@@ -601,19 +579,41 @@ function resolveSafeArtifactPath(rawPath, projectPath) {
601
579
  return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
602
580
  }
603
581
  function hubOpenFile(filePath) {
604
- if (process.platform === 'win32') {
605
- (0, child_process_1.spawn)('powershell.exe', ['-NoProfile', '-Command', 'Start-Process -LiteralPath $args[0]', filePath], {
606
- detached: true,
607
- stdio: 'ignore',
608
- windowsHide: true,
609
- }).unref();
610
- return;
611
- }
612
- if (process.platform === 'darwin') {
613
- (0, child_process_1.spawn)('open', [filePath], { detached: true, stdio: 'ignore' }).unref();
614
- return;
615
- }
616
- (0, child_process_1.spawn)('xdg-open', [filePath], { detached: true, stdio: 'ignore' }).unref();
582
+ return new Promise((resolve, reject) => {
583
+ const args = process.platform === 'win32'
584
+ ? [
585
+ '-NoProfile',
586
+ '-ExecutionPolicy',
587
+ 'Bypass',
588
+ '-Command',
589
+ '& { param($p) try { Invoke-Item -LiteralPath $p; exit 0 } catch { Write-Error $_; exit 1 } }',
590
+ filePath,
591
+ ]
592
+ : process.platform === 'darwin'
593
+ ? [filePath]
594
+ : [filePath];
595
+ const command = process.platform === 'win32'
596
+ ? 'powershell.exe'
597
+ : process.platform === 'darwin'
598
+ ? 'open'
599
+ : 'xdg-open';
600
+ const child = (0, child_process_1.spawn)(command, args, {
601
+ stdio: ['ignore', 'ignore', 'pipe'],
602
+ windowsHide: process.platform === 'win32',
603
+ });
604
+ let stderr = '';
605
+ child.stderr?.on('data', (chunk) => {
606
+ stderr += String(chunk);
607
+ });
608
+ child.on('error', reject);
609
+ child.on('close', (code) => {
610
+ if (code === 0) {
611
+ resolve();
612
+ return;
613
+ }
614
+ reject(new Error(stderr.trim() || `Open command failed with exit code ${code ?? 'unknown'}.`));
615
+ });
616
+ });
617
617
  }
618
618
  function buildManagedLoginCommand(command) {
619
619
  const managedPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
@@ -1113,36 +1113,6 @@ class AiHubServer {
1113
1113
  // Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
1114
1114
  // content) export routes so a conversational deliverable with no on-disk file
1115
1115
  // can still be downloaded for Word annotation.
1116
- async markdownToDocxBuffer(md) {
1117
- const html = md
1118
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1119
- // headings
1120
- .replace(/^#### (.+)$/gm, '<h4>$1</h4>')
1121
- .replace(/^### (.+)$/gm, '<h3>$1</h3>')
1122
- .replace(/^## (.+)$/gm, '<h2>$1</h2>')
1123
- .replace(/^# (.+)$/gm, '<h1>$1</h1>')
1124
- // bold, italic, code
1125
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
1126
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
1127
- .replace(/`(.+?)`/g, '<code>$1</code>')
1128
- // unordered lists
1129
- .replace(/^[-*] (.+)$/gm, '<li>$1</li>')
1130
- // horizontal rules
1131
- .replace(/^---+$/gm, '<hr/>')
1132
- // paragraphs (double newlines)
1133
- .replace(/\n{2,}/g, '</p><p>')
1134
- .replace(/^/, '<p>')
1135
- .replace(/$/, '</p>')
1136
- // clean up list items into a ul
1137
- .replace(/(<li>.+<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`);
1138
- // eslint-disable-next-line @typescript-eslint/no-var-requires
1139
- const htmlToDocx = require('html-to-docx');
1140
- return await htmlToDocx(`<html><body>${html}</body></html>`, null, {
1141
- table: { row: { cantSplit: true } },
1142
- footer: false,
1143
- pageNumber: false,
1144
- });
1145
- }
1146
1116
  prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
1147
1117
  const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1148
1118
  const resolvedJobId = explicit?.jobId || selectedJobId;
@@ -1201,7 +1171,7 @@ class AiHubServer {
1201
1171
  key: bundle.personaKey,
1202
1172
  displayName: bundle.catalogMetadata.displayName,
1203
1173
  role: bundle.catalogMetadata.role,
1204
- avatarUrl: buildPersonaAvatarUrl(bundle.personaKey),
1174
+ avatarUrl: (0, persona_hiring_1.buildPersonaAvatarUrl)(bundle.personaKey),
1205
1175
  pricingLabel: bundle.catalogMetadata.pricingLabel,
1206
1176
  status: 'locked',
1207
1177
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
@@ -1239,7 +1209,7 @@ class AiHubServer {
1239
1209
  key: bundle.personaKey,
1240
1210
  displayName: bundle.catalogMetadata.displayName,
1241
1211
  role: bundle.catalogMetadata.role,
1242
- avatarUrl: buildPersonaAvatarUrl(bundle.personaKey),
1212
+ avatarUrl: (0, persona_hiring_1.buildPersonaAvatarUrl)(bundle.personaKey),
1243
1213
  pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
1244
1214
  status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
1245
1215
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
@@ -1582,6 +1552,14 @@ class AiHubServer {
1582
1552
  ? path_1.default.resolve(body.projectPath)
1583
1553
  : this.projectPath;
1584
1554
  const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, body.key);
1555
+ if (loc.managedByOrgSync || !loc.writePath) {
1556
+ // Enforcement only: block editing a synced org file (it would be
1557
+ // overwritten on next sync). The how-to-change procedure lives in the
1558
+ // organization-onboarding job, not in this error body (issue #563 review).
1559
+ return res.status(409).json({
1560
+ error: 'This organization file is managed by org sync and is read-only here.'
1561
+ });
1562
+ }
1585
1563
  const dest = path_1.default.resolve(loc.writePath);
1586
1564
  // Path-traversal guard: the resolved destination must live under a
1587
1565
  // personalized-employee directory (covers both ~/.fraim/... and repo-local).
@@ -1599,59 +1577,7 @@ class AiHubServer {
1599
1577
  // Re-read so the client gets the canonical post-write state (present flips).
1600
1578
  return res.json(readContextFile(projectPath, body.key));
1601
1579
  });
1602
- // ── Issue #512 R7: Artifact export (md → docx) ──────────────────────────
1603
- // GET /api/ai-hub/artifact/export-docx?path=<abs-path-to-md>
1604
- // Converts a markdown file to .docx using html-to-docx and streams the result.
1605
- // The manager downloads it, annotates in Word, and saves it in place on disk
1606
- // (no upload). The agent then reads the comments + tracked changes via the
1607
- // `apply-docx-changes-to-md` skill (registry script extract-docx-edits.js)
1608
- // during the address-feedback phase.
1609
- this.app.get('/api/ai-hub/artifact/export-docx', async (req, res) => {
1610
- const rawPath = typeof req.query.path === 'string' ? req.query.path : '';
1611
- if (!rawPath)
1612
- return res.status(400).json({ error: 'path is required.' });
1613
- // Safety: path must be under the current workspace or a known safe root.
1614
- const resolved = resolveSafeArtifactPath(rawPath, this.projectPath);
1615
- if (!resolved)
1616
- return res.status(403).json({ error: 'Path outside allowed roots.' });
1617
- if (!fs_1.default.existsSync(resolved))
1618
- return res.status(404).json({ error: 'File not found.' });
1619
- try {
1620
- const md = fs_1.default.readFileSync(resolved, 'utf8');
1621
- const docxBuf = await this.markdownToDocxBuffer(md);
1622
- const basename = path_1.default.basename(resolved, path_1.default.extname(resolved));
1623
- res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
1624
- res.setHeader('Content-Disposition', `attachment; filename="${basename}.docx"`);
1625
- res.send(docxBuf);
1626
- }
1627
- catch (err) {
1628
- res.status(500).json({ error: err instanceof Error ? err.message : 'Export failed.' });
1629
- }
1630
- });
1631
- // POST /api/ai-hub/artifact/export-docx { content, filename? }
1632
- // Converts inline markdown (the employee's conversational deliverable) to .docx
1633
- // when the run produced no on-disk file — e.g. an onboarding answer to an empty
1634
- // repo. Keeps the "annotate in Word" review flow working instead of erroring
1635
- // with "no local artifact path available".
1636
- this.app.post('/api/ai-hub/artifact/export-docx', async (req, res) => {
1637
- const content = typeof req.body?.content === 'string' ? req.body.content : '';
1638
- if (!content.trim())
1639
- return res.status(400).json({ error: 'content is required.' });
1640
- const rawName = typeof req.body?.filename === 'string' && req.body.filename.trim()
1641
- ? req.body.filename.trim()
1642
- : 'deliverable';
1643
- const safeName = rawName.replace(/[^a-zA-Z0-9._ -]/g, '').replace(/\.docx?$/i, '').slice(0, 80) || 'deliverable';
1644
- try {
1645
- const docxBuf = await this.markdownToDocxBuffer(content);
1646
- res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
1647
- res.setHeader('Content-Disposition', `attachment; filename="${safeName}.docx"`);
1648
- res.send(docxBuf);
1649
- }
1650
- catch (err) {
1651
- res.status(500).json({ error: err instanceof Error ? err.message : 'Export failed.' });
1652
- }
1653
- });
1654
- this.app.post('/api/ai-hub/artifact/open', (req, res) => {
1580
+ this.app.post('/api/ai-hub/artifact/open', async (req, res) => {
1655
1581
  const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
1656
1582
  const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
1657
1583
  ? path_1.default.resolve(req.body.projectPath)
@@ -1664,7 +1590,7 @@ class AiHubServer {
1664
1590
  if (!fs_1.default.existsSync(resolved))
1665
1591
  return res.status(404).json({ error: 'File not found.' });
1666
1592
  try {
1667
- hubOpenFile(resolved);
1593
+ await hubOpenFile(resolved);
1668
1594
  return res.json({ ok: true, path: resolved });
1669
1595
  }
1670
1596
  catch (err) {
@@ -210,22 +210,23 @@ const runInitProject = async (options = {}) => {
210
210
  if (!isMinimalConversationMode(preferredMode)) {
211
211
  console.log(chalk_1.default.blue(` Platform: ${formatPlatformLabel(detection.provider)}`));
212
212
  }
213
- }
214
- if (!isMinimalConversationMode(preferredMode)) {
215
- const repo = detection.repository;
216
- if (repo.owner && repo.name) {
217
- console.log(chalk_1.default.gray(` Repository: ${repo.owner}/${repo.name}`));
218
- }
219
- else if (repo.organization && repo.project && repo.name) {
220
- console.log(chalk_1.default.gray(` Organization: ${repo.organization}`));
221
- console.log(chalk_1.default.gray(` Project: ${repo.project}`));
222
- console.log(chalk_1.default.gray(` Repository: ${repo.name}`));
223
- }
224
- else if (repo.namespace && repo.name) {
225
- console.log(chalk_1.default.gray(` Namespace: ${repo.namespace || '(none)'}`));
226
- console.log(chalk_1.default.gray(` Repository: ${repo.name}`));
213
+ else {
214
+ console.log(chalk_1.default.blue(` Repository-backed project: ${formatPlatformLabel(detection.provider)}`));
227
215
  }
228
216
  }
217
+ const repo = detection.repository;
218
+ if (repo.owner && repo.name) {
219
+ console.log(chalk_1.default.gray(` Repository: ${repo.owner}/${repo.name}`));
220
+ }
221
+ else if (repo.organization && repo.project && repo.name) {
222
+ console.log(chalk_1.default.gray(` Organization: ${repo.organization}`));
223
+ console.log(chalk_1.default.gray(` Project: ${repo.project}`));
224
+ console.log(chalk_1.default.gray(` Repository: ${repo.name}`));
225
+ }
226
+ else if (repo.namespace && repo.name) {
227
+ console.log(chalk_1.default.gray(` Namespace: ${repo.namespace || '(none)'}`));
228
+ console.log(chalk_1.default.gray(` Repository: ${repo.name}`));
229
+ }
229
230
  }
230
231
  else {
231
232
  result.mode = 'conversational';
@@ -144,6 +144,42 @@ const runSync = async (options) => {
144
144
  console.log(chalk_1.default.green('Removed legacy FRAIM sync block from .gitignore'));
145
145
  }
146
146
  };
147
+ // Issue #563: refresh the machine-level org cache (~/.fraim/org/) from the
148
+ // configured org backend. Failures never fail the sync: an existing cache is
149
+ // served stale with its age (R2.3, R4.3).
150
+ const refreshOrgCache = async (remoteUrl, apiKey) => {
151
+ // Org sync is a network round-trip to the org backend. In automated
152
+ // tests there is no org configured and no server to reach, so skip it
153
+ // entirely rather than emit warnings or attempt a real request. Local
154
+ // dev (--local / FRAIM_LOCAL_SYNC) passes the loopback URL + 'local-dev'
155
+ // key below, which syncOrgCache treats as "no cloud org" unless a git
156
+ // backend is configured, so it degrades cleanly without this guard.
157
+ if (process.env.TEST_MODE === 'true')
158
+ return;
159
+ const { syncOrgCache } = await Promise.resolve().then(() => __importStar(require('../utils/org-pack-sync')));
160
+ const outcome = await syncOrgCache({ remoteUrl, apiKey });
161
+ if (outcome.status === 'synced') {
162
+ console.log(chalk_1.default.green(`Org context synced (${outcome.metadata.backend}, version ${outcome.metadata.version.slice(0, 12)})`));
163
+ }
164
+ else if (outcome.status === 'stale') {
165
+ console.log(chalk_1.default.yellow(`Org source unreachable. Using cached org context from ${Math.round(outcome.ageHours)}h ago. Will refresh when reachable.`));
166
+ }
167
+ else if (outcome.status === 'absent') {
168
+ console.log(chalk_1.default.yellow(`Org context not synced: ${outcome.error}`));
169
+ }
170
+ // 'disabled' (no org configured) stays silent.
171
+ // R8.1: one-time publish offer for legacy machine-local org files. The
172
+ // publish itself runs through organization-onboarding (propose-and-approve);
173
+ // sync only surfaces the offer and never moves files on its own (R8.2).
174
+ if (outcome.status !== 'disabled') {
175
+ const { detectLegacyOrgArtifacts } = await Promise.resolve().then(() => __importStar(require('../utils/org-migration')));
176
+ const legacy = detectLegacyOrgArtifacts();
177
+ if (legacy.length > 0) {
178
+ console.log(chalk_1.default.yellow(`Found ${legacy.length} legacy machine-local org file(s) at ~/.fraim/personalized-employee/.`));
179
+ console.log(chalk_1.default.yellow('Run the organization-onboarding job to publish them to your shared org backend; the originals are archived to ~/.fraim/backups/.'));
180
+ }
181
+ }
182
+ };
147
183
  const isNpx = process.env.npm_config_prefix === undefined || process.env.npm_lifecycle_event === 'npx';
148
184
  const isGlobal = !isNpx && (process.env.npm_config_global === 'true' || process.env.npm_config_prefix);
149
185
  if (isGlobal && !options.skipUpdates) {
@@ -188,6 +224,7 @@ const runSync = async (options) => {
188
224
  console.log(chalk_1.default.green(line));
189
225
  }
190
226
  }
227
+ await refreshOrgCache(localUrl, 'local-dev');
191
228
  return;
192
229
  }
193
230
  console.error(chalk_1.default.red(`Local sync failed: ${result.error}`));
@@ -241,6 +278,7 @@ const runSync = async (options) => {
241
278
  if (adapterUpdates.length > 0) {
242
279
  console.log(chalk_1.default.green(`Updated FRAIM agent adapter files: ${adapterUpdates.join(', ')}`));
243
280
  }
281
+ await refreshOrgCache(config.remoteUrl || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me', apiKey);
244
282
  };
245
283
  exports.runSync = runSync;
246
284
  exports.syncCommand = new commander_1.Command('sync')
@@ -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 = {