fraim 2.0.131 → 2.0.133

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,13 +27,11 @@ 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'] },
31
30
  { base: 'fraim', segments: ['ai-employee', 'jobs'] },
32
31
  { base: 'fraim', segments: ['personalized-employee', 'jobs'] },
33
32
  ];
34
- // Manager templates use the matching three-layer model.
33
+ // Manager templates use the matching two-layer model.
35
34
  const MANAGER_JOB_LAYERS = [
36
- { base: 'registry', segments: ['jobs', 'ai-manager'] },
37
35
  { base: 'fraim', segments: ['ai-manager', 'jobs'] },
38
36
  { base: 'fraim', segments: ['personalized-employee', 'manager-jobs'] },
39
37
  ];
@@ -139,16 +137,14 @@ function summarizeProject(projectPath) {
139
137
  };
140
138
  }
141
139
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
142
- const registryEmployeeDir = path_1.default.join(projectPath, 'registry', 'jobs', 'ai-employee');
143
- const registryManagerDir = path_1.default.join(projectPath, 'registry', 'jobs', 'ai-manager');
144
140
  const hasFraim = fs_1.default.existsSync(fraimDir);
145
- const hasRegistry = fs_1.default.existsSync(registryEmployeeDir) || fs_1.default.existsSync(registryManagerDir);
146
- if (!hasFraim && !hasRegistry) {
141
+ if (!hasFraim) {
147
142
  return {
148
143
  path: projectPath,
149
144
  exists: true,
150
145
  hasFraim: false,
151
- message: 'This folder does not contain a local fraim/ or registry/jobs/ directory.',
146
+ needsOnboarding: true,
147
+ message: 'This folder has not been set up with FRAIM yet. Onboarding will get your AI employee started.',
152
148
  };
153
149
  }
154
150
  return {
@@ -158,13 +154,6 @@ function summarizeProject(projectPath) {
158
154
  };
159
155
  }
160
156
  function resolveLayerRoot(projectPath, layer) {
161
- if (layer.base === 'registry') {
162
- // The registry is the FRAIM source-of-truth catalog. It lives at the
163
- // project root in the FRAIM source repo (this repo) and in any project
164
- // that ships dist/registry alongside its source. Not under fraim/.
165
- return path_1.default.join(projectPath, 'registry', ...layer.segments);
166
- }
167
- // 'fraim' layers live inside <projectRoot>/fraim/.
168
157
  return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
169
158
  }
170
159
  function discoverLayers(projectPath, layers) {
@@ -300,13 +289,29 @@ function nextPhase(edge, discriminant) {
300
289
  }
301
290
  return null;
302
291
  }
292
+ // Parse the ordered phase list from a job stub's ## Steps section.
293
+ // Real FRAIM job stubs use Markdown steps rather than JSON frontmatter;
294
+ // this is the fallback parser that makes the pizza tracker work for them.
295
+ function loadJobPhasesFromSteps(filePath) {
296
+ const raw = fs_1.default.readFileSync(filePath, 'utf8');
297
+ const stepsMatch = raw.match(/## Steps\r?\n([\s\S]*?)(?:\r?\n## |\r?\n---|$)/);
298
+ if (!stepsMatch)
299
+ return [];
300
+ const phases = [];
301
+ for (const line of stepsMatch[1].split(/\r?\n/)) {
302
+ const m = line.match(/`([a-z][a-z0-9-]*)`/);
303
+ if (m)
304
+ phases.push({ id: m[1], label: friendlyPhaseLabel(m[1]) });
305
+ }
306
+ return phases;
307
+ }
303
308
  function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
304
309
  const stubPath = findJobStubPath(projectPath, jobId);
305
310
  if (!stubPath)
306
311
  return [];
307
312
  const fm = readJobFrontmatter(stubPath);
308
313
  if (!fm || !fm.initialPhase || !fm.phases)
309
- return [];
314
+ return loadJobPhasesFromSteps(stubPath);
310
315
  const visited = new Set();
311
316
  const ordered = [];
312
317
  let cursor = fm.initialPhase;
@@ -334,8 +339,10 @@ function loadAllJobPhaseIds(jobId, projectPath) {
334
339
  if (!stubPath)
335
340
  return new Set();
336
341
  const fm = readJobFrontmatter(stubPath);
337
- if (!fm || !fm.phases)
338
- return new Set();
342
+ if (!fm || !fm.phases) {
343
+ const phases = loadJobPhasesFromSteps(stubPath);
344
+ return new Set(phases.map((p) => p.id));
345
+ }
339
346
  return new Set(Object.keys(fm.phases));
340
347
  }
341
348
  // Issue #347 — public exposure of the friendly-label rule so callers
@@ -386,6 +386,14 @@ class AiHubServer {
386
386
  recentJobIds: existingPreferences.recentJobIds,
387
387
  }, jobId);
388
388
  res.status(201).json(this.enrichRunForResponse(run));
389
+ // Background sync: refresh the local FRAIM catalog so the next job
390
+ // picker load sees any jobs that were added or updated since last run.
391
+ try {
392
+ const syncChild = (0, child_process_1.spawn)('npx', ['fraim', 'sync'], { cwd: projectPath, detached: true, stdio: 'ignore' });
393
+ syncChild.on('error', () => { });
394
+ syncChild.unref();
395
+ }
396
+ catch { /* ignore if spawn itself throws synchronously */ }
389
397
  }
