shakers-cli 0.16.0 → 0.17.0

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.
@@ -669,18 +669,15 @@ function trackImport(funnel, attempted, importRes, durationMs) {
669
669
  const bySource = new Map();
670
670
  const report = importRes.ok && importRes.report ? importRes.report.sources : [];
671
671
  for (const src of report) bySource.set(sourceTypeFor(src.source), src);
672
- const connected = [];
673
672
  for (const type of attempted) {
674
673
  const src = bySource.get(type);
675
674
  const ok = importRes.ok && (!src || src.status === 'imported');
676
675
  if (ok) {
677
- connected.push(type);
678
676
  funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
679
677
  } else {
680
678
  funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
681
679
  }
682
680
  }
683
- return connected;
684
681
  }
685
682
 
686
683
  // Blocking orchestrator used by the CLI (`bin/onboarding.js`).
@@ -689,9 +686,10 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
689
686
  const c = getCatalog(io.lang).onboarding;
690
687
 
691
688
  funnel.begin();
689
+ funnel.viewed('start');
692
690
  funnel.completed('start');
693
691
  io.section(c.signupTitle);
694
- funnel.viewed('account_creation');
692
+ funnel.viewed('account_creation', { auth_methods_offered: require('./auth-methods').methodIdsForFlow('register') });
695
693
  funnel.flush();
696
694
  const accountFailed = (reason) => {
697
695
  funnel.errored('account_creation', reason);
@@ -711,8 +709,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
711
709
  io.error(c.signupError);
712
710
  return accountFailed(reg.reason);
713
711
  }
714
- funnel.completed('account_creation', { auth_method: method, is_returning_account: reg.kind === 'account-already-exists' });
715
- funnel.identified();
712
+ funnel.completed('account_creation', { auth_method: method, auth_attempts: 1, is_returning_account: reg.kind === 'account-already-exists' });
713
+ funnel.identified({ newAccount: reg.kind !== 'account-already-exists' });
716
714
  if (reg.kind === 'account-already-exists') {
717
715
  // Existing account: already signed in — stop, no wizard re-run.
718
716
  io.warn(c.accountExists);
@@ -738,8 +736,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
738
736
  io.warn(c.accountExists);
739
737
  const logged = await io.login(account.email);
740
738
  if (!logged) return accountFailed('login-failed');
741
- funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
742
- funnel.identified();
739
+ funnel.completed('account_creation', { auth_method: 'email', auth_attempts: 1, is_returning_account: true });
740
+ funnel.identified({ newAccount: false });
743
741
  return { ok: true, alreadyRegistered: true };
744
742
  }
745
743
  const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
@@ -747,13 +745,13 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
747
745
  io.error(c.sessionError);
748
746
  return accountFailed(sess.reason);
749
747
  }
750
- funnel.completed('account_creation', { auth_method: 'email', is_returning_account: false });
748
+ funnel.completed('account_creation', { auth_method: 'email', auth_attempts: 1, is_returning_account: false });
751
749
  funnel.identified();
752
750
  notifyOk(io, c.accountReady);
753
751
  }
754
752
 
755
753
  io.section(c.title);
756
- funnel.viewed('import_sources');
754
+ funnel.viewed('import_sources', { sources_offered: ['linkedin', 'cv', 'github', 'web_page'] });
757
755
  const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
758
756
  if (!linkedinUrl) {
759
757
  funnel.errored('import_sources', 'no-linkedin', { error_field: 'linkedin' });
@@ -772,15 +770,15 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
772
770
  const importStartedAt = Date.now();
773
771
  const importRes = await withProgress(c.importChecking, () =>
774
772
  startProfileImport(deps, { linkedinUrl, cvPath, githubUrl, websiteUrl, language: io.lang }));
775
- const connected = trackImport(funnel, attempted, importRes, Date.now() - importStartedAt);
776
- funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: connected, sources_connected_count: connected.length });
773
+ trackImport(funnel, attempted, importRes, Date.now() - importStartedAt);
774
+ funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
777
775
  funnel.flush();
778
776
  if (importRes.ok) notifyOk(io, c.importDone);
