shakers-cli 0.16.0 → 0.18.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.
Files changed (69) hide show
  1. package/README.md +21 -6
  2. package/bin/ai-usage.js +31 -12
  3. package/bin/command-audit-worker.js +19 -0
  4. package/bin/login.js +4 -8
  5. package/bin/mcp.js +32 -21
  6. package/bin/register.js +12 -30
  7. package/bin/shakers-cli.js +15 -10
  8. package/package.json +1 -1
  9. package/src/account-marker.js +40 -0
  10. package/src/account-trace.js +23 -0
  11. package/src/agents-client.js +1 -0
  12. package/src/ai-usage-review.js +103 -0
  13. package/src/auth-client.js +7 -107
  14. package/src/auth-session-store.js +12 -11
  15. package/src/backend-request.js +3 -24
  16. package/src/cli-skill.js +77 -0
  17. package/src/command-audit.js +47 -4
  18. package/src/config.js +2 -16
  19. package/src/consent-flow.js +9 -3
  20. package/src/detect-repos.js +4 -8
  21. package/src/detectors.js +23 -0
  22. package/src/device-auth-store.js +2 -2
  23. package/src/device-login.js +11 -9
  24. package/src/env-paths.js +9 -1
  25. package/src/i18n.js +186 -102
  26. package/src/mcp-ai-usage-result-tool.js +38 -14
  27. package/src/mcp-ai-usage-tool.js +317 -172
  28. package/src/mcp-choice.js +171 -34
  29. package/src/mcp-detector.js +26 -2
  30. package/src/mcp-device-login-tools.js +40 -8
  31. package/src/mcp-install.js +292 -53
  32. package/src/mcp-instructions.js +43 -17
  33. package/src/mcp-onboarding-tools.js +1 -3
  34. package/src/mcp-prompts.js +8 -6
  35. package/src/mcp-register-tools.js +25 -14
  36. package/src/mcp-role-tools.js +59 -18
  37. package/src/mcp-server-name.js +7 -0
  38. package/src/mcp-server.js +35 -7
  39. package/src/mcp-signup-client.js +3 -6
  40. package/src/mcp-signup-flow.js +389 -0
  41. package/src/mcp-signup-tools.js +250 -133
  42. package/src/onboarding-flow.js +72 -146
  43. package/src/onboarding-funnel.js +46 -6
  44. package/src/opencode-sessions.js +92 -0
  45. package/src/profile-agents.js +26 -0
  46. package/src/render-report-text.js +1 -1
  47. package/src/report-gating.js +4 -2
  48. package/src/report-store.js +7 -2
  49. package/src/roles-flow.js +8 -5
  50. package/src/saved-positions-flow.js +3 -3
  51. package/src/scanner.js +104 -12
  52. package/src/session-gate.js +1 -4
  53. package/src/session-normalizers.js +114 -8
  54. package/src/session-refresh.js +5 -16
  55. package/src/session-scan.js +167 -22
  56. package/src/share-consent-state.js +2 -2
  57. package/src/share-payload.js +4 -1
  58. package/src/share.js +2 -2
  59. package/src/signup-client.js +12 -39
  60. package/src/signup-copy.js +545 -58
  61. package/src/skill-install.js +102 -0
  62. package/src/stdin-ask.js +13 -1
  63. package/src/tier-engine.js +14 -2
  64. package/src/trust-narration.js +32 -0
  65. package/src/usage-run-steps.js +68 -6
  66. package/src/usage-scan.js +11 -8
  67. package/src/brand-svg.js +0 -9
  68. package/src/inter-fonts.js +0 -6
  69. package/src/mcp-auth-form.js +0 -480