390
398
  catch (error) {
391
399
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
@@ -660,6 +660,7 @@ const runSetup = async (options) => {
660
660
  console.log(chalk_1.default.gray(' legal, product, hiring, customer development, and more.'));
661
661
  // Ask how the user wants to work with their AI employees.
662
662
  // Skip prompt when the user already chose on a previous run (R2.2/R3.2/R4.6).
663
+ // Also skip in non-interactive environments (CI, tests, automated installs).
663
664
  const { writeSetupHandoffChoice, readSetupHandoffChoice } = await Promise.resolve().then(() => __importStar(require('../../core/utils/setup-preferences')));
664
665
  const storedChoice = readSetupHandoffChoice();
665
666
  let choice;
@@ -667,6 +668,11 @@ const runSetup = async (options) => {
667
668
  console.log(chalk_1.default.gray(`\n Using your saved preference: ${storedChoice === 'ide' ? 'In my IDE' : 'In FRAIM Hub'}`));
668
669
  choice = storedChoice;
669
670
  }
671
+ else if (process.env.FRAIM_NON_INTERACTIVE || process.env.CI) {
672
+ // In non-interactive environments, default to IDE mode (no browser launch)
673
+ console.log(chalk_1.default.gray('\n Non-interactive mode: defaulting to IDE setup'));
674
+ choice = 'ide';
675
+ }
670
676
  else {
671
677
  const userTypeResponse = await (0, prompts_1.default)({
672
678
  type: 'select',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.131",
3
+ "version": "2.0.133",
4
4
  "description": "FRAIM CLI - Framework for Rigor-based AI Management (alias for fraim-framework)",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -951,18 +951,19 @@ function fraimInvocationFor(employeeId, jobId, kind) {
951
951
  // invocation. The wrapped text is what we ACTUALLY send to the host CLI
952
952
  // AND what we show in the timeline so the manager sees what the agent
953
953
  // received.
954
- function buildAgentMessage(employeeId, jobId, kind, instructions) {
954
+ function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
955
955
  const invocation = fraimInvocationFor(employeeId, jobId, kind);
956
+ const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
956
957
  const trimmed = (instructions || '').trim();
957
- if (!trimmed) return invocation;
958
- return `${invocation}\n\n${trimmed}`;
958
+ if (!trimmed) return `${invocation}${stub}`;
959
+ return `${invocation}${stub}\n\n${trimmed}`;
959
960
  }
960
961
 
961
962
  async function startRun(job, instructions, employeeId) {
962
963
  // Prefix the manager's typed instructions with the FRAIM invocation so
963
964
  // the underlying host actually launches the right job. The prefixed
964
965
  // text is what the host receives AND what we show in the timeline.
965
- const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions);
966
+ const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions, job.stubPath);
966
967
  const conv = {
967
968
  id: newConversationId(),
968
969
  projectPath: state.projectPath,
@@ -1266,6 +1267,16 @@ function wireEvents() {
1266
1267
  return;
1267
1268
  }
1268
1269
 
1270
+ // Auto-onboard: if the project hasn't been set up with FRAIM and there's no
1271
+ // existing conversation for it, kick off the onboarding job automatically.
1272
+ if (state.bootstrap && state.bootstrap.project && state.bootstrap.project.needsOnboarding) {
1273
+ const existing = activeConversation();
1274
+ if (!existing || existing.projectPath !== state.projectPath) {
1275
+ await autoOnboardProject();
1276
+ return;
1277
+ }
1278
+ }
1279
+
1269
1280
  // If an active conversation belongs to the loaded project and is still running, resume polling.
1270
1281
  const conv = activeConversation();
1271
1282
  if (conv && conv.projectPath === state.projectPath) {
@@ -1277,6 +1288,53 @@ function wireEvents() {
1277
1288
  }
1278
1289
  })();
1279
1290
 
1291
+ async function autoOnboardProject() {
1292
+ const employeeId = state.selectedEmployeeId || 'claude';
1293
+ const onboardingMessage = "Your AI employee is onboarding to this project. They'll explore the codebase, learn how it's structured, and may ask a few questions so they can contribute meaningfully.";
1294
+ const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
1295
+ if (job) {
1296
+ await startRun(job, onboardingMessage, employeeId);
1297
+ return;
1298
+ }
1299
+ // Fallback: project-onboarding job not in catalog (fraim/ doesn't exist yet) — POST directly.
1300
+ try {
1301
+ const run = await requestJson('/api/ai-hub/runs', {
1302
+ method: 'POST',
1303
+ headers: { 'Content-Type': 'application/json' },
1304
+ body: JSON.stringify({
1305
+ projectPath: state.projectPath,
1306
+ hostId: employeeId,
1307
+ jobId: 'project-onboarding',
1308
+ message: onboardingMessage,
1309
+ }),
1310
+ });
1311
+ const conv = {
1312
+ id: newConversationId(),
1313
+ projectPath: state.projectPath,
1314
+ title: 'Project Onboarding',
1315
+ jobId: 'project-onboarding',
1316
+ jobTitle: 'Project Onboarding',
1317
+ employeeId,
1318
+ runId: run.id,
1319
+ sessionId: run.sessionId || null,
1320
+ status: 'running',
1321
+ messages: [{ role: 'manager', text: onboardingMessage, at: Date.now() }],
1322
+ events: [],
1323
+ artifacts: [],
1324
+ lastUpdatedAt: Date.now(),
1325
+ };
1326
+ foldRunIntoConversation(conv, run);
1327
+ upsertConversation(conv);
1328
+ state.activeId = conv.id;
1329
+ persistConversations();
1330
+ renderRail();
1331
+ renderActive();
1332
+ startPolling();
1333
+ } catch (err) {
1334
+ showStatus(err.message, true);
1335
+ }
1336
+ }
1337
+
1280
1338
  function renderFirstRunLanding() {
1281
1339
  // Show first-run onboarding screen over the normal Hub UI.
1282
1340
  // The user picks a project folder here; clicking Start fires the