779
777
  else io.warn(c.importUnavailable);
780
778
 
781
779
  // Steps 4-8 are saved out of funnel order: sent together once all are answered.
782
780
  io.section(c.professionalTitle);
783
- funnel.viewed('work_situation');
781
+ funnel.viewed('work_situation', { is_prefilled: false });
784
782
  const workSituationInput = await io.askWorkSituation(c);
785
783
  const workSituationRes = await saveWorkSituation(deps, workSituationInput);
786
784
  if (!workSituationRes.ok) {
@@ -788,11 +786,11 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
788
786
  funnel.flush();
789
787
  return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
790
788
  }
791
- funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()) });
789
+ funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()), was_edited: true });
792
790
  notifyOk(io, c.professionalSaved);
793
791
 
794
792
  io.section(c.pricingTitle);
795
- funnel.viewed('rates');
793
+ funnel.viewed('rates', { suggested_rate_shown: false });
796
794
  const pricingInput = await io.askPricing(c);
797
795
  const priceRes = await savePricing(deps, pricingInput);
798
796
  if (!priceRes.ok) {
@@ -805,23 +803,31 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
805
803
  rate_types_selected: selectedRates,
806
804
  rate_type: selectedRates[0],
807
805
  rate_currency: normalizeCurrency(pricingInput.partTimeSelected === true ? pricingInput.partTimeCurrency : pricingInput.fullTimeCurrency) || undefined,
806
+ was_edited: true,
808
807
  });
809
808
  notifyOk(io, c.pricingSaved);
810
809
 
811
810
  // Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
812
811
  io.section(c.availabilityTitle);
813
- funnel.viewed('availability');
812
+ funnel.viewed('availability', { is_prefilled: false });
814
813
  if (await io.confirm(c.availabilityAsk)) {
815
- const availabilityInput = await io.askAvailability(c);
814
+ let availabilityTime;
815
+ const availabilityInput = await io.askAvailability(c, {
816
+ onLocation: () => {
817
+ availabilityTime = funnel.stepTime('availability');
818
+ funnel.viewed('location', { is_prefilled: false });
819
+ },
820
+ });
816
821
  const availRes = await saveAvailability(deps, availabilityInput);
817
822
  if (availRes.ok) {
818
- funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)) });
823
+ funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)), time_on_step_ms: availabilityTime, was_edited: true });
819
824
  const country = normalizeCountry(availabilityInput.country);
820
825
  const modes = Array.isArray(availabilityInput.workModes) ? availabilityInput.workModes.map((m) => String(m).trim().toUpperCase()) : [];
821
826
  funnel.completed('location', {
822
827
  location_country: country ? country.toLowerCase() : undefined,
823
828
  location_method: 'manual',
824
829
  is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
830
+ was_edited: true,
825
831
  });
826
832
  notifyOk(io, c.availabilitySaved);
827
833
  } else {
@@ -839,13 +845,13 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
839
845
  }
840
846
 
841
847
  io.section(c.languagesTitle);