@@ -249,7 +249,6 @@ function makeDeps(overrides = {}) {
249
249
  const ai = require('./ai');
250
250
  const inventory = require('./inventory-client');
251
251
  const auth = require('./auth-session-store');
252
- const authClient = require('./auth-client');
253
252
  const signup = require('./signup-client');
254
253
  const livekit = require('./onboarding-livekit-client');
255
254
  return {
@@ -257,17 +256,14 @@ function makeDeps(overrides = {}) {
257
256
  sessionStatus: auth.sessionStatus,
258
257
  saveAuthSession: auth.saveAuthSession,
259
258
  ensureFreshSession: require('./session-refresh').ensureFreshSession,
260
- getSignUpEndpoint: config.getSignUpEndpoint,
261
259
  getCompleteRegistrationEndpoint: config.getCompleteRegistrationEndpoint,
262
- getLoginEndpoint: config.getLoginEndpoint,
263
260
  getAuthTokenEndpoint: config.getAuthTokenEndpoint,
264
261
  getDeviceAuthorizeEndpoint: config.getDeviceAuthorizeEndpoint,
265
262
  getDeviceTokenEndpoint: config.getDeviceTokenEndpoint,
266
263
  runDeviceLogin: require('./device-login').runDeviceLogin,
267
264
  requestCompleteRegistration: signup.requestCompleteRegistration,
268
265
  buildRegistrationContext: signup.buildRegistrationContext,
269
- requestSignUp: signup.requestSignUp,
270
- requestLogin: authClient.requestLogin,
266
+ buildRegistrationConsent: signup.buildRegistrationConsent,
271
267
  getImportProfileEndpoint: config.getImportProfileEndpoint,
272
268
  getProfessionalDetailsEndpoint: config.getProfessionalDetailsEndpoint,
273
269
  getPricingRateEndpoint: config.getPricingRateEndpoint,
@@ -305,7 +301,6 @@ function makeDeps(overrides = {}) {
305
301
  };
306
302
  }
307
303
 
308
- // Refreshes the email session's hub JWT from its stored cookie when the cached one has lapsed (session-refresh.js), then gates on the durable session status.
309
304
  async function requireActiveSession(deps) {
310
305
  const session = typeof deps.ensureFreshSession === 'function'
311
306
  ? await deps.ensureFreshSession(process.env, { loadAuthSession: deps.loadAuthSession, saveAuthSession: deps.saveAuthSession })
@@ -316,79 +311,26 @@ async function requireActiveSession(deps) {
316
311
  return { ok: true, session };
317
312
  }
318
313
 
319
- function isAcceptablePassword(password) {
320
- return typeof password === 'string' && password.length >= 8;
321
- }
322
-
323
- async function signUp(deps, fields = {}) {
324
- // The CLI no longer asks freelanceType at signup → transient default (corrected later from the work situation via professional-details).
325
- if (!isAcceptablePassword(fields.password)) return { ok: false, reason: 'weak-password' };
326
- const signUpEndpoint = deps.getSignUpEndpoint();
327
- if (!signUpEndpoint) return { ok: false, reason: 'no-endpoint' };
328
- const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function'
329
- ? deps.getCompleteRegistrationEndpoint()
330
- : null;
331
- return deps.requestSignUp(
332
- {
333
- name: fields.name,
334
- lastName: fields.lastName,
335
- email: fields.email,
336
- password: fields.password,
337
- preferredLanguage: fields.preferredLanguage,
338
- newsletterConsent: fields.newsletterConsent === true,
339
- freelanceType: normalizeFreelanceType(fields.freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
340
- freelanceIntent: fields.freelanceIntent,
341
- claimCode: fields.claimCode || null,
342
- },
343
- { signUpEndpoint, completeRegistrationEndpoint },
344
- );
345
- }
346
-
347
- async function establishSessionFromCredentials(deps, { email, password } = {}) {
348
- const signInEndpoint = deps.getLoginEndpoint();
349
- if (!signInEndpoint) return { ok: false, reason: 'no-endpoint' };
350
- const tokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
351
- const result = await deps.requestLogin({ email, password }, { signInEndpoint, tokenEndpoint });
352
- if (!result.ok) return { ok: false, reason: result.reason || 'login-failed', status: result.status || null };
353
- try {
354
- // For an email session the durable credential is the cookie; `accessToken` is
355
- // the fresh JWT and `expiresAt` its own exp (stored as the refresh horizon).
356
- deps.saveAuthSession({
357
- accessToken: result.accessToken,
358
- expiresAt: result.expiresAt,
359
- accessTokenExpiresAt: result.expiresAt,
360
- cookie: result.cookie || null,
361
- cookieExpiresAt: result.cookieExpiresAt || null,
362
- email: result.email || email || null,
363
- hubAccessToken: result.hubAccessToken || null,
364
- });
365
- } catch {
366
- return { ok: false, reason: 'session-persist-failed' };
367
- }
368
- return { ok: true };
369
- }
370
-
371
- // Register a talent via a social provider (device flow, RFC 8628); a live session results either way.
372
- async function registerWithSocialAccount(deps, { provider = 'google', freelanceType, language } = {}, { openBrowser, onAuthUrl } = {}) {
373
- const { appendProviderParam, deviceSessionRecord } = require('./device-login');
314
+ async function registerWithDeviceFlow(deps, { provider = 'email', freelanceType, language, newsletterConsent = false } = {}, { openBrowser, onAuthUrl } = {}) {
315
+ const { withDeviceParams, deviceSessionRecord } = require('./device-login');
374
316
  const authorizeEndpoint = deps.getDeviceAuthorizeEndpoint();
375
317
  const tokenEndpoint = deps.getDeviceTokenEndpoint();
376
318
  const authTokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
377
319
  const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function' ? deps.getCompleteRegistrationEndpoint() : null;
378
320
  if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
379
321
 
380
- // freelanceType is a transient default (corrected later); feeds complete-registration for a new account.
381
322
  const registrationContext = deps.buildRegistrationContext({
382
323
  preferredLanguage: language,
383
- newsletterConsent: false,
324
+ newsletterConsent: newsletterConsent === true,
384
325
  freelanceType: normalizeFreelanceType(freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
385
326
  });
327
+ const consent = deps.buildRegistrationConsent({ newsletterConsent: newsletterConsent === true });
386
328
 
387
329
  const result = await deps.runDeviceLogin(
388
- { authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext },
330
+ { authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext, consent },
389
331
  {
390
332
  onPrompt: ({ verificationUriComplete, userCode }) => {
391
- const url = appendProviderParam(verificationUriComplete, provider);
333
+ const url = withDeviceParams(verificationUriComplete, { provider, mode: 'register', surface: 'cli' });
392
334
  if (typeof onAuthUrl === 'function') onAuthUrl(url, userCode);
393
335
  if (typeof openBrowser === 'function') { try { openBrowser(url); } catch { /* printed URL covers it */ } }
394
336
  },
@@ -402,7 +344,6 @@ async function registerWithSocialAccount(deps, { provider = 'google', freelanceT
402
344
  return { ok: false, reason: 'session-persist-failed' };
403
345
  }
404
346
 
405
- // isNewUser distinguishes a fresh registration from signing an existing account in.
406
347
  return { ok: true, kind: result.isNewUser ? 'registered' : 'account-already-exists', email: result.email || null };
407
348
  }
408
349
 
@@ -669,18 +610,15 @@ function trackImport(funnel, attempted, importRes, durationMs) {
669
610
  const bySource = new Map();
670
611
  const report = importRes.ok && importRes.report ? importRes.report.sources : [];
671
612
  for (const src of report) bySource.set(sourceTypeFor(src.source), src);
672
- const connected = [];
673
613
  for (const type of attempted) {
674
614
  const src = bySource.get(type);
675
615
  const ok = importRes.ok && (!src || src.status === 'imported');
676
616
  if (ok) {
677
- connected.push(type);
678
617
  funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
679
618
  } else {
680
619
  funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
681
620
  }
682
621
  }
683
- return connected;
684
622
  }
685
623
 
686
624
  // Blocking orchestrator used by the CLI (`bin/onboarding.js`).
@@ -689,9 +627,10 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
689
627
  const c = getCatalog(io.lang).onboarding;
690
628
 
691
629
  funnel.begin();
630
+ funnel.viewed('start');
692
631
  funnel.completed('start');
693
632
  io.section(c.signupTitle);
694
- funnel.viewed('account_creation');
633
+ funnel.viewed('account_creation', { auth_methods_offered: require('./auth-methods').methodIdsForFlow('register') });
695
634
  funnel.flush();
696
635
  const accountFailed = (reason) => {
697
636
  funnel.errored('account_creation', reason);
@@ -699,61 +638,30 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
699
638
  return { ok: false, reason, step: 'signup' };
700
639
  };
701
640
 
702
- if (method === 'google' || method === 'linkedin') {
703
- // The provider supplies name/email; freelanceType is derived later from work situation.
704
- await io.askGoogleSignUp(c);
705
- const reg = await registerWithSocialAccount(
706
- deps,
707
- { provider: method, language: io.lang },
708
- { openBrowser, onAuthUrl },
709
- );
710
- if (!reg.ok) {
711
- io.error(c.signupError);
712
- return accountFailed(reg.reason);
713
- }
714
- funnel.completed('account_creation', { auth_method: method, is_returning_account: reg.kind === 'account-already-exists' });
715
- funnel.identified();
716
- if (reg.kind === 'account-already-exists') {
717
- // Existing account: already signed in — stop, no wizard re-run.
718
- io.warn(c.accountExists);
719
- return { ok: true, alreadyRegistered: true };
720
- }
721
- notifyOk(io, c.accountReady);
722
- } else {
723
- const account = await io.askSignUp(c);
724
- if (!account || !account.email) return accountFailed('missing-fields');
725
- if (!isAcceptablePassword(account.password)) {
726
- io.error(c.weakPassword);
727
- return accountFailed('weak-password');
728
- }
729
-
730
- const su = await signUp(deps, { ...account, preferredLanguage: account.preferredLanguage || io.lang });
731
- if (!su.ok) {
732
- io.error(su.message || c.signupError);
733
- return accountFailed(su.reason);
734
- }
735
-
736
- if (su.accountExists) {
737
- // Existing account: sign them in, then STOP — no wizard re-run.
738
- io.warn(c.accountExists);
739
- const logged = await io.login(account.email);
740
- if (!logged) return accountFailed('login-failed');
741
- funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
742
- funnel.identified();
743
- return { ok: true, alreadyRegistered: true };
744
- }
745
- const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
746
- if (!sess.ok) {
747
- io.error(c.sessionError);
748
- return accountFailed(sess.reason);
749
- }
750
- funnel.completed('account_creation', { auth_method: 'email', is_returning_account: false });
751
- funnel.identified();
752
- notifyOk(io, c.accountReady);
641
+ const terms = await io.askTerms(c);
642
+ if (!terms.accepted) {
643
+ io.warn(c.termsDeclined);
644
+ return accountFailed('terms-declined');
645
+ }
646
+ const reg = await registerWithDeviceFlow(
647
+ deps,
648
+ { provider: method, language: io.lang, newsletterConsent: terms.newsletterConsent },
649
+ { openBrowser, onAuthUrl },
650
+ );
651
+ if (!reg.ok) {
652
+ io.error(c.signupError);
653
+ return accountFailed(reg.reason);
654
+ }
655
+ funnel.completed('account_creation', { auth_method: method, auth_attempts: 1, is_returning_account: reg.kind === 'account-already-exists' });
656
+ funnel.identified({ newAccount: reg.kind !== 'account-already-exists' });
657
+ if (reg.kind === 'account-already-exists') {
658
+ io.warn(c.accountExists);
659
+ return { ok: true, alreadyRegistered: true };
753
660
  }
661
+ notifyOk(io, c.accountReady);
754
662
 
755
663
  io.section(c.title);
756
- funnel.viewed('import_sources');
664
+ funnel.viewed('import_sources', { sources_offered: ['linkedin', 'cv', 'github', 'web_page'] });
757
665
  const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
758
666
  if (!linkedinUrl) {
759
667
  funnel.errored('import_sources', 'no-linkedin', { error_field: 'linkedin' });
@@ -772,15 +680,15 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
772
680
  const importStartedAt = Date.now();
773
681
  const importRes = await withProgress(c.importChecking, () =>
774
682
  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 });
683
+ trackImport(funnel, attempted, importRes, Date.now() - importStartedAt);
684
+ funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
777
685
  funnel.flush();
778
686
  if (importRes.ok) notifyOk(io, c.importDone);
779
687
  else io.warn(c.importUnavailable);
780
688
 
781
689
  // Steps 4-8 are saved out of funnel order: sent together once all are answered.
782
690
  io.section(c.professionalTitle);
783
- funnel.viewed('work_situation');
691
+ funnel.viewed('work_situation', { is_prefilled: false });
784
692
  const workSituationInput = await io.askWorkSituation(c);
785
693
  const workSituationRes = await saveWorkSituation(deps, workSituationInput);
786
694
  if (!workSituationRes.ok) {
@@ -788,11 +696,11 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
788
696
  funnel.flush();
789
697
  return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
790
698
  }
791
- funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()) });
699
+ funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()), was_edited: true });
792
700
  notifyOk(io, c.professionalSaved);
793
701
 
794
702
  io.section(c.pricingTitle);
795
- funnel.viewed('rates');
703
+ funnel.viewed('rates', { suggested_rate_shown: false });
796
704
  const pricingInput = await io.askPricing(c);
797
705
  const priceRes = await savePricing(deps, pricingInput);
798
706
  if (!priceRes.ok) {
@@ -805,23 +713,31 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
805
713
  rate_types_selected: selectedRates,
806
714
  rate_type: selectedRates[0],
807
715
  rate_currency: normalizeCurrency(pricingInput.partTimeSelected === true ? pricingInput.partTimeCurrency : pricingInput.fullTimeCurrency) || undefined,
716
+ was_edited: true,
808
717
  });
809
718
  notifyOk(io, c.pricingSaved);
810
719
 
811
720
  // Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
812
721
  io.section(c.availabilityTitle);
813
- funnel.viewed('availability');
722
+ funnel.viewed('availability', { is_prefilled: false });
814
723
  if (await io.confirm(c.availabilityAsk)) {
815
- const availabilityInput = await io.askAvailability(c);
724
+ let availabilityTime;
725
+ const availabilityInput = await io.askAvailability(c, {
726
+ onLocation: () => {
727
+ availabilityTime = funnel.stepTime('availability');
728
+ funnel.viewed('location', { is_prefilled: false });
729
+ },
730
+ });
816
731
  const availRes = await saveAvailability(deps, availabilityInput);
817
732
  if (availRes.ok) {
818
- funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)) });
733
+ funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)), time_on_step_ms: availabilityTime, was_edited: true });
819
734
  const country = normalizeCountry(availabilityInput.country);
