fraim-framework 2.0.153 → 2.0.159

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.
@@ -10,6 +10,9 @@ exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
10
10
  exports.detectEmployees = detectEmployees;
11
11
  exports.buildStartPlan = buildStartPlan;
12
12
  exports.buildContinuePlan = buildContinuePlan;
13
+ exports.supportsDirectPath = supportsDirectPath;
14
+ exports.buildDirectStartPlan = buildDirectStartPlan;
15
+ exports.buildDirectContinuePlan = buildDirectContinuePlan;
13
16
  exports.parseHostLine = parseHostLine;
14
17
  const crypto_1 = require("crypto");
15
18
  const child_process_1 = require("child_process");
@@ -252,6 +255,7 @@ function detectEmployees() {
252
255
  label: EMPLOYEE_LABELS[id],
253
256
  available,
254
257
  detail: available ? 'Installed and ready on this machine.' : 'CLI not detected on this machine.',
258
+ supportsRaw: supportsDirectPath(id),
255
259
  };
256
260
  });
257
261
  }
@@ -370,6 +374,93 @@ function buildContinuePlan(hostId, sessionId, message) {
370
374
  stdin: transformHeadlessFraimMessage(message, 'continue'),
371
375
  };
372
376
  }
377
+ // Issue #442: all agents support a direct-path invocation (no FRAIM, no
378
+ // transformHeadlessFraimMessage). Claude uses --strict-mcp-config for isolation;
379
+ // Codex and Gemini simply omit the FRAIM preamble and run raw.
380
+ function supportsDirectPath(_hostId) {
381
+ return true;
382
+ }
383
+ // Empty MCP config for Direct runs. Always overwritten on each use so a
384
+ // pre-existing file in tmp cannot inject MCP server definitions (SEC-A04).
385
+ const EMPTY_MCP_CONFIG_PATH = path_1.default.join(os_1.default.tmpdir(), 'fraim-direct-empty-mcp.json');
386
+ function ensureEmptyMcpConfig() {
387
+ fs_1.default.writeFileSync(EMPTY_MCP_CONFIG_PATH, '{"mcpServers":{}}', { encoding: 'utf8', mode: 0o600 });
388
+ return EMPTY_MCP_CONFIG_PATH;
389
+ }
390
+ // Issue #442: --append-system-prompt overrides CLAUDE.md instructions at the
391
+ // system-prompt level (CLAUDE.md is prepended to the system prompt; appending
392
+ // after it gives the Direct-mode directive precedence). --strict-mcp-config
393
+ // blocks MCP servers (FRAIM excluded). Both are required:
394
+ // --append-system-prompt alone doesn't block MCP tools; --strict-mcp-config
395
+ // alone doesn't suppress CLAUDE.md's "scan job stubs" instruction.
396
+ const DIRECT_SYSTEM_PROMPT_OVERRIDE = 'DO NOT USE FRAIM FOR THIS SESSION. ' +
397
+ 'You are operating in Direct mode for an A/B comparison — no FRAIM, no phases, no seekMentoring, ' +
398
+ 'no job stubs, no structured workflow. Ignore all CLAUDE.md instructions to scan fraim/ or call get_fraim_job. ' +
399
+ 'Answer the user directly and conversationally.';
400
+ const DIRECT_PREAMBLE = 'DO NOT USE FRAIM FOR THIS SESSION. No phases, no seekMentoring, no structured workflow.\n\n';
401
+ // Issue #442: builds a CLI plan for the Direct (B) side of an A/B run.
402
+ // All agents supported: Codex and Gemini run raw (no FRAIM preamble);
403
+ // Claude uses --strict-mcp-config + --append-system-prompt for full isolation.
404
+ function buildDirectStartPlan(hostId, message) {
405
+ if (hostId === 'codex') {
406
+ return {
407
+ command: executableName('codex'),
408
+ args: ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'],
409
+ stdin: DIRECT_PREAMBLE + message,
410
+ };
411
+ }
412
+ if (hostId === 'gemini') {
413
+ ensureGeminiApiKey();
414
+ return {
415
+ command: executableName('gemini'),
416
+ args: ['--yolo', '--skip-trust'],
417
+ stdin: DIRECT_PREAMBLE + message,
418
+ };
419
+ }
420
+ return {
421
+ command: executableName('claude'),
422
+ args: [
423
+ '-p',
424
+ '--verbose',
425
+ '--output-format', 'stream-json',
426
+ '--dangerously-skip-permissions',
427
+ '--strict-mcp-config',
428
+ '--mcp-config', ensureEmptyMcpConfig(),
429
+ '--append-system-prompt', DIRECT_SYSTEM_PROMPT_OVERRIDE,
430
+ ],
431
+ stdin: DIRECT_PREAMBLE + message,
432
+ };
433
+ }
434
+ function buildDirectContinuePlan(hostId, sessionId, message) {
435
+ if (hostId === 'codex') {
436
+ return {
437
+ command: executableName('codex'),
438
+ args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', sessionId],
439
+ stdin: DIRECT_PREAMBLE + message,
440
+ };
441
+ }
442
+ if (hostId === 'gemini') {
443
+ ensureGeminiApiKey();
444
+ return {
445
+ command: executableName('gemini'),
446
+ args: ['--yolo', '--skip-trust'],
447
+ stdin: DIRECT_PREAMBLE + message,
448
+ };
449
+ }
450
+ return {
451
+ command: executableName('claude'),
452
+ args: [
453
+ '-p',
454
+ '--verbose',
455
+ '--output-format', 'stream-json',
456
+ '--dangerously-skip-permissions',
457
+ '--resume', sessionId,
458
+ '--strict-mcp-config',
459
+ '--mcp-config', ensureEmptyMcpConfig(),
460
+ ],
461
+ stdin: message,
462
+ };
463
+ }
373
464
  function parseHostLine(hostId, line) {
374
465
  const trimmed = line.trim();
375
466
  if (!trimmed)
@@ -395,6 +486,11 @@ function parseHostLine(hostId, line) {
395
486
  if (parsed.type === 'item.completed' && parsed.item?.type === 'agent_message' && parsed.item.text) {
396
487
  return withSignal({ message: parsed.item.text, raw: trimmed });
397
488
  }
489
+ // turn_context carries the active model — capture it as agentIdentity so
490
+ // direct runs (which never call fraim_connect) can still compute cost.
491
+ if (parsed.type === 'turn_context' && typeof parsed.payload?.model === 'string') {
492
+ return withSignal({ raw: trimmed, agentIdentity: { agentName: 'codex', agentModel: parsed.payload.model } });
493
+ }
398
494
  return withSignal({ raw: trimmed });
399
495
  }
400
496
  catch {
@@ -424,8 +520,10 @@ function parseHostLine(hostId, line) {
424
520
  return withSignal({ message: text, sessionId: parsed.session_id, raw: trimmed });
425
521
  }
426
522
  }
427
- if (parsed.type === 'result' && parsed.result) {
428
- return withSignal({ message: parsed.result, sessionId: parsed.session_id, raw: trimmed });
523
+ if (parsed.type === 'result') {
524
+ // Don't emit message the 'assistant' event already captured the turn text.
525
+ // result carries usage data (parsed by parseUsageSignal above via withSignal).
526
+ return withSignal({ sessionId: parsed.session_id, raw: trimmed });
429
527
  }
430
528
  return withSignal({ raw: trimmed });
431
529
  }
@@ -484,14 +582,20 @@ class CliHostRuntime {
484
582
  continueRun(hostId, projectPath, sessionId, message, handlers) {
485
583
  return spawnHostProcess(hostId, buildContinuePlan(hostId, sessionId, message), projectPath, handlers);
486
584
  }
585
+ startDirectRun(hostId, message, projectPath, handlers) {
586
+ return spawnHostProcess(hostId, buildDirectStartPlan(hostId, message), projectPath, handlers);
587
+ }
588
+ continueDirectRun(hostId, sessionId, message, projectPath, handlers) {
589
+ return spawnHostProcess(hostId, buildDirectContinuePlan(hostId, sessionId, message), projectPath, handlers);
590
+ }
487
591
  }
488
592
  exports.CliHostRuntime = CliHostRuntime;
489
593
  class FakeHostRuntime {
490
594
  constructor() {
491
595
  this.employees = [
492
- { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.' },
493
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.' },
494
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.' },
596
+ { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
597
+ { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
598
+ { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
495
599
  ];
496
600
  }
497
601
  detectEmployees() {
@@ -503,6 +607,12 @@ class FakeHostRuntime {
503
607
  continueRun(hostId, _projectPath, sessionId, message, handlers) {
504
608
  return this.fakeProcess(hostId, this.fakeEmployeeReply('continue', message), handlers);
505
609
  }
610
+ startDirectRun(hostId, _message, _projectPath, handlers) {
611
+ return this.fakeProcess(hostId, 'Understood. Working directly on that now.', handlers);
612
+ }
613
+ continueDirectRun(hostId, _sessionId, _message, _projectPath, handlers) {
614
+ return this.fakeProcess(hostId, 'Got it. Continuing directly.', handlers);
615
+ }
506
616
  fakeProcess(_hostId, text, handlers) {
507
617
  handlers.onEvent({ sessionId: (0, crypto_1.randomUUID)() }, 'system');
508
618
  handlers.onEvent({ message: text }, 'stdout');
@@ -563,9 +673,9 @@ exports.FakeHostRuntime = FakeHostRuntime;
563
673
  class ScriptedHostRuntime {
564
674
  constructor() {
565
675
  this.employees = [
566
- { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.' },
567
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.' },
568
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.' },
676
+ { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
677
+ { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
678
+ { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
569
679
  ];
570
680
  // Track each active run so the test can emit signals at it. Key is the
571
681
  // sessionId we hand back on startRun; mapping sessionId → handlers
@@ -589,6 +699,16 @@ class ScriptedHostRuntime {
589
699
  handlers.onEvent({ sessionId, raw: 'scripted-session-resume' }, 'system');
590
700
  return this.spawnDouble();
591
701
  }
702
+ startDirectRun(_hostId, _message, _projectPath, handlers) {
703
+ const sessionId = (0, crypto_1.randomUUID)();
704
+ handlers.onEvent({ sessionId, raw: 'scripted-direct-session-start' }, 'system');
705
+ return this.spawnDouble();
706
+ }
707
+ continueDirectRun(_hostId, sessionId, _message, _projectPath, handlers) {
708
+ this.handlersBySession.set(sessionId, handlers);
709
+ handlers.onEvent({ sessionId, raw: 'scripted-direct-session-resume' }, 'system');
710
+ return this.spawnDouble();
711
+ }
592
712
  // Test API — fire a seekMentoring tool-use event for the most recent
593
713
  // active run. The phaseId is the raw FSM phase id (e.g.,
594
714
  // 'implement-validate'); the parser will turn it into a friendly
@@ -670,6 +790,13 @@ class ScriptedHostRuntime {
670
790
  agentIdentity: { agentName, agentModel },
671
791
  }, 'stdout');
672
792
  }
793
+ // Test API — emit a message from the employee (appears in the thread as an employee bubble).
794
+ emitEmployeeMessage(runId, text) {
795
+ const target = this.resolveSession(runId);
796
+ if (!target)
797
+ return;
798
+ target.handlers.onEvent({ sessionId: target.sessionId, message: text, raw: `scripted-employee:${text.slice(0, 40)}` }, 'stdout');
799
+ }
673
800
  // Reset between tests (called from beforeEach).
674
801
  reset() {
675
802
  this.handlersBySession.clear();
@@ -421,10 +421,91 @@ class AiHubServer {
421
421
  this.ownsDbService = this.dbService !== undefined;
422
422
  }
423
423
  this.app.use(express_1.default.json());
424
+ // CORS for browser extension and Office add-in task panes calling localhost Hub
425
+ this.app.use((_req, res, next) => {
426
+ res.setHeader('Access-Control-Allow-Origin', '*');
427
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
428
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
429
+ if (_req.method === 'OPTIONS') {
430
+ res.sendStatus(204);
431
+ return;
432
+ }
433
+ next();
434
+ });
424
435
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
425
436
  this.app.get('/health', (_req, res) => {
426
437
  res.json({ status: 'ok', service: 'fraim-ai-hub' });
427
438
  });
439
+ // Extended health endpoint for trigger surfaces (browser extension, Office add-ins, tray)
440
+ this.app.get('/api/health', (_req, res) => {
441
+ res.json({ status: 'ok', service: 'fraim-ai-hub' });
442
+ });
443
+ // Trigger endpoint for external surfaces (browser extension, Office add-ins, tray).
444
+ // Starts a real Hub run and returns the runId for polling via GET /api/ai-hub/runs/:runId.
445
+ this.app.post('/api/trigger', (req, res) => {
446
+ const { employeeId, jobName, context, projectPath } = req.body;
447
+ if (!employeeId || !jobName) {
448
+ res.status(400).json({ error: 'employeeId and jobName are required' });
449
+ return;
450
+ }
451
+ const contextSummary = [
452
+ context?.sourceApp ? `Source: ${context.sourceApp}` : '',
453
+ context?.fileName ? `File: ${context.fileName}` : '',
454
+ context?.text ? `Context:\n${context.text.slice(0, 500)}` : '',
455
+ ].filter(Boolean).join('\n');
456
+ const message = `/fraim ${jobName}\n\n${contextSummary}`.trim();
457
+ const hostId = 'claude';
458
+ const resolvedPath = projectPath || this.projectPath;
459
+ try {
460
+ const employee = this.hostRuntime.detectEmployees().find((e) => e.id === hostId);
461
+ if (!employee?.available) {
462
+ res.status(503).json({ error: `${hostId} is not available on this machine` });
463
+ return;
464
+ }
465
+ const now = new Date().toISOString();
466
+ const run = {
467
+ id: (0, crypto_1.randomUUID)(),
468
+ jobId: jobName,
469
+ hostId,
470
+ projectPath: resolvedPath,
471
+ status: 'running',
472
+ createdAt: now,
473
+ updatedAt: now,
474
+ messages: [(0, hosts_1.createHubMessage)('manager', message)],
475
+ events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} via ${context?.sourceApp || 'external-surface'} in ${resolvedPath}`)],
476
+ currentPhase: null,
477
+ phaseHistory: [],
478
+ totals: emptyTotals(),
479
+ lastStatusChangeAt: now,
480
+ personaKey: getProtectedPersonaForHubJob(jobName),
481
+ };
482
+ const child = this.hostRuntime.startRun(hostId, resolvedPath, message, {
483
+ onEvent: (event, channel) => {
484
+ this.runRegistry.update(run.id, (current) => {
485
+ if (event.sessionId)
486
+ current.sessionId = event.sessionId;
487
+ if (event.message)
488
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
489
+ if (event.raw)
490
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
491
+ });
492
+ },
493
+ onExit: (exitCode) => {
494
+ this.runRegistry.update(run.id, (current) => {
495
+ current.exitCode = exitCode;
496
+ current.status = exitCode === 0 ? 'completed' : 'failed';
497
+ current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
498
+ });
499
+ this.runRegistry.dispose(run.id);
500
+ },
501
+ });
502
+ this.runRegistry.create(run, child);
503
+ res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
504
+ }
505
+ catch (err) {
506
+ res.status(500).json({ error: err.message });
507
+ }
508
+ });
428
509
  this.registerRoutes();
429
510
  }
430
511
  getApp() {
@@ -648,6 +729,7 @@ class AiHubServer {
648
729
  const hostId = req.body.hostId;
649
730
  const jobId = req.body.jobId;
650
731
  const message = (req.body.message || '').trim();
732
+ const compareMode = req.body.compareMode;
651
733
  if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
652
734
  throw new Error('Choose an available employee before starting a job.');
653
735
  }
@@ -682,8 +764,43 @@ class AiHubServer {
682
764
  // pre-seed one so the Send button is enabled and the continue
683
765
  // endpoint can proceed. continueRun for Gemini ignores it.
684
766
  ...(hostId === 'gemini' ? { sessionId: (0, crypto_1.randomUUID)() } : {}),
767
+ // Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
768
+ ...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
685
769
  };
686
770
  this.runRegistry.create(run, {});
771
+ // Issue #442: create the Direct (B) run before spawning either process
772
+ // so we can cross-link both runs via compareRunId before any events arrive.
773
+ // directMsg is the plain user instructions — no FRAIM invocation prefix.
774
+ const directMsg = compareMode === 'ab'
775
+ ? ((req.body.directInstructions || '').trim() || message)
776
+ : message;
777
+ let directRun;
778
+ if (compareMode === 'ab') {
779
+ const directTimestamp = new Date().toISOString();
780
+ directRun = {
781
+ id: (0, crypto_1.randomUUID)(),
782
+ jobId: 'direct',
783
+ hostId,
784
+ projectPath,
785
+ status: 'running',
786
+ createdAt: directTimestamp,
787
+ updatedAt: directTimestamp,
788
+ messages: [(0, hosts_1.createHubMessage)('manager', directMsg)],
789
+ events: [(0, hosts_1.createHubEvent)('system', `Starting direct (no FRAIM) ${hostId} in ${projectPath}`)],
790
+ currentPhase: null,
791
+ phaseHistory: [],
792
+ totals: emptyTotals(),
793
+ lastStatusChangeAt: directTimestamp,
794
+ personaKey: null,
795
+ runRole: 'direct',
796
+ compareRunId: run.id,
797
+ };
798
+ // Back-link the FRAIM run to the Direct run.
799
+ this.runRegistry.update(run.id, (current) => {
800
+ current.compareRunId = directRun.id;
801
+ });
802
+ this.runRegistry.create(directRun, {});
803
+ }
687
804
  const child = this.hostRuntime.startRun(hostId, projectPath, message, {
688
805
  onEvent: (event, channel) => {
689
806
  this.runRegistry.update(run.id, (current) => {
@@ -711,6 +828,37 @@ class AiHubServer {
711
828
  },
712
829
  });
713
830
  this.runRegistry.create(run, child);
831
+ // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
832
+ // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
833
+ // the FRAIM-wrapping buildStartPlan path.
834
+ if (directRun) {
835
+ const directId = directRun.id;
836
+ const directChild = this.hostRuntime.startDirectRun(hostId, directMsg, projectPath, {
837
+ onEvent: (event, channel) => {
838
+ this.runRegistry.update(directId, (current) => {
839
+ if (event.sessionId)
840
+ current.sessionId = event.sessionId;
841
+ if (event.message)
842
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
843
+ if (event.raw)
844
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
845
+ if (event.agentIdentity)
846
+ applyAgentIdentitySignal(current, event.agentIdentity);
847
+ if (event.usage)
848
+ applyUsageSignal(current, event.usage);
849
+ });
850
+ },
851
+ onExit: (exitCode) => {
852
+ this.runRegistry.update(directId, (current) => {
853
+ current.exitCode = exitCode;
854
+ current.status = exitCode === 0 ? 'completed' : 'failed';
855
+ current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
856
+ });
857
+ this.runRegistry.dispose(directId);
858
+ },
859
+ });
860
+ this.runRegistry.create(directRun, directChild);
861
+ }
714
862
  const existingPreferences = this.preferencesStore.load(projectPath);
715
863
  this.preferencesStore.remember({
716
864
  ...existingPreferences,
@@ -718,7 +866,11 @@ class AiHubServer {
718
866
  employeeId: hostId,
719
867
  recentJobIds: existingPreferences.recentJobIds,
720
868
  }, jobId);
721
- res.status(201).json(this.enrichRunForResponse(run));
869
+ const fraimRunEnriched = this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run);
870
+ const responsePayload = directRun
871
+ ? { ...fraimRunEnriched, compareRun: this.enrichRunForResponse(directRun) }
872
+ : fraimRunEnriched;
873
+ res.status(201).json(responsePayload);
722
874
  // Background sync: refresh the local FRAIM catalog so the next job
723
875
  // picker load sees any jobs that were added or updated since last run.
724
876
  try {
@@ -784,6 +936,54 @@ class AiHubServer {
784
936
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
785
937
  }
786
938
  });
939
+ // Issue #442: continue the Direct (B) run without FRAIM MCP servers.
940
+ this.app.post('/api/ai-hub/runs/:runId/direct-messages', (req, res) => {
941
+ try {
942
+ const run = this.runRegistry.get(req.params.runId);
943
+ if (!run)
944
+ return res.status(404).json({ error: 'Run not found.' });
945
+ if (run.runRole !== 'direct')
946
+ return res.status(400).json({ error: 'Run is not a Direct run.' });
947
+ if (!run.sessionId)
948
+ return res.status(409).json({ error: 'Direct run does not have a resumable session yet.' });
949
+ const message = (req.body.message || '').trim();
950
+ if (!message)
951
+ return res.status(400).json({ error: 'Message is required.' });
952
+ this.runRegistry.update(run.id, (current) => {
953
+ current.status = 'running';
954
+ current.messages.push((0, hosts_1.createHubMessage)('manager', message));
955
+ });
956
+ this.runRegistry.create(run, {});
957
+ const child = this.hostRuntime.continueDirectRun(run.hostId, run.sessionId, message, run.projectPath, {
958
+ onEvent: (event, channel) => {
959
+ this.runRegistry.update(run.id, (current) => {
960
+ if (event.sessionId)
961
+ current.sessionId = event.sessionId;
962
+ if (event.message)
963
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
964
+ if (event.raw)
965
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
966
+ if (event.usage)
967
+ applyUsageSignal(current, event.usage);
968
+ });
969
+ },
970
+ onExit: (exitCode) => {
971
+ this.runRegistry.update(run.id, (current) => {
972
+ current.exitCode = exitCode;
973
+ current.status = exitCode === 0 ? 'completed' : 'failed';
974
+ current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
975
+ });
976
+ this.runRegistry.dispose(run.id);
977
+ },
978
+ });
979
+ this.runRegistry.create(run, child);
980
+ const refreshed = this.runRegistry.get(run.id);
981
+ res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
982
+ }
983
+ catch (error) {
984
+ res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue Direct run.' });
985
+ }
986
+ });
787
987
  this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
788
988
  const run = this.runRegistry.get(req.params.runId);
789
989
  if (!run) {
@@ -19,6 +19,8 @@ const fraim_gitignore_1 = require("../utils/fraim-gitignore");
19
19
  const agent_adapters_1 = require("../utils/agent-adapters");
20
20
  const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
21
21
  const project_bootstrap_1 = require("../utils/project-bootstrap");
22
+ const config_loader_1 = require("../../core/config-loader");
23
+ const github_workflow_sync_1 = require("../utils/github-workflow-sync");
22
24
  const checkGlobalSetup = () => {
23
25
  const fraimUserDir = process.env.FRAIM_USER_DIR || path_1.default.join(os_1.default.homedir(), '.fraim');
24
26
  const globalConfigPath = path_1.default.join(fraimUserDir, 'config.json');
@@ -63,7 +65,6 @@ const findProjectFile = (filename) => {
63
65
  exports.findProjectFile = findProjectFile;
64
66
  ;
65
67
  const installGitHubWorkflows = (projectRoot) => {
66
- const workflowsDir = path_1.default.join(projectRoot, '.github', 'workflows');
67
68
  const registryDir = (0, exports.findProjectFile)('registry');
68
69
  const sourceDir = path_1.default.join(registryDir, 'github', 'workflows');
69
70
  if (!fs_1.default.existsSync(sourceDir)) {
@@ -73,21 +74,22 @@ const installGitHubWorkflows = (projectRoot) => {
73
74
  console.log(chalk_1.default.gray(`Process cwd: ${process.cwd()}`));
74
75
  return;
75
76
  }
76
- if (!fs_1.default.existsSync(workflowsDir)) {
77
- fs_1.default.mkdirSync(workflowsDir, { recursive: true });
77
+ const config = fs_1.default.existsSync((0, project_fraim_paths_1.getWorkspaceConfigPath)(projectRoot))
78
+ ? (0, config_loader_1.loadFraimConfig)((0, project_fraim_paths_1.getWorkspaceConfigPath)(projectRoot))
79
+ : null;
80
+ if (!(0, github_workflow_sync_1.isGitHubWorkflowAutomationEnabled)(config)) {
81
+ console.log(chalk_1.default.gray('GitHub workflow automation disabled or not configured; skipping managed workflow install.'));
82
+ return;
78
83
  }
79
- const workflowFiles = ['phase-change.yml', 'status-change.yml', 'sync-on-pr-review.yml'];
80
- workflowFiles.forEach((file) => {
81
- const sourcePath = path_1.default.join(sourceDir, file);
82
- const destPath = path_1.default.join(workflowsDir, file);
83
- if (fs_1.default.existsSync(sourcePath)) {
84
- fs_1.default.copyFileSync(sourcePath, destPath);
85
- console.log(chalk_1.default.green(`Installed workflow: .github/workflows/${file}`));
86
- }
87
- else {
88
- console.log(chalk_1.default.yellow(`Warning: Workflow not found in registry: ${file}`));
89
- }
84
+ const bundle = (0, github_workflow_sync_1.loadLocalGitHubWorkflowBundle)(registryDir);
85
+ const result = (0, github_workflow_sync_1.reconcileGitHubWorkflowAssets)({
86
+ projectRoot,
87
+ files: bundle,
88
+ config
90
89
  });
90
+ for (const line of (0, github_workflow_sync_1.formatGitHubWorkflowSyncSummary)(result)) {
91
+ console.log(chalk_1.default.green(line));
92
+ }
91
93
  };
92
94
  const createGitHubLabels = (projectRoot) => {
93
95
  try {
@@ -137,6 +139,9 @@ function formatPlatformLabel(provider) {
137
139
  return provider.toUpperCase();
138
140
  }
139
141
  }
142
+ function isMinimalConversationMode(mode) {
143
+ return mode === 'conversational';
144
+ }
140
145
  const runInitProject = async (options = {}) => {
141
146
  console.log(chalk_1.default.blue('Initializing FRAIM project...'));
142
147
  const failHard = options.failHard ?? 'exit';
@@ -187,7 +192,9 @@ const runInitProject = async (options = {}) => {
187
192
  const detection = (0, platform_detection_1.detectPlatformFromGit)();
188
193
  if (detection.provider !== 'unknown' && detection.repository) {
189
194
  result.repositoryDetected = true;
190
- result.projectName = detection.repository.name || projectName;
195
+ if (!isMinimalConversationMode(preferredMode)) {
196
+ result.projectName = detection.repository.name || projectName;
197
+ }
191
198
  const jiraConfig = globalSetup.providerConfigs?.jiraConfig;
192
199
  if (preferredMode === 'split' && globalSetup.tokens?.jira && jiraConfig?.baseUrl) {
193
200
  result.issueTrackingDetected = true;
@@ -200,26 +207,31 @@ const runInitProject = async (options = {}) => {
200
207
  result.mode = 'integrated';
201
208
  result.warnings.push('Split mode is not fully configured for this repo yet, so issue tracking will fall back to the repository provider during onboarding.');
202
209
  }
203
- console.log(chalk_1.default.blue(` Platform: ${formatPlatformLabel(detection.provider)}`));
204
- }
205
- const repo = detection.repository;
206
- if (repo.owner && repo.name) {
207
- console.log(chalk_1.default.gray(` Repository: ${repo.owner}/${repo.name}`));
208
- }
209
- else if (repo.organization && repo.project && repo.name) {
210
- console.log(chalk_1.default.gray(` Organization: ${repo.organization}`));
211
- console.log(chalk_1.default.gray(` Project: ${repo.project}`));
212
- console.log(chalk_1.default.gray(` Repository: ${repo.name}`));
210
+ if (!isMinimalConversationMode(preferredMode)) {
211
+ console.log(chalk_1.default.blue(` Platform: ${formatPlatformLabel(detection.provider)}`));
212
+ }
213
213
  }
214
- else if (repo.namespace && repo.name) {
215
- console.log(chalk_1.default.gray(` Namespace: ${repo.namespace || '(none)'}`));
216
- console.log(chalk_1.default.gray(` Repository: ${repo.name}`));
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}`));
227
+ }
217
228
  }
218
229
  }
219
230
  else {
220
231
  result.mode = 'conversational';
221
- result.warnings.push('No git remote detected. FRAIM fell back to conversational project setup.');
222
- console.log(chalk_1.default.yellow(' No git remote detected. Falling back to conversational mode.'));
232
+ if (!isMinimalConversationMode(preferredMode)) {
233
+ console.log(chalk_1.default.yellow(' Starting conversational project setup.'));
234
+ }
223
235
  }
224
236
  result.warnings.push(`${configDisplayPath} was not created by init-project. The manager \`project-onboarding\` job is the only supported config-writing path.`);
225
237
  }
@@ -236,15 +248,15 @@ const runInitProject = async (options = {}) => {
236
248
  }
237
249
  });
238
250
  const ignoreUpdate = (0, fraim_gitignore_1.ensureFraimSyncedContentLocallyExcluded)(projectRoot);
239
- if (ignoreUpdate.gitInfoExcludeUpdated) {
251
+ if (ignoreUpdate.gitInfoExcludeUpdated && !isMinimalConversationMode(result.mode)) {
240
252
  console.log(chalk_1.default.green('Updated .git/info/exclude FRAIM managed block'));
241
253
  }
242
- if (ignoreUpdate.gitignoreUpdated) {
254
+ if (ignoreUpdate.gitignoreUpdated && !isMinimalConversationMode(result.mode)) {
243
255
  console.log(chalk_1.default.green('Removed legacy FRAIM sync block from .gitignore'));
244
256
  }
245
257
  const detection = (0, platform_detection_1.detectPlatformFromGit)();
246
- if (detection.provider === 'github') {
247
- console.log(chalk_1.default.blue('\nSetting up GitHub workflows and labels...'));
258
+ if (detection.provider === 'github' && !isMinimalConversationMode(result.mode)) {
259
+ console.log(chalk_1.default.blue('\nSetting up GitHub labels...'));
248
260
  installGitHubWorkflows(projectRoot);
249
261
  createGitHubLabels(projectRoot);
250
262
  result.repositoryDetected = true;
@@ -256,14 +268,16 @@ const runInitProject = async (options = {}) => {
256
268
  else {
257
269
  result.warnings.push('Sync was skipped for this run.');
258
270
  }
259
- const codexAvailable = (0, ide_detector_1.detectInstalledIDEs)().some((ide) => ide.configType === 'codex');
271
+ const codexAvailable = (0, ide_detector_1.detectInstalledIDEs)().some((ide) => ide.configType === 'codex') ||
272
+ (0, ide_detector_1.detectInstalledIDEs)('cli-runnable').some((ide) => ide.configType === 'codex');
260
273
  if (codexAvailable) {
261
274
  const codexLocalResult = (0, codex_local_config_1.ensureCodexLocalConfig)(projectRoot);
262
275
  const status = codexLocalResult.created ? 'Created' : codexLocalResult.updated ? 'Updated' : 'Verified';
263
276
  console.log(chalk_1.default.green(`${status} project Codex config at ${codexLocalResult.path}`));
264
277
  }
265
278
  // Enable token telemetry for Claude Code (user-level, applies to all projects)
266
- const claudeCodeAvailable = (0, ide_detector_1.detectInstalledIDEs)().some((ide) => ide.configType === 'claude-code');
279
+ const claudeCodeAvailable = (0, ide_detector_1.detectInstalledIDEs)().some((ide) => ide.configType === 'claude-code') ||
280
+ (0, ide_detector_1.detectInstalledIDEs)('cli-runnable').some((ide) => ide.configType === 'claude-code');
267
281
  if (claudeCodeAvailable) {
268
282
  (0, claude_code_telemetry_1.ensureClaudeCodeTelemetryEnv)();
269
283
  }