842
- funnel.viewed('languages');
848
+ funnel.viewed('languages', { is_prefilled: false });
843
849
  if (await io.confirm(c.languagesAsk)) {
844
850
  const languageRows = await io.askLanguages(c);
845
851
  const langRes = await saveLanguages(deps, languageRows);
846
852
  if (langRes.ok) {
847
853
  const codes = validateLanguageRows(languageRows).rows.map((r) => r.language);
848
- funnel.completed('languages', { languages_count: codes.length, languages: codes });
854
+ funnel.completed('languages', { languages_count: codes.length, languages: codes, was_edited: true });
849
855
  notifyOk(io, c.languagesSaved);
850
856
  } else {
851
857
  funnel.errored('languages', langRes.reason);
@@ -888,20 +894,35 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
888
894
  // Every ending points the Talent at their profile, and opens it when a browser is at hand.
889
895
  const showProfile = () => {
890
896
  const url = profileUrl(deps);
897
+ funnel.viewed('profile_summary');
891
898
  notifyOk(io, url ? c.profileReadyUrl(url) : c.profileReadyNoUrl);
892
899
  if (url && typeof openProfile === 'function') {
893
900
  try { openProfile(url); } catch { /* the printed link covers it */ }
894
901
  }
902
+ funnel.completed('profile_summary');
903
+ funnel.flush();
895
904
  return url;
896
905
  };
897
906
 
898
907
  const mainRole = async () => {
899
- const role = await runMainRoleFinalStep(io, deps);
900
- if (role && role.ok && role.chosen) funnel.completed('supercluster', { supercluster: role.chosen });
901
- else if (role && role.ok) funnel.skipped('supercluster');
908
+ let shown = null;
909
+ const role = await runMainRoleFinalStep(io, deps, {
910
+ onRolesShown: (info) => {
911
+ shown = info;
912
+ funnel.viewed('supercluster', { suggested_superclusters: info.suggested, suggestion_source: 'assigned_clusters', suggestions_count: info.suggested.length });
913
+ },
914
+ });
915
+ if (role && role.ok && role.chosen) {
916
+ funnel.completed('supercluster', {
917
+ supercluster: role.chosen,
918
+ clusters_count: shown ? shown.clustersCount : undefined,
919
+ was_suggestion_accepted: shown ? shown.suggested.includes(role.chosen) : undefined,
920
+ });
921
+ } else if (role && role.ok) funnel.skipped('supercluster');
902
922
  funnel.flush();
903
923
  };
904
924
 
925
+ funnel.holdUntil('profile_summary');
905
926
  io.section(c.interviewTitle);
906
927
  funnel.viewed('pre_interview');
907
928
  io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
@@ -1010,14 +1031,14 @@ async function conductLivekitInterview(io, deps, { interviewId = null, language
1010
1031
  }
1011
1032
 
1012
1033
  // Runs the final main-role step, degrading softly (never aborts the flow / never throws).
1013
- async function runMainRoleFinalStep(io, deps, session = null) {
1014
- const active = session || (typeof deps.loadAuthSession === 'function' ? deps.loadAuthSession() : null);
1034
+ async function runMainRoleFinalStep(io, deps, { onRolesShown } = {}) {
1035
+ const active = typeof deps.loadAuthSession === 'function' ? deps.loadAuthSession() : null;
1015
1036
  if (typeof deps.runMainRoleStep !== 'function' || !active) return;
1016
1037
  const { getCatalog } = require('./i18n');
1017
1038
  const catalog = getCatalog(io.lang);
1018
1039
  try {
1019
1040
  const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
1020
- return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
1041
+ return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps, onRolesShown });
1021
1042
  } catch {
1022
1043
  return null;
1023
1044
  }
@@ -196,8 +196,17 @@ function createFunnel({
196
196
  let active = false;
197
197
  let mcpClient = null;
198
198
  let cachedDeviceId;
199
+ let begunAt = null;
200
+ let lastCompletedAt = null;
201
+ let heldFrom = null;
199
202
  const seen = new Set();
200
203
  const inflight = new Set();
204
+ const viewedAt = new Map();
205
+
206
+ function stepTime(stepName) {
207
+ const since = viewedAt.has(stepName) ? viewedAt.get(stepName) : (lastCompletedAt ?? begunAt);
208
+ return since === null ? undefined : Math.max(0, now() - since);
209
+ }
201
210
 
202
211
  function track(eventType, stepName, properties = {}) {
203
212
  if (!active || !EVENT_TYPES.includes(eventType) || !STEP_INDEX[stepName]) return false;
@@ -206,9 +215,20 @@ function createFunnel({
206
215
  if (seen.has(key)) return false;
207
216
  seen.add(key);
208
217
  }
218
+ const at = now();
219
+ const own = cleanProperties(properties);
220
+ if (eventType === 'onboarding_step_viewed') viewedAt.set(stepName, at);
221
+ if (eventType === 'onboarding_step_completed') {
222
+ if (own.time_on_step_ms === undefined) {
223
+ const elapsed = stepTime(stepName);
224
+ if (elapsed !== undefined) own.time_on_step_ms = elapsed;
225
+ }
226
+ lastCompletedAt = at;
227
+ }
228
+ if (heldFrom === STEP_INDEX[stepName] && (eventType === 'onboarding_step_completed' || eventType === 'onboarding_step_skipped')) heldFrom = null;
209
229
  const common = { step_name: stepName, step_index: STEP_INDEX[stepName], flow_version: FLOW_VERSION, surface };
210
230
  if (surface === 'mcp') common.mcp_client = mcpClient || 'other';
211
- pending.push({ eventType, insertId: uuid(), at: now(), seq: seq++, properties: { ...cleanProperties(properties), ...common } });
231
+ pending.push({ eventType, insertId: uuid(), at, seq: seq++, properties: { ...own, ...common } });
212
232
  return true;
213
233
  }
214
234
 
@@ -220,9 +240,16 @@ function createFunnel({
220
240
  // Funnel order, each time strictly after the previous one sent.
221
241
  function stamp(batch) {
222
242
  const ordered = [...batch].sort((a, b) => a.properties.step_index - b.properties.step_index || a.seq - b.seq);
223
- return ordered.map((item) => {
243
+ const latest = [];
244
+ let next = Infinity;
245
+ for (let i = ordered.length - 1; i >= 0; i--) {
246
+ const item = ordered[i];
247
+ latest[i] = item.time !== undefined ? item.time : Math.min(item.at, next - 1);
248
+ next = latest[i];
249
+ }
250
+ return ordered.map((item, i) => {
224
251
  if (item.time === undefined) {
225
- lastTime = Math.max(item.at, lastTime + 1);
252
+ lastTime = Math.max(latest[i], lastTime + 1);
226
253
  item.time = lastTime;
227
254
  }
228
255
  return { item, time: item.time };
@@ -246,8 +273,9 @@ function createFunnel({
246
273
  const url = safe(endpoint);
247
274
  // Never send under another account signed in since.
248
275
  if (!token || !url || accountOf(token) !== account) return Promise.resolve();
249
- const batch = pending;
250
- pending = [];
276
+ const batch = heldFrom === null ? pending : pending.filter((e) => e.properties.step_index < heldFrom);
277
+ if (!batch.length) return Promise.resolve();
278
+ pending = pending.filter((e) => !batch.includes(e));
251
279
  const id = currentDeviceId();
252
280
  const toWire = ({ item, time }) => ({ event_type: item.eventType, insert_id: item.insertId, time, ...(id ? { device_id: id } : {}), properties: item.properties });
253
281
  return post(stamp(batch), toWire, (events) => send({ endpoint: url, events, hubAccessToken: token })).then(() => undefined);
@@ -288,6 +316,10 @@ function createFunnel({
288
316
  surface,
289
317
  begin() {
290
318
  seen.clear();
319
+ viewedAt.clear();
320
+ lastCompletedAt = null;
321
+ heldFrom = null;
322
+ begunAt = now();
291
323
  active = true;
292
324
  },
293
325
  get active() { return active; },
@@ -296,8 +328,13 @@ function createFunnel({
296
328
  viewed: (step, props) => track('onboarding_step_viewed', step, props),
297
329
  completed: (step, props) => track('onboarding_step_completed', step, props),
298
330
  skipped: (step, props) => track('onboarding_step_skipped', step, props),
331
+ stepTime: (step) => (active ? stepTime(step) : undefined),
332
+ holdUntil(step) {
333
+ if (active && STEP_INDEX[step]) heldFrom = STEP_INDEX[step];
334
+ },
299
335
  errored: (step, reason, props = {}) => track('onboarding_step_errored', step, { error_type: errorTypeFor(reason), ...props }),
300
- identified() {
336
+ identified({ newAccount = true } = {}) {
337
+ if (!newAccount) pending = pending.filter((e) => !(e.eventType === 'onboarding_step_completed' && STEP_INDEX[e.properties.step_name] <= STEP_INDEX.account_creation));
301
338
  const token = safe(hubToken);
302
339
  if (!token) return Promise.resolve();
303
340
  account = accountOf(token);
@@ -307,6 +344,7 @@ function createFunnel({
307
344
  flush,
308
345
  idle,
309
346
  settle(capMs = EXIT_FLUSH_CAP_MS) {
347
+ heldFrom = null;
310
348
  flush();
311
349
  let timer;
312
350
  const cap = new Promise((resolve) => { timer = setTimeout(resolve, capMs); });
@@ -334,6 +372,8 @@ const NOOP_FUNNEL = Object.freeze({
334
372
  viewed: () => false,
335
373
  completed: () => false,
336
374
  skipped: () => false,
375
+ stepTime: () => undefined,
376
+ holdUntil() {},
337
377
  errored: () => false,
338
378
  identified: () => Promise.resolve(),
339
379
  flush: () => Promise.resolve(),
@@ -14,7 +14,7 @@ function renderFullReportText(report, maturity, aiProfile, { lang = 'en', gate }
14
14
  );
15
15
  let terminal = '';
16
16
  try {
17
- terminal = stripAnsi(renderTerminal(report, maturity, lang, { gate: gate || reportGateForIdentity() }))
17
+ terminal = stripAnsi(renderTerminal(report, maturity, lang, { gate: { ...(gate || reportGateForIdentity()), showRoadmap: false } }))
18
18
  .replace(/[ \t]+$/gm, '')
19
19
  .replace(/\n{3,}/g, '\n\n')
20
20
  .trim();
@@ -2,11 +2,12 @@
2
2
 
3
3
  const { loadAuthSession, sessionStatus, isPlatformTalent } = require('./auth-session-store');
4
4
  const { getProfile } = require('./config');
5
+ const { hasAccountTrace } = require('./account-trace');
5
6
 
6
7
  // THE ONE LAYER THAT DECIDES WHAT THE REPORT SHOWS ACCORDING TO IDENTITY.
7
8
 
8
9
  // Pure: identity in, policy out.
9
- function resolveReportGate({ loggedIn = false, status = null, email = null, userType = null, profile = 'talent' } = {}) {
10
+ function resolveReportGate({ loggedIn = false, accountTrace = false, status = null, email = null, userType = null, profile = 'talent' } = {}) {
10
11
  const showEverything = !loggedIn;
11
12
  const resolvedProfile = profile === 'external' ? 'external' : 'talent';
12
13
  return Object.freeze({
@@ -16,7 +17,7 @@ function resolveReportGate({ loggedIn = false, status = null, email = null, user
16
17
  email: loggedIn && typeof email === 'string' && email ? email : null,
17
18
  // --roadmap AND its server-side computation/egress (see bin/report.js): a
18
19
  // logged-in Talent's roadmap is not merely hidden, it is not requested.
19
- showRoadmap: showEverything,
20
+ showRoadmap: showEverything && !accountTrace,
20
21
  // Per-agent "how to improve this agent" tips in the footprint report.
21
22
  showAgentSuggestions: showEverything,
22
23
  // The copyable remediation prompt in a `certify` verdict.
@@ -35,6 +36,7 @@ function reportGateForIdentity(env = process.env, now = Date.now()) {
35
36
  const status = sessionStatus(session, now);
36
37
  return resolveReportGate({
37
38
  loggedIn: status === 'active',
39
+ accountTrace: hasAccountTrace(env),
38
40
  status,
39
41
  email: session ? session.email : null,
40
42
  userType: session ? session.userType : null,
@@ -54,10 +54,10 @@ function migrateFromV1(parsed) {
54
54
  return { schemaVersion: SCHEMA_VERSION, updatedAt: (parsed && parsed.updatedAt) || null, projects };
55
55
  }
56
56
 
57
- function loadState() {
57
+ function loadState(env = process.env) {
58
58
  let parsed;
59
59
  try {
60
- parsed = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
60
+ parsed = JSON.parse(fs.readFileSync(statePath(env), 'utf8'));
61
61
  } catch {
62
62
  return freshState();
63
63
  }
@@ -154,6 +154,10 @@ function recordBackendAcceptance({ root, kind, backend }) {
154
154
  return stampAndSaveState(state, absRoot);
155
155
  }
156
156
 
157
+ function hasRecordedUpload(env = process.env) {
158
+ return Object.values(loadState(env).projects).some((p) => !!(p && p.backendAcceptance && Object.keys(p.backendAcceptance).length));
159
+ }
160
+
157
161
  function loadFootprint({ root } = {}) {
158
162
  const absRoot = path.resolve(root || process.cwd());
159
163
  const state = loadState();
@@ -220,6 +224,7 @@ module.exports = {
220
224
  loadAgentClassifications,
221
225
  loadFootprint,
222
226
  recordBackendAcceptance,
227
+ hasRecordedUpload,
223
228
  materializeProjectReport,
224
229
  certKey,
225
230
  SCHEMA_VERSION,
package/src/roles-flow.js CHANGED
@@ -88,6 +88,7 @@ async function runAddRole({ io, session, catalog, deps = makeRolesDeps() }) {
88
88
  // deduped, for setting/switching the MAIN role.
89
89
  async function fetchHeldRoles({ deps, hubAccessToken }) {
90
90
  const held = [];
91
+ const assigned = [];
91
92
  let mainClusterId = null;
92
93
  const certEndpoint = deps.getMyCertificationsEndpoint();
93
94
  if (certEndpoint) {
@@ -99,10 +100,11 @@ async function fetchHeldRoles({ deps, hubAccessToken }) {
99
100
  }
100
101
  const assignedEndpoint = deps.getAssignedClustersEndpoint();
101
102
  if (assignedEndpoint) {
102
- const assigned = await deps.requestAssignedClusters({ hubAccessToken }, { endpoint: assignedEndpoint });
103
- if (assigned.ok) for (const c of assigned.clusters || []) held.push(c);
103
+ const res = await deps.requestAssignedClusters({ hubAccessToken }, { endpoint: assignedEndpoint });
104
+ if (res.ok) assigned.push(...(res.clusters || []));
104
105
  }
105
- return { held: dedupeByCluster(held), mainClusterId };
106
+ held.push(...assigned);
107
+ return { held: dedupeByCluster(held), assigned, mainClusterId };
106
108
  }
107
109
 
108
110
  // `change-role`: switch the MAIN role to one of the roles the talent already holds.
@@ -130,12 +132,12 @@ async function runChangeMainRole({ io, session, catalog, deps = makeRolesDeps()
130
132
 
131
133
  // The onboarding final step: pick a MAIN role from the roles resolved for the
132
134
  // talent (interview recommendations + any held). Degrades gracefully to a skip.
133
- async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRolesDeps() }) {
135
+ async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRolesDeps(), onRolesShown = () => {} }) {
134
136
  const r = catalog.roles;
135
137
  const hubAccessToken = session ? session.hubAccessToken : null;
136
138
 
137
139
  io.section(r.mainRoleTitle);
138
- const { held, mainClusterId } = await io.withProgress(r.loadingHeld, () => fetchHeldRoles({ deps, hubAccessToken }));
140
+ const { held, assigned, mainClusterId } = await io.withProgress(r.loadingHeld, () => fetchHeldRoles({ deps, hubAccessToken }));
139
141
 
140
142
  // Prefer the talent's own/recommended roles; fall back to the catalog for a
141
143
  // fresh talent so a main role can always be set (picking one adds it first).
@@ -150,6 +152,7 @@ async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRole
150
152
  io.notify(r.mainRolePending);
151
153
  return { ok: true, chosen: null };
152
154
  }
155
+ onRolesShown({ suggested: assigned.filter((c) => c.type === 'RECOMMENDED').map((c) => c.clusterId), clustersCount: assigned.length });
153
156
  const choice = await chooseRole({
154
157
  io, roles, header: r.mainRolePrompt, hint: r.selectHint, initialClusterId: mainClusterId,
155
158
  });
@@ -19,9 +19,9 @@ const SEND_THROTTLE_MS = 60 * 60 * 1000;
19
19
 
20
20
  // consent state (~/.config/ai-footprint/consent.json) Replaces the old `credentials.json` (token model).
21
21
 
22
- function loadConsentState() {
22
+ function loadConsentState(env = process.env) {
23
23
  try {
24
- return JSON.parse(fs.readFileSync(consentPath(), 'utf8'));
24
+ return JSON.parse(fs.readFileSync(consentPath(env), 'utf8'));
25
25
  } catch {
26
26
  return null;
27
27
  }