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.
- package/dist/src/ai-hub/hosts.js +135 -8
- package/dist/src/ai-hub/server.js +201 -1
- package/dist/src/cli/commands/init-project.js +50 -36
- package/dist/src/cli/commands/sync.js +22 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
- package/dist/src/cli/utils/github-workflow-sync.js +231 -0
- package/dist/src/cli/utils/managed-agent-paths.js +1 -1
- package/dist/src/cli/utils/project-bootstrap.js +6 -3
- package/dist/src/core/ai-mentor.js +46 -37
- package/dist/src/core/config-loader.js +68 -0
- package/dist/src/core/fraim-config-schema.generated.js +267 -1
- package/dist/src/core/utils/fraim-labels.js +182 -0
- package/dist/src/core/utils/git-utils.js +22 -1
- package/dist/src/core/utils/project-fraim-paths.js +58 -0
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
- package/dist/src/local-mcp-server/stdio-server.js +212 -13
- package/package.json +8 -3
- package/public/ai-hub/index.html +271 -229
- package/public/ai-hub/script.js +879 -527
- package/public/ai-hub/styles.css +877 -694
- package/public/first-run/index.html +35 -35
- package/public/first-run/script.js +667 -667
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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'
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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
|
}
|