shakers-cli 0.15.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.
Files changed (132) hide show
  1. package/README.md +56 -1203
  2. package/bin/ai-usage.js +32 -58
  3. package/bin/login.js +10 -10
  4. package/bin/mcp.js +17 -12
  5. package/bin/mcpb.js +1 -1
  6. package/bin/onboarding.js +3 -2
  7. package/bin/register.js +19 -5
  8. package/bin/report-html.js +2 -2
  9. package/bin/shakers-cli.js +352 -0
  10. package/bin/shakers.js +20 -344
  11. package/bin/share.js +1 -1
  12. package/bin/superadmin.js +5 -5
  13. package/bin/version.js +3 -0
  14. package/package.json +12 -3
  15. package/src/account-marker.js +40 -0
  16. package/src/account-trace.js +23 -0
  17. package/src/agent-category.js +1 -1
  18. package/src/agent-evaluation.js +3 -3
  19. package/src/agent-org-chart.js +5 -5
  20. package/src/agent-synthesis.js +3 -3
  21. package/src/agent-usage.js +1 -1
  22. package/src/agents-client.js +4 -4
  23. package/src/ai-usage-review.js +101 -0
  24. package/src/ai.js +1 -1
  25. package/src/ansi.js +1 -1
  26. package/src/auth-client.js +18 -4
  27. package/src/auth-method-picker.js +1 -1
  28. package/src/auth-methods.js +1 -1
  29. package/src/auth-session-store.js +18 -8
  30. package/src/authorship-mismatch.js +1 -1
  31. package/src/authorship.js +2 -2
  32. package/src/automations-detector.js +1 -1
  33. package/src/backend-request.js +5 -3
  34. package/src/brand-ansi.js +1 -1
  35. package/src/browser-tools-detector.js +1 -1
  36. package/src/certify-disclaimer.js +1 -1
  37. package/src/certify-remediation-prompt.js +1 -1
  38. package/src/cli-args.js +2 -2
  39. package/src/command-audit.js +8 -5
  40. package/src/config-dir.js +1 -1
  41. package/src/config.js +114 -48
  42. package/src/consent-flow.js +7 -6
  43. package/src/consent-skip.js +1 -1
  44. package/src/detection-evidence.js +2 -2
  45. package/src/detection-noise.js +1 -1
  46. package/src/detectors.js +1 -1
  47. package/src/device-login.js +12 -1
  48. package/src/email-verification.js +3 -3
  49. package/src/env-paths.js +1 -1
  50. package/src/find-projects-client.js +12 -0
  51. package/src/find-projects-flow.js +1 -13
  52. package/src/git-activity.js +1 -1
  53. package/src/graph-certs.js +3 -3
  54. package/src/graph-scan.js +9 -9
  55. package/src/i18n.js +148 -138
  56. package/src/interactive-select.js +1 -1
  57. package/src/interview-client.js +6 -1
  58. package/src/interview-transcript.js +1 -1
  59. package/src/legal-notice.js +1 -1
  60. package/src/maturity.js +6 -6
  61. package/src/mcp-ai-usage-result-tool.js +34 -12
  62. package/src/mcp-ai-usage-tool.js +282 -163
  63. package/src/mcp-auth-form.js +7 -3
  64. package/src/mcp-detector.js +3 -3
  65. package/src/mcp-device-login-tools.js +2 -1
  66. package/src/mcp-find-projects-tools.js +1 -2
  67. package/src/mcp-install.js +279 -44
  68. package/src/mcp-instructions.js +1 -1
  69. package/src/mcp-onboarding-tools.js +4 -4
  70. package/src/mcp-prompts.js +4 -4
  71. package/src/mcp-register-tools.js +1 -0
  72. package/src/mcp-role-tools.js +24 -1
  73. package/src/mcp-server.js +5 -1
  74. package/src/mcp-signup-client.js +2 -2
  75. package/src/mcp-signup-tools.js +39 -9
  76. package/src/memory-structure-detector.js +1 -1
  77. package/src/model-call-record.js +2 -2
  78. package/src/onboarding-flow.js +63 -42
  79. package/src/onboarding-funnel.js +153 -20
  80. package/src/open-file.js +1 -1
  81. package/src/osc-link.js +1 -1
  82. package/src/portfolio-client.js +11 -1
  83. package/src/portfolio-git-info.js +1 -1
  84. package/src/profile-flow.js +1 -1
  85. package/src/profile-switch.js +5 -12
  86. package/src/render-certification.js +10 -10
  87. package/src/render-html.js +6 -6
  88. package/src/render-report-text.js +1 -1
  89. package/src/render-sheet.js +10 -10
  90. package/src/render-terminal.js +6 -6
  91. package/src/repo-scope.js +132 -0
  92. package/src/report-gating.js +5 -3
  93. package/src/report-store.js +13 -8
  94. package/src/roadmap-content.js +5 -5
  95. package/src/roadmap-personalization.js +3 -3
  96. package/src/roadmap-prompt.js +2 -2
  97. package/src/roles-flow.js +8 -5
  98. package/src/scan-exclusions.js +1 -1
  99. package/src/scanner.js +14 -14
  100. package/src/secret-prompt.js +1 -1
  101. package/src/session-gate.js +1 -1
  102. package/src/session-refresh.js +33 -3
  103. package/src/session-scan.js +1 -1
  104. package/src/shakers-wordmark.js +1 -1
  105. package/src/share-card.js +2 -2
  106. package/src/share-certification.js +4 -4
  107. package/src/share-consent-state.js +9 -9
  108. package/src/share-payload.js +7 -7
  109. package/src/share.js +7 -7
  110. package/src/signup-client.js +1 -1
  111. package/src/signup-copy.js +177 -2
  112. package/src/skills-client.js +1 -1
  113. package/src/start-add-agents.js +2 -2
  114. package/src/start-add-portfolio.js +3 -13
  115. package/src/start-add-skills.js +2 -2
  116. package/src/stdin-ask.js +16 -4
  117. package/src/strip-internal-comments.js +1 -1
  118. package/src/tech-detector.js +2 -2
  119. package/src/templates/report-sheet.html +11 -13
  120. package/src/terminal-agents.js +8 -8
  121. package/src/terminal-format.js +3 -3
  122. package/src/terminal-sections.js +11 -11
  123. package/src/tier-engine.js +3 -3
  124. package/src/tier-projection.js +2 -2
  125. package/src/transcript-turns.js +1 -1
  126. package/src/typewriter.js +1 -1
  127. package/src/usage-model-calls.js +6 -6
  128. package/src/usage-oneshot-commands.js +5 -5
  129. package/src/usage-repo-scope.js +11 -129
  130. package/src/usage-run-steps.js +82 -181
  131. package/src/usage-scan.js +235 -0
  132. package/src/work-streams.js +1 -1
