fraim 2.0.132 → 2.0.134

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.' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.132",
3
+ "version": "2.0.134",
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,22 @@ 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 absoluteStubPath = (job.stubPath && state.projectPath)
967
+ ? [state.projectPath, job.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
968
+ : job.stubPath;
969
+ const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions, absoluteStubPath);
966
970
  const conv = {
967
971
  id: newConversationId(),
968
972
  projectPath: state.projectPath,
@@ -1266,6 +1270,16 @@ function wireEvents() {
1266
1270
  return;
1267
1271
  }
1268
1272
 
1273
+ // Auto-onboard: if the project hasn't been set up with FRAIM and there's no
1274
+ // existing conversation for it, kick off the onboarding job automatically.
1275
+ if (state.bootstrap && state.bootstrap.project && state.bootstrap.project.needsOnboarding) {
1276
+ const existing = activeConversation();
1277
+ if (!existing || existing.projectPath !== state.projectPath) {
1278
+ await autoOnboardProject();
1279
+ return;
1280
+ }
1281
+ }
1282
+
1269
1283
  // If an active conversation belongs to the loaded project and is still running, resume polling.
1270
1284
  const conv = activeConversation();
1271
1285
  if (conv && conv.projectPath === state.projectPath) {
@@ -1277,6 +1291,53 @@ function wireEvents() {
1277
1291
  }
1278
1292
  })();
1279
1293
 
1294
+ async function autoOnboardProject() {
1295
+ const employeeId = state.selectedEmployeeId || 'claude';
1296
+ 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.";
1297
+ const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
1298
+ if (job) {
1299
+ await startRun(job, onboardingMessage, employeeId);
1300
+ return;
1301
+ }
1302
+ // Fallback: project-onboarding job not in catalog (fraim/ doesn't exist yet) — POST directly.
1303
+ try {
1304
+ const run = await requestJson('/api/ai-hub/runs', {
1305
+ method: 'POST',
1306
+ headers: { 'Content-Type': 'application/json' },
1307
+ body: JSON.stringify({
1308
+ projectPath: state.projectPath,
1309
+ hostId: employeeId,
1310
+ jobId: 'project-onboarding',
1311
+ message: onboardingMessage,
1312
+ }),
1313
+ });
1314
+ const conv = {
1315
+ id: newConversationId(),
1316
+ projectPath: state.projectPath,
1317
+ title: 'Project Onboarding',
1318
+ jobId: 'project-onboarding',
1319
+ jobTitle: 'Project Onboarding',
1320
+ employeeId,
1321
+ runId: run.id,
1322
+ sessionId: run.sessionId || null,
1323
+ status: 'running',
1324
+ messages: [{ role: 'manager', text: onboardingMessage, at: Date.now() }],
1325
+ events: [],
1326
+ artifacts: [],
1327
+ lastUpdatedAt: Date.now(),
1328
+ };
1329
+ foldRunIntoConversation(conv, run);
1330
+ upsertConversation(conv);
1331
+ state.activeId = conv.id;
1332
+ persistConversations();
1333
+ renderRail();
1334
+ renderActive();
1335
+ startPolling();
1336
+ } catch (err) {
1337
+ showStatus(err.message, true);
1338
+ }
1339
+ }
1340
+
1280
1341
  function renderFirstRunLanding() {
1281
1342
  // Show first-run onboarding screen over the normal Hub UI.
1282
1343
  // The user picks a project folder here; clicking Start fires the