820
735
  const modes = Array.isArray(availabilityInput.workModes) ? availabilityInput.workModes.map((m) => String(m).trim().toUpperCase()) : [];
821
736
  funnel.completed('location', {
822
737
  location_country: country ? country.toLowerCase() : undefined,
823
738
  location_method: 'manual',
824
739
  is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
740
+ was_edited: true,
825
741
  });
826
742
  notifyOk(io, c.availabilitySaved);
827
743
  } else {
@@ -839,13 +755,13 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
839
755
  }
840
756
 
841
757
  io.section(c.languagesTitle);
842
- funnel.viewed('languages');
758
+ funnel.viewed('languages', { is_prefilled: false });
843
759
  if (await io.confirm(c.languagesAsk)) {
844
760
  const languageRows = await io.askLanguages(c);
845
761
  const langRes = await saveLanguages(deps, languageRows);
846
762
  if (langRes.ok) {
847
763
  const codes = validateLanguageRows(languageRows).rows.map((r) => r.language);
848
- funnel.completed('languages', { languages_count: codes.length, languages: codes });
764
+ funnel.completed('languages', { languages_count: codes.length, languages: codes, was_edited: true });
849
765
  notifyOk(io, c.languagesSaved);
850
766
  } else {
851
767
  funnel.errored('languages', langRes.reason);
@@ -888,20 +804,35 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
888
804
  // Every ending points the Talent at their profile, and opens it when a browser is at hand.
889
805
  const showProfile = () => {
890
806
  const url = profileUrl(deps);
807
+ funnel.viewed('profile_summary');
891
808
  notifyOk(io, url ? c.profileReadyUrl(url) : c.profileReadyNoUrl);
892
809
  if (url && typeof openProfile === 'function') {
893
810
  try { openProfile(url); } catch { /* the printed link covers it */ }
894
811
  }
812
+ funnel.completed('profile_summary');
813
+ funnel.flush();
895
814
  return url;
896
815
  };
897
816
 
898
817
  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');
818
+ let shown = null;
819
+ const role = await runMainRoleFinalStep(io, deps, {
820
+ onRolesShown: (info) => {
821
+ shown = info;
822
+ funnel.viewed('supercluster', { suggested_superclusters: info.suggested, suggestion_source: 'assigned_clusters', suggestions_count: info.suggested.length });
823
+ },
824
+ });
825
+ if (role && role.ok && role.chosen) {
826
+ funnel.completed('supercluster', {
827
+ supercluster: role.chosen,
828
+ clusters_count: shown ? shown.clustersCount : undefined,
829
+ was_suggestion_accepted: shown ? shown.suggested.includes(role.chosen) : undefined,
830
+ });
831
+ } else if (role && role.ok) funnel.skipped('supercluster');
902
832
  funnel.flush();
903
833
  };
904
834
 
835
+ funnel.holdUntil('profile_summary');
905
836
  io.section(c.interviewTitle);
906
837
  funnel.viewed('pre_interview');
907
838
  io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
@@ -1010,28 +941,26 @@ async function conductLivekitInterview(io, deps, { interviewId = null, language
1010
941
  }
1011
942
 
1012
943
  // 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);
944
+ async function runMainRoleFinalStep(io, deps, { onRolesShown } = {}) {
945
+ const active = typeof deps.loadAuthSession === 'function' ? deps.loadAuthSession() : null;
1015
946
  if (typeof deps.runMainRoleStep !== 'function' || !active) return;
1016
947
  const { getCatalog } = require('./i18n');
1017
948
  const catalog = getCatalog(io.lang);
1018
949
  try {
1019
950
  const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
1020
- return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
951
+ return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps, onRolesShown });
1021
952
  } catch {
1022
953
  return null;
1023
954
  }
1024
955
  }
