fraim 2.0.134 → 2.0.135

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.
@@ -27,11 +27,13 @@ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
27
27
  // (per-project override layer; CLAUDE.md says it
28
28
  // "takes precedence over synced baseline content")
29
29
  const EMPLOYEE_JOB_LAYERS = [
30
+ { base: 'registry', segments: ['jobs', 'ai-employee'] },
30
31
  { base: 'fraim', segments: ['ai-employee', 'jobs'] },
31
32
  { base: 'fraim', segments: ['personalized-employee', 'jobs'] },
32
33
  ];
33
34
  // Manager templates use the matching two-layer model.
34
35
  const MANAGER_JOB_LAYERS = [
36
+ { base: 'registry', segments: ['jobs', 'ai-manager'] },
35
37
  { base: 'fraim', segments: ['ai-manager', 'jobs'] },
36
38
  { base: 'fraim', segments: ['personalized-employee', 'manager-jobs'] },
37
39
  ];
@@ -137,7 +139,8 @@ function summarizeProject(projectPath) {
137
139
  };
138
140
  }
139
141
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
140
- const hasFraim = fs_1.default.existsSync(fraimDir);
142
+ const registryJobsDir = path_1.default.join(projectPath, 'registry', 'jobs');
143
+ const hasFraim = fs_1.default.existsSync(fraimDir) || fs_1.default.existsSync(registryJobsDir);
141
144
  if (!hasFraim) {
142
145
  return {
143
146
  path: projectPath,
@@ -154,6 +157,9 @@ function summarizeProject(projectPath) {
154
157
  };
155
158
  }
156
159
  function resolveLayerRoot(projectPath, layer) {
160
+ if (layer.base === 'registry') {
161
+ return path_1.default.join(projectPath, 'registry', ...layer.segments);
162
+ }
157
163
  return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
158
164
  }
159
165
  function discoverLayers(projectPath, layers) {
@@ -8,7 +8,7 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
10
10
  const DEFAULT_CATEGORY = 'marketing';
11
- const DEFAULT_EMPLOYEE = 'codex';
11
+ const DEFAULT_EMPLOYEE = 'claude';
12
12
  const defaultPreferences = (projectPath) => ({
13
13
  projectPath,
14
14
  employeeId: DEFAULT_EMPLOYEE,
@@ -27,8 +27,8 @@ class AiHubPreferencesStore {
27
27
  const raw = JSON.parse(fs_1.default.readFileSync(this.stateFilePath, 'utf8'));
28
28
  return {
29
29
  projectPath: raw.projectPath || projectPath,
30
- employeeId: raw.employeeId === 'claude' ? 'claude' : DEFAULT_EMPLOYEE,
31
- categoryId: raw.categoryId === 'go-to-market' ? 'go-to-market' : DEFAULT_CATEGORY,
30
+ employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex') ? raw.employeeId : DEFAULT_EMPLOYEE,
31
+ categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
32
32
  recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
33
33
  };
34
34
  }
@@ -210,16 +210,26 @@ function deriveStages(run, projectPath) {
210
210
  const currentIndex = run.currentPhase
211
211
  ? declaredPath.findIndex((p) => p.id === run.currentPhase)
212
212
  : -1;
213
+ const historyMap = new Map((run.phaseHistory || []).map((e) => [e.phaseId, e]));
213
214
  return declaredPath.map((phase, index) => {
214
215
  let state;
215
- if (currentIndex < 0)
216
+ if (currentIndex < 0) {
216
217
  state = 'upcoming';
217
- else if (index < currentIndex)
218
+ }
219
+ else if (index < currentIndex) {
218
220
  state = 'done';
219
- else if (index === currentIndex)
220
- state = 'current';
221
- else
221
+ }
222
+ else if (index === currentIndex) {
223
+ // If the agent has already reported this phase as 'complete', advance
224
+ // its visual state to 'done' so the tracker doesn't look frozen while
225
+ // waiting for the agent to start the next phase (e.g. after
226
+ // implement-submission completes but before address-feedback starts).
227
+ const entry = historyMap.get(phase.id);
228
+ state = entry?.latestStatus === 'complete' ? 'done' : 'current';
229
+ }
230
+ else {
222
231
  state = 'upcoming';
232
+ }
223
233
  return { phaseId: phase.id, label: phase.label, state };
224
234
  });
225
235
  }
@@ -275,7 +285,18 @@ class AiHubServer {
275
285
  }
276
286
  bootstrapResponse(projectPath) {
277
287
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
278
- const preferences = this.preferencesStore.load(normalizedProjectPath);
288
+ const employees = this.hostRuntime.detectEmployees();
289
+ let preferences = this.preferencesStore.load(normalizedProjectPath);
290
+ // If the stored employee isn't available on this machine, auto-select the
291
+ // first available one so the Hub never opens showing "(unavailable)".
292
+ const storedAvailable = employees.find((e) => e.id === preferences.employeeId)?.available ?? false;
293
+ if (!storedAvailable) {
294
+ const firstAvailable = employees.find((e) => e.available);
295
+ if (firstAvailable) {
296
+ preferences = { ...preferences, employeeId: firstAvailable.id };
297
+ this.preferencesStore.save(preferences);
298
+ }
299
+ }
279
300
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
280
301
  const jobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
281
302
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
@@ -291,7 +312,7 @@ class AiHubServer {
291
312
  categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath),
292
313
  jobs,
293
314
  managerTemplates,
294
- employees: this.hostRuntime.detectEmployees(),
315
+ employees,
295
316
  activeRun,
296
317
  };
297
318
  }
@@ -102,7 +102,7 @@ exports.IDE_CONFIGS = [
102
102
  configPath: '~/.gemini/antigravity/mcp_config.json',
103
103
  configFormat: 'json',
104
104
  configType: 'standard',
105
- invocationProfile: 'instructions-only',
105
+ invocationProfile: 'cursor-mention',
106
106
  detectMethod: () => fs_1.default.existsSync(expandPath('~/.gemini/antigravity')),
107
107
  description: 'Google Gemini Antigravity IDE'
108
108
  },
@@ -72,6 +72,10 @@ async function installGlobalRules(homeDir) {
72
72
  if (fs_1.default.existsSync(geminiDir)) {
73
73
  installFileIfMissing(path_1.default.join(geminiDir, 'commands', 'fraim.toml'), (0, ide_invocation_surfaces_1.buildGeminiCommandContent)(), 'Gemini CLI FRAIM command (~/.gemini/commands/fraim.toml)');
74
74
  }
75
+ const antigravityDir = path_1.default.join(home, '.gemini', 'antigravity');
76
+ if (fs_1.default.existsSync(antigravityDir)) {
77
+ installFileIfMissing(path_1.default.join(antigravityDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildAntigravityCommandContent)(), 'Antigravity FRAIM command (~/.gemini/antigravity/commands/fraim.md)');
78
+ }
75
79
  const windsurfDir = path_1.default.join(home, '.codeium', 'windsurf');
76
80
  if (fs_1.default.existsSync(windsurfDir)) {
77
81
  installFileIfMissing(path_1.default.join(windsurfDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildWindsurfCommandContent)(), 'Windsurf FRAIM command (~/.codeium/windsurf/commands/fraim.md)');
@@ -9,6 +9,7 @@ exports.buildCursorMentionRuleContent = buildCursorMentionRuleContent;
9
9
  exports.buildCodexSkillContent = buildCodexSkillContent;
10
10
  exports.buildWindsurfCommandContent = buildWindsurfCommandContent;
11
11
  exports.buildKiroCommandContent = buildKiroCommandContent;
12
+ exports.buildAntigravityCommandContent = buildAntigravityCommandContent;
12
13
  exports.buildGeminiCommandContent = buildGeminiCommandContent;
13
14
  exports.describeInvocationSurface = describeInvocationSurface;
14
15
  exports.FRAIM_LAUNCH_PHRASE = 'Use FRAIM for <job or task>';
@@ -111,6 +112,11 @@ ${buildFraimInvocationBody('generic-tool-discovery')}`;
111
112
  function escapeTomlMultiline(value) {
112
113
  return value.replace(/"""/g, '\\"""');
113
114
  }
115
+ function buildAntigravityCommandContent() {
116
+ return `# FRAIM
117
+
118
+ ${buildFraimInvocationBody('generic-tool-discovery')}`;
119
+ }
114
120
  function buildGeminiCommandContent() {
115
121
  return `description = "Discover and execute FRAIM jobs and skills"
116
122
  prompt = """
@@ -231,6 +231,37 @@ exports.FRAIM_CONFIG_SCHEMA = {
231
231
  "required": true
232
232
  }
233
233
  }
234
+ },
235
+ "stakeholderUpdate": {
236
+ "kind": "object",
237
+ "properties": {
238
+ "stakeholderListPath": {
239
+ "kind": "string"
240
+ },
241
+ "cadence": {
242
+ "kind": "enum",
243
+ "values": [
244
+ "monthly",
245
+ "quarterly"
246
+ ]
247
+ },
248
+ "historyPath": {
249
+ "kind": "string"
250
+ }
251
+ }
252
+ },
253
+ "operatingReview": {
254
+ "kind": "object",
255
+ "properties": {
256
+ "cadence": {
257
+ "kind": "enum",
258
+ "values": [
259
+ "weekly",
260
+ "bi-weekly",
261
+ "monthly"
262
+ ]
263
+ }
264
+ }
234
265
  }
235
266
  },
236
267
  "required": true
@@ -292,5 +323,11 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
292
323
  "customer-communication.senderEmail",
293
324
  "customer-communication.senderReplyTo",
294
325
  "customer-communication.newsletterAudienceProvider",
295
- "customer-communication.deliveryProvider"
326
+ "customer-communication.deliveryProvider",
327
+ "stakeholderUpdate",
328
+ "stakeholderUpdate.stakeholderListPath",
329
+ "stakeholderUpdate.cadence",
330
+ "stakeholderUpdate.historyPath",
331
+ "operatingReview",
332
+ "operatingReview.cadence"
296
333
  ];
@@ -234,6 +234,44 @@ class FirstRunServer {
234
234
  (0, setup_preferences_1.writeSetupHandoffChoice)(choice);
235
235
  return res.json({ ok: true });
236
236
  });
237
+ this.app.post('/api/first-run/install-agent', async (req, res) => {
238
+ try {
239
+ const { agentId } = req.body || {};
240
+ if (!agentId || typeof agentId !== 'string') {
241
+ return res.status(400).json({ error: 'agentId is required.' });
242
+ }
243
+ const result = await this.sessionService.installAgent(agentId);
244
+ return res.json(result);
245
+ }
246
+ catch (error) {
247
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not install agent.' });
248
+ }
249
+ });
250
+ this.app.post('/api/first-run/trigger-agent-login', async (req, res) => {
251
+ try {
252
+ const { agentId } = req.body || {};
253
+ if (!agentId || typeof agentId !== 'string') {
254
+ return res.status(400).json({ error: 'agentId is required.' });
255
+ }
256
+ const result = await this.sessionService.triggerAgentLogin(agentId);
257
+ return res.json(result);
258
+ }
259
+ catch (error) {
260
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not trigger login.' });
261
+ }
262
+ });
263
+ this.app.post('/api/first-run/check-agent', (req, res) => {
264
+ try {
265
+ const { agentId } = req.body || {};
266
+ if (!agentId || typeof agentId !== 'string') {
267
+ return res.status(400).json({ error: 'agentId is required.' });
268
+ }
269
+ return res.json(this.sessionService.checkAgentReady(agentId));
270
+ }
271
+ catch (error) {
272
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not check agent.' });
273
+ }
274
+ });
237
275
  // Hub-launch helper - starts an AiHubServer for the chosen project and
238
276
  // opens the user's browser. v2 (#355) replaces the in-process spawn with
239
277
  // a durable launcher binary that survives independently.
@@ -257,8 +257,11 @@ class FirstRunSessionService {
257
257
  if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|getaddrinfo|network/i.test(detail)) {
258
258
  return "Couldn't reach the FRAIM registry. Check your internet connection (or if you're testing locally, that the local FRAIM server is running) and click Retry.";
259
259
  }
260
+ if (/EPERM|EACCES|permission denied/i.test(detail)) {
261
+ return 'A security tool (Windows Defender, antivirus, or IT policy) may be locking files in the install folder. Try disabling real-time scanning for the ~/.fraim directory, then click Retry.';
262
+ }
260
263
  if (/Global FRAIM setup not found/i.test(detail)) {
261
- return 'FRAIM\'s global config is missing. This usually means the agent step didn\'t complete cleanly — go back and re-run the AI agent row.';
264
+ return 'FRAIM\'s global config is missing. This usually means the agent step didn\'t complete cleanly go back and re-run the AI agent row.';
262
265
  }
263
266
  return undefined;
264
267
  }
@@ -286,7 +289,7 @@ class FirstRunSessionService {
286
289
  }
287
290
  else {
288
291
  gitRow.status = 'pending';
289
- gitRow.verb = "we'll install";
292
+ gitRow.verb = 'optional — only needed for code delivery workflows';
290
293
  }
291
294
  const fraimRow = this.getRow('fraim');
292
295
  if (commandVersion('npx') !== null) {
@@ -496,22 +499,19 @@ class FirstRunSessionService {
496
499
  }
497
500
  async runGitRow() {
498
501
  const row = this.getRow('git');
499
- const ver = commandVersion('git');
502
+ const ver = commandVersion('git') || (this.fakeMode ? 'git version 2.45' : null);
500
503
  if (ver) {
501
504
  row.status = 'ok';
502
505
  row.verb = `${ver} installed`;
503
506
  this.persist();
504
507
  return this.respond(`git detected (${ver}).`, true);
505
508
  }
506
- if (this.fakeMode) {
507
- row.status = 'ok';
508
- row.verb = 'git version 2.45 installed';
509
- this.persist();
510
- return this.respond('Fake-mode git ok.', true);
511
- }
512
- this.setRowError(row, 'Verifying git installation', 'git --version returned a non-zero exit code or no output.\n\nOn macOS, run `xcode-select --install` to install the Command Line Developer Tools, then retry. On Windows, install Git for Windows from https://git-scm.com/download/win.', [{ id: 'retry', label: 'Retry', variant: 'primary' }]);
509
+ // git is optional — non-technical users and non-code workflows don't need it.
510
+ // Mark ok so the wizard is not blocked; the verb makes the situation clear.
511
+ row.status = 'ok';
512
+ row.verb = 'not installed — optional, only needed for code delivery';
513
513
  this.persist();
514
- return this.respond('git not detected.', false);
514
+ return this.respond('git not found — continuing without it. Install git later if you plan to do code delivery work.', true);
515
515
  }
516
516
  async runFraimRow() {
517
517
  const row = this.getRow('fraim');
@@ -544,10 +544,10 @@ class FirstRunSessionService {
544
544
  await installSlashCommands();
545
545
  await installGlobalRules();
546
546
  row.status = 'ok';
547
- row.verb = 'Ready.';
547
+ row.verb = 'Ready — open a new terminal before running fraim commands.';
548
548
  delete row.streamOutput;
549
549
  this.persist();
550
- return this.respond('FRAIM is ready.', true);
550
+ return this.respond('FRAIM is ready. Open a new terminal window so your PATH update takes effect before running fraim commands.', true);
551
551
  }
552
552
  catch (error) {
553
553
  const detail = error instanceof Error ? error.message : 'Unknown error';
@@ -574,6 +574,15 @@ class FirstRunSessionService {
574
574
  this.persist();
575
575
  return this.respond('AI Employee recruiting deferred.', true);
576
576
  }
577
+ if (this.fakeMode === 'agent-install-fails') {
578
+ this.setRowError(row, 'Checking for installed AI Employees and configuring one for this project', this.fakeStderr, [
579
+ { id: 'retry', label: 'Retry', variant: 'primary' },
580
+ { id: 'alternative', label: 'Try alternative', variant: 'secondary' },
581
+ { id: 'skip', label: 'Skip and continue', variant: 'ghost' },
582
+ ]);
583
+ this.persist();
584
+ return this.respond('AI Employee recruiting failed.', false);
585
+ }
577
586
  this.updateAgentSummaryRow();
578
587
  this.persist();
579
588
  const count = this.state.setupResult?.detectedSurfaceCount || 0;
@@ -623,11 +632,130 @@ class FirstRunSessionService {
623
632
  * leave the user typing a command. The single-file launcher binary in v2
624
633
  * (#355) replaces this in-process spawn with a durable tray-icon launcher.
625
634
  */
635
+ async installAgent(agentId) {
636
+ const option = findAgentOption(agentId);
637
+ if (!option) {
638
+ return { ok: false, message: `Unknown agent: ${agentId}` };
639
+ }
640
+ if (this.fakeMode) {
641
+ return {
642
+ ok: true,
643
+ message: `${option.label} installed successfully.`,
644
+ needsLogin: true,
645
+ loginCommand: option.loginCommand,
646
+ loginHint: `Sign in to ${option.label} to activate it.`,
647
+ };
648
+ }
649
+ try {
650
+ const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
651
+ fs_1.default.mkdirSync(prefix, { recursive: true });
652
+ await runProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
653
+ const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
654
+ if (detectedIDEs.length > 0) {
655
+ await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, {}, detectedIDEs.map((ide) => ide.name), {});
656
+ }
657
+ appendInstallLog(`agent-installed ${agentId}`);
658
+ return {
659
+ ok: true,
660
+ message: `${option.label} installed successfully.`,
661
+ needsLogin: true,
662
+ loginCommand: option.loginCommand,
663
+ loginHint: `Sign in to ${option.label} to activate it. A terminal window will open with the sign-in command — complete sign-in there, then return here and click "Check if Ready".`,
664
+ };
665
+ }
666
+ catch (error) {
667
+ const detail = error instanceof Error ? error.message : 'Unknown error';
668
+ appendInstallLog(`agent-install-failed ${agentId} ${detail}`);
669
+ return { ok: false, message: `Failed to install ${option.label}: ${detail}` };
670
+ }
671
+ }
672
+ async triggerAgentLogin(agentId) {
673
+ const option = findAgentOption(agentId);
674
+ if (!option) {
675
+ return { ok: false, message: `Unknown agent: ${agentId}` };
676
+ }
677
+ if (this.fakeMode) {
678
+ return { ok: true, message: `${option.label} login triggered (fake-mode).` };
679
+ }
680
+ try {
681
+ this.openTerminalWithCommand(option.loginCommand);
682
+ appendInstallLog(`agent-login-triggered ${agentId}`);
683
+ return {
684
+ ok: true,
685
+ message: `A terminal window opened with the ${option.label} sign-in command. Complete sign-in there, then return here.`,
686
+ };
687
+ }
688
+ catch (error) {
689
+ const detail = error instanceof Error ? error.message : 'Unknown error';
690
+ return {
691
+ ok: false,
692
+ message: `Could not open a terminal automatically: ${detail}. Run \`${option.loginCommand}\` in a terminal to sign in.`,
693
+ };
694
+ }
695
+ }
696
+ checkAgentReady(agentId) {
697
+ const option = findAgentOption(agentId);
698
+ if (!option) {
699
+ return { ok: false, ready: false, message: `Unknown agent: ${agentId}` };
700
+ }
701
+ if (this.fakeMode) {
702
+ return { ok: true, ready: true, message: `${option.label} is ready (fake-mode).` };
703
+ }
704
+ const ver = commandVersion(option.launchCommand);
705
+ if (ver) {
706
+ this.updateAgentSummaryRow();
707
+ this.persist();
708
+ return { ok: true, ready: true, message: `${option.label} is ready.` };
709
+ }
710
+ return {
711
+ ok: true,
712
+ ready: false,
713
+ message: `${option.label} is not detected yet. Make sure sign-in is complete and try again.`,
714
+ };
715
+ }
716
+ openTerminalWithCommand(command) {
717
+ if (process.platform === 'win32') {
718
+ (0, child_process_1.spawn)('cmd.exe', ['/c', 'start', 'cmd.exe', '/k', command], { detached: true, stdio: 'ignore' }).unref();
719
+ return;
720
+ }
721
+ if (process.platform === 'darwin') {
722
+ const script = `tell application "Terminal" to do script "${command.replace(/"/g, '\\"')}"`;
723
+ (0, child_process_1.spawn)('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
724
+ return;
725
+ }
726
+ const linux = [
727
+ ['gnome-terminal', ['--', 'bash', '-c', `${command}; exec bash`]],
728
+ ['xterm', ['-e', `bash -c '${command}; exec bash'`]],
729
+ ['konsole', ['--noclose', '-e', 'bash', '-c', command]],
730
+ ['x-terminal-emulator', ['-e', `bash -c '${command}; exec bash'`]],
731
+ ];
732
+ for (const [term, args] of linux) {
733
+ if ((0, child_process_1.spawnSync)('which', [term], { encoding: 'utf8' }).status === 0) {
734
+ (0, child_process_1.spawn)(term, args, { detached: true, stdio: 'ignore' }).unref();
735
+ return;
736
+ }
737
+ }
738
+ }
626
739
  async openHub() {
627
740
  if (this.fakeMode) {
628
- // Tests don't actually want a Hub server running — just confirm intent.
741
+ // Tests don't actually want a Hub server running just confirm intent.
629
742
  return { ok: true, message: 'Fake-mode Hub open requested.', hubUrl: 'http://127.0.0.1:0/ai-hub/?firstRun=true' };
630
743
  }
744
+ // Require at least one Hub-compatible CLI to be installed (binary on PATH)
745
+ // OR fully IDE-configured. Accept freshly-installed binaries even before
746
+ // their IDE config files exist (created on first run / login).
747
+ const hubCompatibleBinaries = ['claude', 'codex'];
748
+ const hubCompatibleIds = new Set(['claude-code', 'codex']);
749
+ const surfaces = buildConfiguredSurfaces();
750
+ const hasConfiguredCli = surfaces.some((s) => hubCompatibleIds.has(s.id));
751
+ const hasInstalledCli = hubCompatibleBinaries.some((cmd) => commandVersion(cmd) !== null);
752
+ if (!hasConfiguredCli && !hasInstalledCli) {
753
+ return {
754
+ ok: false,
755
+ needsAgentSetup: true,
756
+ message: "No AI agent (Claude Code or Codex) found on this machine. Let's install one first.",
757
+ };
758
+ }
631
759
  try {
632
760
  const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('../ai-hub/server')));
633
761
  const port = await findAvailablePort(43091);
@@ -58,7 +58,7 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
58
58
  function createInitialRows() {
59
59
  return [
60
60
  { id: 'node', label: 'Node.js', status: 'pending', verb: "we'll install" },
61
- { id: 'git', label: 'git', status: 'pending', verb: "we'll install" },
61
+ { id: 'git', label: 'git', status: 'pending', verb: "we'll install", optional: true },
62
62
  { id: 'fraim', label: 'FRAIM', status: 'pending', verb: "we'll set up FRAIM" },
63
63
  { id: 'agent', label: 'AI Employees', status: 'pending', verb: "we'll check for AI Employees" },
64
64
  ];
@@ -73,14 +73,16 @@ function createInitialRows() {
73
73
  * - "Set up FRAIM": no rows ok yet.
74
74
  */
75
75
  function derivePrimaryButtonLabel(rows) {
76
- const allOk = rows.every((row) => row.status === 'ok');
76
+ // Optional rows (e.g. git) never block wizard progression.
77
+ const required = rows.filter((row) => !row.optional);
78
+ const allOk = required.every((row) => row.status === 'ok');
77
79
  if (allOk)
78
80
  return 'Get Started';
79
- // Skip-path: every row is ok-or-manual-required — nothing left for the wizard.
80
- if (rows.every((row) => row.status === 'ok' || row.status === 'manual-required')) {
81
+ // Skip-path: every required row is ok-or-manual-required — nothing left for the wizard.
82
+ if (required.every((row) => row.status === 'ok' || row.status === 'manual-required')) {
81
83
  return 'Get Started';
82
84
  }
83
- if (rows.some((row) => row.status === 'ok'))
85
+ if (required.some((row) => row.status === 'ok'))
84
86
  return 'Continue';
85
87
  return 'Set up FRAIM';
86
88
  }
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  /**
4
4
  * FRAIM Framework - Smart Entry Point
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.134",
3
+ "version": "2.0.135",
4
4
  "description": "FRAIM CLI - Framework for Rigor-based AI Management (alias for fraim-framework)",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -956,6 +956,13 @@ function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
956
956
  const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
957
957
  const trimmed = (instructions || '').trim();
958
958
  if (!trimmed) return `${invocation}${stub}`;
959
+ // For continue turns, applyTemplateInvocation already writes the full
960
+ // FRAIM invocation (e.g. "/fraim follow-your-mentor") into the textarea.
961
+ // If the text already starts with a known FRAIM symbol, don't prepend again.
962
+ if (kind === 'continue') {
963
+ const knownSymbols = Object.values(FRAIM_INVOCATION_SYMBOL);
964
+ if (knownSymbols.some((s) => trimmed.startsWith(s))) return trimmed;
965
+ }
959
966
  return `${invocation}${stub}\n\n${trimmed}`;
960
967
  }
961
968
 
@@ -243,46 +243,72 @@
243
243
  PRIMARY_BUTTON.style.display = 'none';
244
244
  setHeader('Recruit AI Employees', 'Choose how you want to add AI Employees to this machine.');
245
245
 
246
- const options = [
247
- { title: 'Claude Code', desc: 'Install Claude Code and connect it to FRAIM.' },
248
- { title: 'Codex', desc: 'Install Codex and connect it to FRAIM.' },
249
- { title: 'Gemini CLI', desc: 'Install Gemini CLI and connect it to FRAIM.' },
250
- { title: 'Bring Your Own Agent', desc: 'Install Cursor, Windsurf, Kiro, VS Code, or another supported AI tool.' },
246
+ const agentOptions = (state.session && state.session.agentOptions) ? state.session.agentOptions : [
247
+ { id: 'claude-code', label: 'Claude Code' },
248
+ { id: 'codex', label: 'Codex' },
249
+ { id: 'gemini-cli', label: 'Gemini CLI' },
251
250
  ];
252
251
 
253
- for (const option of options) {
252
+ const AGENT_DESCS = {
253
+ 'claude-code': 'Install Claude Code and connect it to FRAIM.',
254
+ 'codex': 'Install Codex and connect it to FRAIM.',
255
+ 'gemini-cli': 'Install Gemini CLI and connect it to FRAIM.',
256
+ };
257
+
258
+ for (const opt of agentOptions) {
254
259
  const li = document.createElement('li');
255
260
  const card = document.createElement('div');
256
261
  card.className = 'user-type-card recruit-card';
262
+ card.setAttribute('data-agent-id', opt.id);
263
+
257
264
  const title = document.createElement('strong');
258
265
  title.className = 'card-title';
259
- title.textContent = option.title;
266
+ title.textContent = opt.label;
260
267
  const desc = document.createElement('p');
261
268
  desc.className = 'card-desc';
262
- desc.textContent = option.desc;
269
+ desc.textContent = AGENT_DESCS[opt.id] || ('Install ' + opt.label + ' and connect it to FRAIM.');
263
270
  card.appendChild(title);
264
271
  card.appendChild(desc);
265
272
 
266
- if (option.title === 'Bring Your Own Agent') {
267
- const note = document.createElement('p');
268
- note.className = 'card-desc';
269
- note.textContent = 'When you are done installing your agent, run:';
270
- card.appendChild(note);
271
- const cmd = document.createElement('div');
272
- cmd.className = 'cmd-block';
273
- cmd.textContent = 'npx fraim add-ide';
274
- card.appendChild(cmd);
275
- }
273
+ const installBtn = document.createElement('button');
274
+ installBtn.type = 'button';
275
+ installBtn.className = 'btn btn-secondary btn-block';
276
+ installBtn.textContent = 'Install';
277
+ installBtn.setAttribute('data-testid', 'install-' + opt.id);
278
+ installBtn.addEventListener('click', () => renderAgentInstallFlow(opt));
279
+ card.appendChild(installBtn);
276
280
 
277
281
  li.appendChild(card);
278
282
  CHECKLIST_EL.appendChild(li);
279
283
  }
280
284
 
285
+ const byoaLi = document.createElement('li');
286
+ const byoaCard = document.createElement('div');
287
+ byoaCard.className = 'user-type-card recruit-card';
288
+ const byoaTitle = document.createElement('strong');
289
+ byoaTitle.className = 'card-title';
290
+ byoaTitle.textContent = 'Bring Your Own Agent';
291
+ const byoaDesc = document.createElement('p');
292
+ byoaDesc.className = 'card-desc';
293
+ byoaDesc.textContent = 'Install Cursor, Windsurf, Kiro, VS Code, or another supported AI tool.';
294
+ const byoaNote = document.createElement('p');
295
+ byoaNote.className = 'card-desc';
296
+ byoaNote.textContent = 'When you are done installing your agent, run:';
297
+ const byoaCmd = document.createElement('div');
298
+ byoaCmd.className = 'cmd-block';
299
+ byoaCmd.textContent = 'npx fraim add-ide';
300
+ byoaCard.appendChild(byoaTitle);
301
+ byoaCard.appendChild(byoaDesc);
302
+ byoaCard.appendChild(byoaNote);
303
+ byoaCard.appendChild(byoaCmd);
304
+ byoaLi.appendChild(byoaCard);
305
+ CHECKLIST_EL.appendChild(byoaLi);
306
+
281
307
  const continueLi = document.createElement('li');
282
308
  const continueBtn = document.createElement('button');
283
309
  continueBtn.type = 'button';
284
310
  continueBtn.className = 'btn btn-primary btn-block';
285
- continueBtn.textContent = 'Continue';
311
+ continueBtn.textContent = 'Continue without AI Agent';
286
312
  continueBtn.addEventListener('click', async () => {
287
313
  try {
288
314
  const ideData = await api('/api/first-run/ide-commands');
@@ -295,6 +321,161 @@
295
321
  CHECKLIST_EL.appendChild(continueLi);
296
322
  }
297
323
 
324
+ function renderAgentInstallFlow(opt) {
325
+ CHECKLIST_EL.className = 'selection-container recruit-container';
326
+ CHECKLIST_EL.innerHTML = '';
327
+ setHeader('Install ' + opt.label, 'Setting up ' + opt.label + ' on this machine...');
328
+
329
+ const statusLi = document.createElement('li');
330
+ const statusDiv = document.createElement('div');
331
+ statusDiv.className = 'install-status';
332
+ statusDiv.setAttribute('data-testid', 'agent-install-status');
333
+ statusDiv.textContent = 'Installing ' + opt.label + '...';
334
+ statusLi.appendChild(statusDiv);
335
+ CHECKLIST_EL.appendChild(statusLi);
336
+
337
+ (async () => {
338
+ try {
339
+ const result = await api('/api/first-run/install-agent', 'POST', { agentId: opt.id });
340
+ if (!result || !result.ok) {
341
+ statusDiv.textContent = (result && result.message) ? result.message : 'Install failed.';
342
+ statusDiv.setAttribute('data-tone', 'error');
343
+ const actions = document.createElement('div');
344
+ actions.className = 'install-actions';
345
+ const retryBtn = document.createElement('button');
346
+ retryBtn.type = 'button';
347
+ retryBtn.className = 'btn btn-primary';
348
+ retryBtn.textContent = 'Retry';
349
+ retryBtn.addEventListener('click', () => renderAgentInstallFlow(opt));
350
+ const backBtn = document.createElement('button');
351
+ backBtn.type = 'button';
352
+ backBtn.className = 'btn btn-ghost';
353
+ backBtn.textContent = 'Choose a different agent';
354
+ backBtn.addEventListener('click', () => renderRecruitAgents());
355
+ actions.appendChild(retryBtn);
356
+ actions.appendChild(backBtn);
357
+ statusLi.appendChild(actions);
358
+ return;
359
+ }
360
+ statusDiv.textContent = opt.label + ' installed successfully!';
361
+ renderAgentLoginStep(opt, result.loginCommand, result.loginHint, statusLi);
362
+ } catch (err) {
363
+ statusDiv.textContent = err.message || 'Install failed.';
364
+ statusDiv.setAttribute('data-tone', 'error');
365
+ const backBtn = document.createElement('button');
366
+ backBtn.type = 'button';
367
+ backBtn.className = 'btn btn-ghost btn-block';
368
+ backBtn.textContent = 'Choose a different agent';
369
+ backBtn.addEventListener('click', () => renderRecruitAgents());
370
+ statusLi.appendChild(backBtn);
371
+ }
372
+ })();
373
+ }
374
+
375
+ function renderAgentLoginStep(opt, loginCommand, loginHint, parentLi) {
376
+ const hintEl = document.createElement('p');
377
+ hintEl.className = 'install-hint';
378
+ hintEl.textContent = loginHint || ('Sign in to ' + opt.label + ' to activate it.');
379
+ parentLi.appendChild(hintEl);
380
+
381
+ const signInBtn = document.createElement('button');
382
+ signInBtn.type = 'button';
383
+ signInBtn.className = 'btn btn-primary btn-block';
384
+ signInBtn.textContent = 'Sign In to ' + opt.label;
385
+ signInBtn.addEventListener('click', async () => {
386
+ signInBtn.disabled = true;
387
+ signInBtn.textContent = 'Opening terminal...';
388
+ try {
389
+ const result = await api('/api/first-run/trigger-agent-login', 'POST', { agentId: opt.id });
390
+ signInBtn.style.display = 'none';
391
+ renderAgentReadyCheck(opt, result && result.message, parentLi);
392
+ } catch (err) {
393
+ signInBtn.disabled = false;
394
+ signInBtn.textContent = 'Sign In to ' + opt.label;
395
+ setStatus(err.message, 'error');
396
+ }
397
+ });
398
+ parentLi.appendChild(signInBtn);
399
+ }
400
+
401
+ function renderAgentReadyCheck(opt, loginMessage, parentLi) {
402
+ const msgEl = document.createElement('p');
403
+ msgEl.className = 'install-hint';
404
+ msgEl.textContent = loginMessage || ('Complete sign-in in the terminal, then click Check if Ready.');
405
+ parentLi.appendChild(msgEl);
406
+
407
+ const checkBtn = document.createElement('button');
408
+ checkBtn.type = 'button';
409
+ checkBtn.className = 'btn btn-primary btn-block';
410
+ checkBtn.textContent = 'Check if Ready';
411
+
412
+ // Declare skipEl before the click handler so it can be hidden on success.
413
+ const skipEl = document.createElement('p');
414
+ const skipLink = document.createElement('button');
415
+ skipLink.type = 'button';
416
+ skipLink.className = 'text-button';
417
+ skipLink.textContent = 'Skip for now — I will sign in later';
418
+ skipLink.addEventListener('click', async () => {
419
+ try {
420
+ const ideData = await api('/api/first-run/ide-commands');
421
+ renderStartWorking(ideData ? ideData.commands : []);
422
+ } catch (_err) {
423
+ renderStartWorking([]);
424
+ }
425
+ });
426
+ skipEl.appendChild(skipLink);
427
+
428
+ checkBtn.addEventListener('click', async () => {
429
+ checkBtn.disabled = true;
430
+ checkBtn.textContent = 'Checking...';
431
+ try {
432
+ const result = await api('/api/first-run/check-agent', 'POST', { agentId: opt.id });
433
+ if (result && result.ready) {
434
+ checkBtn.style.display = 'none';
435
+ skipEl.style.display = 'none';
436
+ msgEl.textContent = opt.label + ' is ready!';
437
+ setHeader(opt.label + ' is ready!', 'You can now open the Hub.');
438
+ renderOpenHubButton(parentLi);
439
+ } else {
440
+ checkBtn.disabled = false;
441
+ checkBtn.textContent = 'Check if Ready';
442
+ setStatus((result && result.message) || (opt.label + ' not detected yet. Complete sign-in and try again.'), 'error');
443
+ }
444
+ } catch (err) {
445
+ checkBtn.disabled = false;
446
+ checkBtn.textContent = 'Check if Ready';
447
+ setStatus(err.message, 'error');
448
+ }
449
+ });
450
+
451
+ parentLi.appendChild(checkBtn);
452
+ parentLi.appendChild(skipEl);
453
+ }
454
+
455
+ function renderOpenHubButton(parentLi) {
456
+ const openHubBtn = document.createElement('button');
457
+ openHubBtn.type = 'button';
458
+ openHubBtn.className = 'btn btn-primary btn-block';
459
+ openHubBtn.textContent = 'Open Hub';
460
+ openHubBtn.addEventListener('click', async () => {
461
+ openHubBtn.disabled = true;
462
+ setStatus('Opening Hub...');
463
+ try {
464
+ await api('/api/first-run/set-preference', 'POST', { choice: 'hub' });
465
+ } catch (_err) { /* non-fatal */ }
466
+ try {
467
+ const openResp = await api('/api/first-run/open-hub', 'POST');
468
+ if (openResp && openResp.message) setStatus(openResp.message);
469
+ if (openResp && openResp.hubUrl) window.location.replace(openResp.hubUrl);
470
+ else openHubBtn.disabled = false;
471
+ } catch (err) {
472
+ openHubBtn.disabled = false;
473
+ setStatus(err.message, 'error');
474
+ }
475
+ });
476
+ parentLi.appendChild(openHubBtn);
477
+ }
478
+
298
479
  function renderStartWorking(ideCommands) {
299
480
  CHECKLIST_EL.className = 'selection-container start-container';
300
481
  CHECKLIST_EL.innerHTML = '';
@@ -342,8 +523,13 @@
342
523
  hubBtn.disabled = true;
343
524
  setStatus('Opening Hub...');
344
525
  const openResp = await api('/api/first-run/open-hub', 'POST');
526
+ if (openResp && openResp.needsAgentSetup) {
527
+ renderRecruitAgents();
528
+ return;
529
+ }
345
530
  if (openResp && openResp.message) setStatus(openResp.message);
346
531
  if (openResp && openResp.hubUrl) window.location.replace(openResp.hubUrl);
532
+ else hubBtn.disabled = false;
347
533
  } catch (err) {
348
534
  hubBtn.disabled = false;
349
535
  setStatus(err.message, 'error');
@@ -431,6 +617,10 @@
431
617
  try {
432
618
  await api('/api/first-run/set-preference', 'POST', { choice: 'hub' });
433
619
  const openResp = await api('/api/first-run/open-hub', 'POST');
620
+ if (openResp && openResp.needsAgentSetup) {
621
+ renderRecruitAgents();
622
+ return;
623
+ }
434
624
  if (openResp && openResp.hubUrl) window.location.replace(openResp.hubUrl);
435
625
  } catch (err) {
436
626
  setStatus(err.message, 'error');