@@ -13,7 +13,7 @@ const { askAfterTexts, askConsent, optionFor, queueNotice, takeNotices } = requi
13
13
  const { expandHome, tildify } = require('./env-paths');
14
14
  const { NOOP_FUNNEL, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
15
15
 
16
- // MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
16
+ // MCP sign-up: hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
17
17
 
18
18
  const SIGNUP_WINDOW_TIMEOUT_MS = 15 * 60 * 1000;
19
19
  // The browser opens this long after the tool answers, so the AI's reply is on screen first.
@@ -302,6 +302,7 @@ function makeSignupTools(deps = {}) {
302
302
  pollDeviceToken = require('./device-login').pollDeviceToken,
303
303
  finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
304
304
  appendProviderParam = require('./device-login').appendProviderParam,
305
+ deviceSessionRecord = require('./device-login').deviceSessionRecord,
305
306
  buildRegistrationContext = require('./signup-client').buildRegistrationContext,
306
307
  runLoopbackAuth = require('./mcp-auth-form').runLoopbackAuth,
307
308
  checkOnboardingCompleted = require('./onboarding-availability').checkOnboardingCompleted,
@@ -337,6 +338,7 @@ function makeSignupTools(deps = {}) {
337
338
  wake: null,
338
339
  importStartedAt: null,
339
340
  importReported: new Set(),
341
+ authAttempts: undefined,
340
342
  };
341
343
  currentSignup = state;
342
344
 
@@ -344,8 +346,12 @@ function makeSignupTools(deps = {}) {
344
346
  const before = state.account.state;
345
347
  state.account = next;
346
348
  if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
347
- funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
348
- funnel.identified();
349
+ funnel.completed('account_creation', { auth_method: next.via, auth_attempts: state.authAttempts, is_returning_account: next.state === 'existing' });
350
+ funnel.identified({ newAccount: next.state === 'created' });
351
+ }
352
+ if (next.state === 'failed' && before !== 'failed') {
353
+ funnel.errored('account_creation', next.reason);
354
+ funnel.flush();
349
355
  }
350
356
  if (state.wake) state.wake();
351
357
  }
@@ -385,7 +391,7 @@ function makeSignupTools(deps = {}) {
385
391
  }
386
392
  const email = res.email || emailFromHubToken(fin.token);
387
393
  try {
388
- resolved.saveAuthSession({ accessToken: fin.token, hubAccessToken: fin.token, email });
394
+ resolved.saveAuthSession(deviceSessionRecord({ token: fin.token, sessionToken: res.accessToken, sessionExpiresIn: res.expiresIn, email }));
389
395
  } catch {
390
396
  setAccount({ state: 'failed', reason: 'session-persist-failed' });
391
397
  return;
@@ -434,6 +440,7 @@ function makeSignupTools(deps = {}) {
434
440
  onUrl: (url) => { state.windowUrl = url; announce(url); },
435
441
  },
436
442
  ).then((outcome) => {
443
+ if (outcome && outcome.ok) state.authAttempts = outcome.attempts;
437
444
  if (outcome && outcome.viaSocial) return;
438
445
  if (!outcome || !outcome.ok) {
439
446
  setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
@@ -482,6 +489,7 @@ function makeSignupTools(deps = {}) {
482
489
  // rides on the next question block, because models skip a relay that comes before another tool call.
483
490
  if (!nonEmpty(args.answer) && state.cvPath) {
484
491
  funnel.completed('start');
492
+ funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
485
493
  queueNotice(c.welcome(nonEmpty(args.firstName)));
486
494
  return {
487
495
  ok: true,
@@ -500,6 +508,7 @@ function makeSignupTools(deps = {}) {
500
508
  });
501
509
  if (asked.reply) return { ...asked.reply, language: lang() };
502
510
  funnel.completed('start');
511
+ funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
503
512
  // A CV path the talent gave wins over both answers: searching could read another candidate.
504
513
  const cvPath = state.cvPath;
505
514
  if (cvPath) {
@@ -576,6 +585,7 @@ function makeSignupTools(deps = {}) {
576
585
  return { ok: false, reason: 'draft-sources-required', message: "The draft needs sources: 'cv' if you read their CV, 'linkedin' only if you have their LinkedIn URL. Then call signup_draft again." };
577
586
  }
578
587
  if (!state.firstDraft) state.firstDraft = draft;
588
+ trackDraftShown(draft);
579
589
  const asked = await askAfterTexts(ctx, {
580
590
  tool: 'signup_draft',
581
591
  texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
@@ -659,10 +669,10 @@ function makeSignupTools(deps = {}) {
659
669
  const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
660
670
  state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
661
671
  for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
662
- funnel.completed('import_sources', { sources_attempted: attempted });
672
+ funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
663
673
  }
664
674
  if (!reused) {
665
- funnel.viewed('account_creation');
675
+ funnel.viewed('account_creation', { auth_methods_offered: require('./mcp-auth-form').WINDOW_METHODS.register });
666
676
  await openWindow(identity);
667
677
  }
668
678
  return {
@@ -708,7 +718,7 @@ function makeSignupTools(deps = {}) {
708
718
 
709
719
  function nextSteps(account) {
710
720
  const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 45), then set_main_role without clusterId only when its next says so. Finally open_web.';
711
- const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes).';
721
+ const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes). After a yes, ai_usage_upload (waitSeconds 30) shows the talent what would be sent and asks; nothing is uploaded without their pick.';
712
722
  if (account.state === 'existing') {
713
723
  return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update, and its text tells the talent about the account, so say nothing about it yourself. ${aiUsage} ${interview}`;
714
724
  }
@@ -951,6 +961,21 @@ function makeSignupTools(deps = {}) {
951
961
  return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
952
962
  }
953
963
 
964
+ function trackDraftShown(draft) {
965
+ const prefilled = (step) => ({ is_prefilled: STEP_DRAFT_FIELDS[step].some((key) => draft[key] !== undefined) });
966
+ funnel.viewed('work_situation', prefilled('work_situation'));
967
+ funnel.viewed('availability', prefilled('availability'));
968
+ funnel.viewed('rates', { suggested_rate_shown: draft.ratesFromTalent !== true });
969
+ funnel.viewed('languages', prefilled('languages'));
970
+ funnel.viewed('location', prefilled('location'));
971
+ funnel.viewed('profile_summary');
972
+ }
973
+
974
+ function draftFieldsEdited() {
975
+ if (!state.firstDraft || !state.draft) return undefined;
976
+ return DRAFT_FIELDS.filter((key) => String(state.firstDraft[key] ?? '') !== String(state.draft[key] ?? '')).length;
977
+ }
978
+
954
979
  function wasEdited(step) {
955
980
  if (!state.firstDraft || !state.draft) return undefined;
956
981
  return STEP_DRAFT_FIELDS[step].some((key) => String(state.firstDraft[key] ?? '') !== String(state.draft[key] ?? ''));
@@ -958,7 +983,8 @@ function makeSignupTools(deps = {}) {
958
983
 
959
984
  function trackSavedSteps(details, results) {
960
985
  const saved = (section) => results[section] && results[section].ok === true;
961
- const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name) });
986
+ const draftTime = funnel.stepTime('profile_summary');
987
+ const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name), time_on_step_ms: draftTime });
962
988
  if (saved('workSituation')) {
963
989
  step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
964
990
  }
@@ -976,12 +1002,16 @@ function makeSignupTools(deps = {}) {
976
1002
  }
977
1003
  if (saved('pricing')) {
978
1004
  const types = rateTypes(pricingFromRates(details.pricing).input);
979
- funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates') });
1005
+ funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates'), time_on_step_ms: draftTime });
980
1006
  }
981
1007
  if (saved('languages')) {
982
1008
  const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
983
1009
  step('languages', { languages_count: codes.length, languages: codes });
984
1010
  }
1011
+ const sections = Object.entries(results).filter(([name]) => name !== 'phone');
1012
+ if (sections.length && sections.every(([, r]) => r.ok === true)) {
1013
+ funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited(), time_on_step_ms: draftTime });
1014
+ }
985
1015
  funnel.flush();
986
1016
  }
987
1017
 
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { getHomeDir } = require('./env-paths');
6
6
 
7
- // Deterministic (no-LLM) memory STRUCTURE detector (talents-ai-score, issue 016 / ADR-013-014).
7
+ // Deterministic (no-LLM) memory STRUCTURE detector.
8
8
 
9
9
  const MAX_FILE_BYTES = 2_000_000; // safety cap: never read a pathologically huge file
10
10
  const MAX_IMPORT_DEPTH = 5; // mirrors Claude Code's own documented import-hop limit
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // What happened with each model call, written down (talents-ai-score, issue 109).
3
+ // What happened with each model call, written down.
4
4
 
5
5
  // The three keys.
6
6
  const MODEL_CALL = Object.freeze({
@@ -163,7 +163,7 @@ function callPartial(report, key) {
163
163
  return !!c && c.ok === true && Array.isArray(c.omitted) && c.omitted.length > 0;
164
164
  }
165
165
 
166
- // THE CALL LOST TIME TO A DEADLINE — and this is NOT `reason === TIMEOUT` (issue 116, found by measuring, not by reading).
166
+ // THE CALL LOST TIME TO A DEADLINE — and this is NOT `reason === TIMEOUT` (found by measuring, not by reading).
167
167
  function callTimedOut(report, key) {
168
168
  const c = modelCall(report, key);
169
169
  if (!c || c.ok !== false) return false;
@@ -168,7 +168,7 @@ function normalizeAmount(value) {
168
168
  return cents / 100;
169
169
  }
170
170
 
171
- // Pricing input (ADR-024).
171
+ // Pricing input.
172
172
  function validatePricing(input = {}) {
173
173
  const fullSelected = input.fullTimeSelected === true;
174
174
  const partSelected = input.partTimeSelected === true;
@@ -212,7 +212,7 @@ function buildPricingUpsert({ fullSelected, fullAmount, fullCurrency, partSelect
212
212
  };
213
213
  }
214
214
 
215
- // Availability + location (ADR-028).
215
+ // Availability + location.
216
216
  function validateAvailability(input = {}) {
217
217
  const body = { confirmed: true };
218
218
  if (typeof input.available === 'boolean') body.available = input.available;
@@ -299,7 +299,6 @@ function makeDeps(overrides = {}) {
299
299
  runMainRoleStep: require('./roles-flow').runOnboardingMainRoleStep,
300
300
  makeRolesDeps: require('./roles-flow').makeRolesDeps,
301
301
  requestDiscoveredInventory: inventory.requestDiscoveredInventory,
302
- makeInterviewClient: (opts) => new (require('./interview-client').InterviewClient)(opts),
303
302
  // Used by reattachConsentIdentity (A) to re-anchor the ingest identity.
304
303
  recordConsent: require('./share-consent-state').recordConsent,
305
304
  ...overrides,
@@ -371,7 +370,7 @@ async function establishSessionFromCredentials(deps, { email, password } = {}) {
371
370
 
372
371
  // Register a talent via a social provider (device flow, RFC 8628); a live session results either way.
373
372
  async function registerWithSocialAccount(deps, { provider = 'google', freelanceType, language } = {}, { openBrowser, onAuthUrl } = {}) {
374
- const { appendProviderParam } = require('./device-login');
373
+ const { appendProviderParam, deviceSessionRecord } = require('./device-login');
375
374
  const authorizeEndpoint = deps.getDeviceAuthorizeEndpoint();
376
375
  const tokenEndpoint = deps.getDeviceTokenEndpoint();
377
376
  const authTokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
@@ -397,13 +396,8 @@ async function registerWithSocialAccount(deps, { provider = 'google', freelanceT
397
396
  );
398
397
  if (!result.ok) return { ok: false, reason: result.reason || 'register-failed' };
399
398
 
400
- // Persist the API JWT the exchange minted (no cookie), like the retired Google flow did.
401
399
  try {
402
- deps.saveAuthSession({
403
- accessToken: result.token,
404
- hubAccessToken: result.token,
405
- email: result.email || null,
406
- });
400
+ deps.saveAuthSession(deviceSessionRecord(result));
407
401
  } catch {
408
402
  return { ok: false, reason: 'session-persist-failed' };
409
403
  }
@@ -675,18 +669,15 @@ function trackImport(funnel, attempted, importRes, durationMs) {
675
669
  const bySource = new Map();
676
670
  const report = importRes.ok && importRes.report ? importRes.report.sources : [];
677
671
  for (const src of report) bySource.set(sourceTypeFor(src.source), src);
678
- const connected = [];
679
672
  for (const type of attempted) {
680
673
  const src = bySource.get(type);
681
674
  const ok = importRes.ok && (!src || src.status === 'imported');
682
675
  if (ok) {
683
- connected.push(type);
684
676
  funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
685
677
  } else {
686
678
  funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
687
679
  }
688
680
  }
689
- return connected;
690
681
  }
691
682
 
692
683
  // Blocking orchestrator used by the CLI (`bin/onboarding.js`).
@@ -695,9 +686,16 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
695
686
  const c = getCatalog(io.lang).onboarding;
696
687
 
697
688
  funnel.begin();
689
+ funnel.viewed('start');
698
690
  funnel.completed('start');
699
691
  io.section(c.signupTitle);
700
- funnel.viewed('account_creation');
692
+ funnel.viewed('account_creation', { auth_methods_offered: require('./auth-methods').methodIdsForFlow('register') });
693
+ funnel.flush();
694
+ const accountFailed = (reason) => {
695
+ funnel.errored('account_creation', reason);
696
+ funnel.flush();
697
+ return { ok: false, reason, step: 'signup' };
698
+ };
701
699
 
702
700
  if (method === 'google' || method === 'linkedin') {
703
701
  // The provider supplies name/email; freelanceType is derived later from work situation.
@@ -709,10 +707,10 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
709
707
  );
710
708
  if (!reg.ok) {
711
709
  io.error(c.signupError);
712
- return { ok: false, reason: reg.reason, step: 'signup' };
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);
@@ -721,39 +719,39 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
721
719
  notifyOk(io, c.accountReady);
722
720
  } else {
723
721
  const account = await io.askSignUp(c);
724
- if (!account || !account.email) return { ok: false, reason: 'missing-fields', step: 'signup' };
722
+ if (!account || !account.email) return accountFailed('missing-fields');
725
723
  if (!isAcceptablePassword(account.password)) {
726
724
  io.error(c.weakPassword);
727
- return { ok: false, reason: 'weak-password', step: 'signup' };
725
+ return accountFailed('weak-password');
728
726
  }
729
727
 
730
728
  const su = await signUp(deps, { ...account, preferredLanguage: account.preferredLanguage || io.lang });
731
729
  if (!su.ok) {
732
730
  io.error(su.message || c.signupError);
733
- return { ok: false, reason: su.reason, step: 'signup' };
731
+ return accountFailed(su.reason);
734
732
  }
735
733
 
736
734
  if (su.accountExists) {
737
735
  // Existing account: sign them in, then STOP — no wizard re-run.
738
736
  io.warn(c.accountExists);
739
737
  const logged = await io.login(account.email);
740
- if (!logged) return { ok: false, reason: 'login-failed', step: 'signup' };
741
- funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
742
- funnel.identified();
738
+ if (!logged) return accountFailed('login-failed');
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 });
746
744
  if (!sess.ok) {
747
745
  io.error(c.sessionError);
748
- return { ok: false, reason: sess.reason, step: 'signup' };
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
  }