1025
956
 
1026
- // Overwrite-repeat: PATCH /interviews/onboarding/restart. Per contract the Bearer is
1027
- // the talent hub JWT (same token roles use); falls back to accessToken when equal.
957
+ // Overwrite-repeat: PATCH /interviews/onboarding/restart. Candidate certs JWT, like its onboarding siblings.
1028
958
  async function restartOnboarding(deps) {
1029
959
  const gate = await requireActiveSession(deps);
1030
960
  if (!gate.ok) return gate;
1031
961
  const endpoint = deps.getOnboardingRestartEndpoint();
1032
962
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
1033
- const token = gate.session.hubAccessToken || gate.session.accessToken;
1034
- return deps.requestRestartOnboardingInterview({ accessToken: token }, { endpoint });
963
+ return deps.requestRestartOnboardingInterview({ accessToken: gate.session.accessToken }, { endpoint });
1035
964
  }
1036
965
 
1037
966
  // `shakers onboarding --repeat`: warn + explicit confirm, then overwrite the prior
@@ -1123,7 +1052,6 @@ module.exports = {
1123
1052
  validatePricing,
1124
1053
  validateAvailability,
1125
1054
  buildPricingUpsert,
1126
- isAcceptablePassword,
1127
1055
  deriveCurrentEmploymentStatus,
1128
1056
  showsFreelanceOpinion,
1129
1057
  deriveFreelanceIntent,
@@ -1132,9 +1060,7 @@ module.exports = {
1132
1060
  validateLanguageRows,
1133
1061
  validatePhone,
1134
1062
  makeDeps,
1135
- signUp,
1136
- establishSessionFromCredentials,
1137
- registerWithSocialAccount,
1063
+ registerWithDeviceFlow,
1138
1064
  startProfileImport,
1139
1065
  reattachConsentIdentity,
1140
1066
  saveWorkSituation,
@@ -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(),
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getHomeDir } = require('./env-paths');
6
+
7
+ function loadSqlite() {
8
+ const emitWarning = process.emitWarning;
9
+ process.emitWarning = (warning, ...rest) => {
10
+ const type = typeof rest[0] === 'string' ? rest[0] : rest[0] && rest[0].type;
11
+ if (type === 'ExperimentalWarning' && /sqlite/i.test(String(warning))) return;
12
+ emitWarning.call(process, warning, ...rest);
13
+ };
14
+ try {
15
+ return require('node:sqlite');
16
+ } catch {
17
+ return null;
18
+ } finally {
19
+ process.emitWarning = emitWarning;
20
+ }
21
+ }
22
+
23
+ function opencodeDbPath(env = process.env) {
24
+ const dataDir = env.XDG_DATA_HOME
25
+ ? path.join(env.XDG_DATA_HOME, 'opencode')
26
+ : path.join(getHomeDir(env), '.local', 'share', 'opencode');
27
+ if (env.OPENCODE_DB) return path.isAbsolute(env.OPENCODE_DB) ? env.OPENCODE_DB : path.join(dataDir, env.OPENCODE_DB);
28
+ return path.join(dataDir, 'opencode.db');
29
+ }
30
+
31
+ const SESSIONS_SQL = 'SELECT id, directory, parent_id AS parentId, agent FROM session ORDER BY time_created DESC LIMIT ?';
32
+ const MESSAGES_SQL = `SELECT id, time_created AS createdAt, json_extract(data, '$.role') AS role, json_extract(data, '$.agent') AS agent
33
+ FROM message WHERE session_id = ? ORDER BY time_created, id`;
34
+ const PARTS_SQL = `SELECT p.message_id AS messageId, json_extract(p.data, '$.type') AS type, json_extract(p.data, '$.text') AS text,
35
+ json_extract(p.data, '$.tool') AS tool, json_extract(p.data, '$.synthetic') AS synthetic
36
+ FROM part p JOIN message m ON m.id = p.message_id WHERE m.session_id = ? ORDER BY p.id`;
37
+
38
+ function isoOf(ms) {
39
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
40
+ }
41
+
42
+ function sessionObjects(db, session) {
43
+ const parts = new Map();
44
+ for (const p of db.prepare(PARTS_SQL).all(session.id)) {
45
+ if (!parts.has(p.messageId)) parts.set(p.messageId, []);
46
+ parts.get(p.messageId).push(p);
47
+ }
48
+ const head = {};
49
+ if (typeof session.directory === 'string' && session.directory) head.cwd = session.directory;
50
+ if (session.agent === 'plan') head.planMode = true;
51
+ const objects = [head];
52
+ for (const m of db.prepare(MESSAGES_SQL).all(session.id)) {
53
+ const timestamp = isoOf(Number(m.createdAt));
54
+ const own = parts.get(m.id) || [];
55
+ if (m.agent === 'plan') head.planMode = true;
56
+ if (m.role === 'user') {
57
+ const text = own.filter((p) => p.type === 'text' && !p.synthetic && typeof p.text === 'string').map((p) => p.text).join('').trim();
58
+ if (text) {
59
+ objects.push({ type: 'user', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
60
+ continue;
61
+ }
62
+ }
63
+ const uses = own.filter((p) => p.type === 'tool' && typeof p.tool === 'string' && p.tool).map((p) => ({ type: 'tool_use', name: p.tool }));
64
+ const o = timestamp ? { timestamp } : {};
65
+ if (uses.length) o.message = { role: 'assistant', content: uses };
66
+ if (o.timestamp || o.message) objects.push(o);
67
+ }
68
+ return objects;
69
+ }
70
+
71
+ function readOpencodeSessions(env, limit) {
72
+ const file = opencodeDbPath(env);
73
+ if (!fs.existsSync(file)) return [];
74
+ const sqlite = loadSqlite();
75
+ const Database = sqlite && (sqlite.DatabaseSync || sqlite.Database);
76
+ if (!Database) return [];
77
+ let db;
78
+ try {
79
+ db = new Database(file, { readOnly: true });
80
+ return db.prepare(SESSIONS_SQL).all(limit).map((session) => ({
81
+ file: `${file}#${session.id}`,
82
+ objects: sessionObjects(db, session),
83
+ subagent: Boolean(session.parentId),
84
+ }));
85
+ } catch {
86
+ return [];
87
+ } finally {
88
+ if (db) db.close();
89
+ }
90
+ }
91
+
92
+ module.exports = { readOpencodeSessions, opencodeDbPath };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const { requestListAgents } = require('./agents-client');
4
+ const { getAgentsListEndpoint } = require('./config');
5
+ const { ensureFreshSession } = require('./session-refresh');
6
+
7
+ const TALENT_ADDED_SOURCES = new Set(['DECLARED_BY_TALENT', 'CLI_DETECTED']);
8
+
9
+ async function fetchProfileAgentNames(env = process.env, deps = {}) {
10
+ const {
11
+ refresh = ensureFreshSession,
12
+ list = requestListAgents,
13
+ endpoint = getAgentsListEndpoint(env),
14
+ } = deps;
15
+ try {
16
+ const session = await refresh(env);
17
+ if (!session || !session.hubAccessToken) return [];
18
+ const listed = await list({ accessToken: session.accessToken, hubAccessToken: session.hubAccessToken }, { endpoint });
19
+ if (!listed || !listed.ok || !Array.isArray(listed.agents)) return [];
20
+ return listed.agents.filter((a) => a && TALENT_ADDED_SOURCES.has(a.source) && a.name).map((a) => a.name);
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ module.exports = { fetchProfileAgentNames, TALENT_ADDED_SOURCES };
@@ -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();