shakers-cli-beta 0.12.0 → 0.14.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 (44) hide show
  1. package/README.md +28 -3
  2. package/bin/mcp.js +13 -7
  3. package/bin/register.js +5 -2
  4. package/package.json +1 -1
  5. package/src/authorship-mismatch.js +62 -0
  6. package/src/backend-request.js +12 -8
  7. package/src/config.js +27 -6
  8. package/src/consent-flow.js +10 -10
  9. package/src/i18n.js +16 -12
  10. package/src/mcp-ai-usage-result-tool.js +4 -2
  11. package/src/mcp-ai-usage-tool.js +3 -1
  12. package/src/mcp-alma-tools.js +7 -3
  13. package/src/mcp-applications-tools.js +4 -2
  14. package/src/mcp-auth-form.js +39 -10
  15. package/src/mcp-availability-tools.js +4 -2
  16. package/src/mcp-certifications-tools.js +4 -2
  17. package/src/mcp-certify-dimension-tools.js +4 -2
  18. package/src/mcp-choice.js +30 -4
  19. package/src/mcp-device-login-tools.js +6 -2
  20. package/src/mcp-find-projects-tools.js +4 -2
  21. package/src/mcp-instructions.js +6 -6
  22. package/src/mcp-invitations-tools.js +4 -2
  23. package/src/mcp-lang-tools.js +4 -2
  24. package/src/mcp-onboarding-tools.js +41 -44
  25. package/src/mcp-portfolios-tools.js +7 -3
  26. package/src/mcp-profile-tools.js +4 -2
  27. package/src/mcp-prompts.js +3 -3
  28. package/src/mcp-rate-tools.js +4 -2
  29. package/src/mcp-register-tools.js +53 -20
  30. package/src/mcp-report-tools.js +7 -3
  31. package/src/mcp-role-tools.js +36 -13
  32. package/src/mcp-saved-positions-tools.js +7 -3
  33. package/src/mcp-server.js +17 -4
  34. package/src/mcp-show-project-tools.js +4 -2
  35. package/src/mcp-signup-tools.js +229 -62
  36. package/src/mcp-socials-tools.js +4 -2
  37. package/src/onboarding-flow.js +112 -13
  38. package/src/onboarding-funnel.js +272 -0
  39. package/src/profile-client.js +11 -0
  40. package/src/share.js +4 -3
  41. package/src/signup-copy.js +84 -26
  42. package/src/terminal-agents.js +8 -0
  43. package/src/usage-run-steps.js +3 -0
  44. package/src/mcp-ask-tools.js +0 -50
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { NOOP_FUNNEL, sourceTypeFor, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
4
+
3
5
  const FREELANCE_TYPES = ['FREELANCE', 'EMPLOYEE', 'AGENCY', 'POTENTIAL_FREELANCE'];
4
6
  const CURRENCIES = ['USD', 'EUR', 'GBP'];
5
7
  const PRICE_MIN = 0;
@@ -669,12 +671,33 @@ function interviewDisclaimers(lang) {
669
671
  return { infoAccessed: c.interviewInfoAccessed, goalDuration: c.interviewGoalDuration };
670
672
  }
671
673
 
674
+ function trackImport(funnel, attempted, importRes, durationMs) {
675
+ const bySource = new Map();
676
+ const report = importRes.ok && importRes.report ? importRes.report.sources : [];
677
+ for (const src of report) bySource.set(sourceTypeFor(src.source), src);
678
+ const connected = [];
679
+ for (const type of attempted) {
680
+ const src = bySource.get(type);
681
+ const ok = importRes.ok && (!src || src.status === 'imported');
682
+ if (ok) {
683
+ connected.push(type);
684
+ funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
685
+ } else {
686
+ funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
687
+ }
688
+ }
689
+ return connected;
690
+ }
691
+
672
692
  // Blocking orchestrator used by the CLI (`bin/onboarding.js`).
673
- async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrowser, onAuthUrl, openProfile } = {}) {
693
+ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrowser, onAuthUrl, openProfile, funnel = NOOP_FUNNEL } = {}) {
674
694
  const { getCatalog } = require('./i18n');
675
695
  const c = getCatalog(io.lang).onboarding;
676
696
 
697
+ funnel.begin();
698
+ funnel.completed('start');
677
699
  io.section(c.signupTitle);
700
+ funnel.viewed('account_creation');
678
701
 
679
702
  if (method === 'google' || method === 'linkedin') {
680
703
  // The provider supplies name/email; freelanceType is derived later from work situation.
@@ -688,6 +711,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
688
711
  io.error(c.signupError);
689
712
  return { ok: false, reason: reg.reason, step: 'signup' };
690
713
  }
714
+ funnel.completed('account_creation', { auth_method: method, is_returning_account: reg.kind === 'account-already-exists' });
715
+ funnel.identified();
691
716
  if (reg.kind === 'account-already-exists') {
692
717
  // Existing account: already signed in — stop, no wizard re-run.
693
718
  io.warn(c.accountExists);
@@ -713,6 +738,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
713
738
  io.warn(c.accountExists);
714
739
  const logged = await io.login(account.email);
715
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();
716
743
  return { ok: true, alreadyRegistered: true };
717
744
  }
718
745
  const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
@@ -720,12 +747,19 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
720
747
  io.error(c.sessionError);
721
748
  return { ok: false, reason: sess.reason, step: 'signup' };
722
749
  }
750
+ funnel.completed('account_creation', { auth_method: 'email', is_returning_account: false });
751
+ funnel.identified();
723
752
  notifyOk(io, c.accountReady);
724
753
  }
725
754
 
726
755
  io.section(c.title);
756
+ funnel.viewed('import_sources');
727
757
  const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
728
- if (!linkedinUrl) return { ok: false, reason: 'no-linkedin', step: 'import' };
758
+ if (!linkedinUrl) {
759
+ funnel.errored('import_sources', 'no-linkedin', { error_field: 'linkedin' });
760
+ funnel.flush();
761
+ return { ok: false, reason: 'no-linkedin', step: 'import' };
762
+ }
729
763
  const cvPath = (await io.ask(c.askCv) || '').trim();
730
764
  const githubUrl = (await io.ask(c.askGithub) || '').trim();
731
765
  const websiteUrl = (await io.ask(c.askWebsite) || '').trim();
@@ -733,45 +767,95 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
733
767
  // The hub import is SYNCHRONOUS (the POST returns once it finishes), so show the
734
768
  // loader AROUND that wait, then the real outcome. No background job to poll.
735
769
  const withProgress = typeof io.withProgress === 'function' ? io.withProgress : (_l, task) => task();
770
+ const attempted = [linkedinUrl && 'linkedin', cvPath && 'cv', githubUrl && 'github', websiteUrl && 'web_page'].filter(Boolean);
771
+ for (const type of attempted) funnel.track('source_import_started', 'import_sources', { source_type: type });
772
+ const importStartedAt = Date.now();
736
773
  const importRes = await withProgress(c.importChecking, () =>
737
774
  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 });
777
+ funnel.flush();
738
778
  if (importRes.ok) notifyOk(io, c.importDone);
739
779
  else io.warn(c.importUnavailable);
740
780
 
781
+ // Steps 4-8 are saved out of funnel order: sent together once all are answered.
741
782
  io.section(c.professionalTitle);
783
+ funnel.viewed('work_situation');
742
784
  const workSituationInput = await io.askWorkSituation(c);
743
785
  const workSituationRes = await saveWorkSituation(deps, workSituationInput);
744
- if (!workSituationRes.ok) return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
786
+ if (!workSituationRes.ok) {
787
+ funnel.errored('work_situation', workSituationRes.reason);
788
+ funnel.flush();
789
+ return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
790
+ }
791
+ funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()) });
745
792
  notifyOk(io, c.professionalSaved);
746
793
 
747
794
  io.section(c.pricingTitle);
795
+ funnel.viewed('rates');
748
796
  const pricingInput = await io.askPricing(c);
749
797
  const priceRes = await savePricing(deps, pricingInput);
750
- if (!priceRes.ok) return { ok: false, reason: priceRes.reason, step: 'pricing' };
798
+ if (!priceRes.ok) {
799
+ funnel.errored('rates', priceRes.reason);
800
+ funnel.flush();
801
+ return { ok: false, reason: priceRes.reason, step: 'pricing' };
802
+ }
803
+ const selectedRates = rateTypes(pricingInput);
804
+ funnel.completed('rates', {
805
+ rate_types_selected: selectedRates,
806
+ rate_type: selectedRates[0],
807
+ rate_currency: normalizeCurrency(pricingInput.partTimeSelected === true ? pricingInput.partTimeCurrency : pricingInput.fullTimeCurrency) || undefined,
808
+ });
751
809
  notifyOk(io, c.pricingSaved);
752
810
 
753
811
  // Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
754
812
  io.section(c.availabilityTitle);
813
+ funnel.viewed('availability');
755
814
  if (await io.confirm(c.availabilityAsk)) {
756
815
  const availabilityInput = await io.askAvailability(c);
757
816
  const availRes = await saveAvailability(deps, availabilityInput);
758
- if (availRes.ok) notifyOk(io, c.availabilitySaved); else io.warn(c.availabilityUnavailable);
817
+ if (availRes.ok) {
818
+ funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)) });
819
+ const country = normalizeCountry(availabilityInput.country);
820
+ const modes = Array.isArray(availabilityInput.workModes) ? availabilityInput.workModes.map((m) => String(m).trim().toUpperCase()) : [];
821
+ funnel.completed('location', {
822
+ location_country: country ? country.toLowerCase() : undefined,
823
+ location_method: 'manual',
824
+ is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
825
+ });
826
+ notifyOk(io, c.availabilitySaved);
827
+ } else {
828
+ funnel.errored('availability', availRes.reason);
829
+ io.warn(c.availabilityUnavailable);
830
+ }
759
831
  if (availabilityInput.telephoneCode || availabilityInput.telephoneNumber) {
760
832
  const phoneRes = await savePhone(deps, availabilityInput);
761
833
  if (phoneRes.ok) notifyOk(io, c.phoneSaved); else io.warn(c.phoneUnavailable);
762
834
  }
763
835
  } else {
836
+ funnel.skipped('availability');
837
+ funnel.skipped('location');
764
838
  io.notify(c.availabilitySkipped);
765
839
  }
766
840
 
767
841
  io.section(c.languagesTitle);
842
+ funnel.viewed('languages');
768
843
  if (await io.confirm(c.languagesAsk)) {
769
844
  const languageRows = await io.askLanguages(c);
770
845
  const langRes = await saveLanguages(deps, languageRows);
771
- if (langRes.ok) notifyOk(io, c.languagesSaved); else io.warn(c.languagesUnavailable);
846
+ if (langRes.ok) {
847
+ const codes = validateLanguageRows(languageRows).rows.map((r) => r.language);
848
+ funnel.completed('languages', { languages_count: codes.length, languages: codes });
849
+ notifyOk(io, c.languagesSaved);
850
+ } else {
851
+ funnel.errored('languages', langRes.reason);
852
+ io.warn(c.languagesUnavailable);
853
+ }
772
854
  } else {
855
+ funnel.skipped('languages');
773
856
  io.notify(c.languagesSkipped);
774
857
  }
858
+ funnel.flush();
775
859
 
776
860
  const prior = await detectPriorUsage(deps);
777
861
  let didUsage = false;
@@ -811,24 +895,36 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
811
895
  return url;
812
896
  };
813
897
 
898
+ 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');
902
+ funnel.flush();
903
+ };
904
+
814
905
  io.section(c.interviewTitle);
906
+ funnel.viewed('pre_interview');
815
907
  io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
816
908
  if (!(await io.confirm(c.interviewAccept))) {
817
- await runMainRoleFinalStep(io, deps);
909
+ funnel.completed('pre_interview', { interview_choice: 'skip' });
910
+ await mainRole();
818
911
  const fin = await finalize();
819
912
  const url = showProfile();
820
913
  return { ok: true, completed: 'skipped-interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
821
914
  }
915
+ funnel.completed('pre_interview', { interview_choice: 'start_now' });
822
916
 
823
- const interview = await conductLivekitInterview(io, deps);
917
+ const interview = await conductLivekitInterview(io, deps, { funnel });
824
918
  if (!interview.ok) {
825
- await runMainRoleFinalStep(io, deps);
919
+ funnel.errored('interview', interview.reason);
920
+ await mainRole();
826
921
  const fin = await finalize();
827
922
  const url = showProfile();
828
923
  return { ok: true, completed: 'interview-unavailable', reason: interview.reason, didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
829
924
  }
925
+ funnel.completed('interview', { interview_mode: 'text' });
830
926
  notifyOk(io, c.interviewComplete);
831
- await runMainRoleFinalStep(io, deps);
927
+ await mainRole();
832
928
  const fin = await finalize();
833
929
  const url = showProfile();
834
930
  return { ok: true, completed: 'interview', didUsage, profileUrl: url, finalized: fin.ok, registration: fin };
@@ -860,7 +956,7 @@ function livekitTurnBridge(client, transcript) {
860
956
  }
861
957
 
862
958
  // The onboarding/dimension interview over LiveKit text transport. LiveKit Node SDK is an optional lazily-required dep; a `livekit-not-installed` connect prints a hint, not a stack trace. Persists the transcript on session end even when interrupted; a failed PATCH warns, never crashes.
863
- async function conductLivekitInterview(io, deps, { interviewId = null, language = null, plain = false } = {}) {
959
+ async function conductLivekitInterview(io, deps, { interviewId = null, language = null, plain = false, funnel = null } = {}) {
864
960
  const { getCatalog } = require('./i18n');
865
961
  const c = getCatalog(io.lang).onboarding;
866
962
  const { TranscriptBuffer } = require('./interview-transcript');
@@ -870,6 +966,7 @@ async function conductLivekitInterview(io, deps, { interviewId = null, language
870
966
  ? await startLivekitForInterview(deps, { interviewId, language: language || io.lang })
871
967
  : await createAndStartLivekitOnboarding(deps, { language: language || io.lang });
872
968
  if (!start.ok) return { ok: false, reason: start.reason, step: start.step || 'interview' };
969
+ if (funnel) funnel.viewed('interview', { interview_mode: 'text' });
873
970
 
874
971
  const closing = start.closingMessage;
875
972
  const isClosing = closing ? (t) => typeof t === 'string' && t.includes(closing) : null;
@@ -920,8 +1017,10 @@ async function runMainRoleFinalStep(io, deps, session = null) {
920
1017
  const catalog = getCatalog(io.lang);
921
1018
  try {
922
1019
  const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
923
- await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
924
- } catch { /* the alta stands even if the role step fails */ }
1020
+ return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
1021
+ } catch {
1022
+ return null;
1023
+ }
925
1024
  }
926
1025
 
927
1026
  // Overwrite-repeat: PATCH /interviews/onboarding/restart. Per contract the Bearer is
@@ -0,0 +1,272 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { getConfigDir } = require('./config-dir');
7
+
8
+ // Names and values mirror works-frontend onboarding-funnel-enums.ts.
9
+
10
+ const FLOW_VERSION = 'signup_new_works_v1';
11
+
12
+ const STEP_INDEX = Object.freeze({
13
+ start: 1,
14
+ account_creation: 2,
15
+ import_sources: 3,
16
+ work_situation: 4,
17
+ availability: 5,
18
+ rates: 6,
19
+ languages: 7,
20
+ location: 8,
21
+ profile_summary: 9,
22
+ pre_interview: 10,
23
+ interview: 11,
24
+ supercluster: 12,
25
+ });
26
+
27
+ const EVENT_TYPES = Object.freeze([
28
+ 'onboarding_step_viewed',
29
+ 'onboarding_step_completed',
30
+ 'onboarding_step_skipped',
31
+ 'onboarding_step_errored',
32
+ 'onboarding_step_exited',
33
+ 'source_import_started',
34
+ 'source_import_completed',
35
+ 'source_import_failed',
36
+ ]);
37
+
38
+ const SURFACES = Object.freeze(['cli', 'mcp']);
39
+ const MCP_CLIENTS = Object.freeze(['claude', 'chatgpt', 'cursor', 'other']);
40
+ const SOURCE_TYPES = Object.freeze(['linkedin', 'cv', 'github', 'web_page']);
41
+ const ERROR_TYPES = Object.freeze(['validation', 'server', 'timeout', 'auth', 'import']);
42
+
43
+ const WORK_SITUATION_VALUES = Object.freeze({
44
+ FREELANCE: 'freelance',
45
+ EMPLOYED: 'employed',
46
+ BETWEEN_JOBS: 'between-jobs',
47
+ STUDYING: 'studying',
48
+ OTHER: 'other',
49
+ });
50
+
51
+ const ONCE_PER_STEP = new Set(['onboarding_step_viewed', 'onboarding_step_completed']);
52
+ const MAX_EVENTS_PER_REQUEST = 50;
53
+ const SEND_TIMEOUT_MS = 3000;
54
+ const DEVICE_FILE_NAME = 'analytics-device.json';
55
+
56
+ function mcpClientFor(clientName) {
57
+ const name = typeof clientName === 'string' ? clientName.toLowerCase() : '';
58
+ if (name.includes('claude')) return 'claude';
59
+ if (name.includes('chatgpt') || name.includes('openai')) return 'chatgpt';
60
+ if (name.includes('cursor')) return 'cursor';
61
+ return 'other';
62
+ }
63
+
64
+ function sourceTypeFor(source) {
65
+ if (source === 'linkedin' || source === 'cv' || source === 'github') return source;
66
+ return 'web_page';
67
+ }
68
+
69
+ function workSituationValue(code) {
70
+ return WORK_SITUATION_VALUES[code] || null;
71
+ }
72
+
73
+ // Same ÷4 as the web; '> 160' has no number.
74
+ function weeklyHours(monthlyHours) {
75
+ const n = Number(monthlyHours);
76
+ return monthlyHours != null && monthlyHours !== '' && Number.isFinite(n) ? Math.round(n / 4) : null;
77
+ }
78
+
79
+ // As on the web: part-time project price is hourly, full-time is annual.
80
+ function rateTypes({ partTimeSelected, fullTimeSelected } = {}) {
81
+ return [partTimeSelected === true && 'hourly', fullTimeSelected === true && 'annual'].filter(Boolean);
82
+ }
83
+
84
+ function errorTypeFor(reason) {
85
+ const r = String(reason || '');
86
+ if (r === 'no-session' || r === 'no-hub-token' || r === 'http-401' || r === 'http-403') return 'auth';
87
+ if (r === 'timeout') return 'timeout';
88
+ if (r === 'no-endpoint') return 'server';
89
+ if (r.startsWith('bad-') || r.startsWith('no-') || r === 'duplicate-language' || r === 'unresolved-languages') return 'validation';
90
+ return 'server';
91
+ }
92
+
93
+ function cleanProperties(properties) {
94
+ const ok = (v) => typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))
95
+ || (Array.isArray(v) && v.every((x) => typeof x === 'string'));
96
+ return Object.fromEntries(Object.entries(properties || {}).filter(([, v]) => ok(v)));
97
+ }
98
+
99
+ function deviceFilePath(env) {
100
+ return path.join(getConfigDir(env), DEVICE_FILE_NAME);
101
+ }
102
+
103
+ function loadOrCreateDeviceId(env = process.env, { fs: fsImpl = fs } = {}) {
104
+ const target = deviceFilePath(env);
105
+ try {
106
+ const parsed = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
107
+ if (parsed && typeof parsed.deviceId === 'string' && parsed.deviceId) return parsed.deviceId;
108
+ } catch { /* create one */ }
109
+ const deviceId = crypto.randomUUID();
110
+ const dir = path.dirname(target);
111
+ const tmp = path.join(dir, `.${DEVICE_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
112
+ let fd = null;
113
+ try {
114
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
115
+ fd = fsImpl.openSync(tmp, 'wx', 0o600);
116
+ fsImpl.writeFileSync(fd, `${JSON.stringify({ deviceId }, null, 2)}\n`);
117
+ try { fsImpl.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
118
+ fsImpl.closeSync(fd);
119
+ fd = null;
120
+ fsImpl.renameSync(tmp, target);
121
+ return deviceId;
122
+ } catch {
123
+ if (fd !== null) { try { fsImpl.closeSync(fd); } catch { /* already closed */ } }
124
+ try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
125
+ return null;
126
+ }
127
+ }
128
+
129
+ async function defaultSend({ endpoint, events, hubAccessToken }) {
130
+ const { postJsonWithTimeout } = require('./backend-request');
131
+ const res = await postJsonWithTimeout(endpoint, { events }, SEND_TIMEOUT_MS, 'POST', null, { Authorization: `Bearer ${hubAccessToken}` });
132
+ return { ok: res.status >= 200 && res.status < 300, status: res.status };
133
+ }
134
+
135
+ function defaultHubToken() {
136
+ const { loadAuthSession, sessionStatus } = require('./auth-session-store');
137
+ const session = loadAuthSession();
138
+ return sessionStatus(session) === 'active' && session && session.hubAccessToken ? session.hubAccessToken : null;
139
+ }
140
+
141
+ function createFunnel({
142
+ surface,
143
+ hubToken = defaultHubToken,
144
+ deviceId = () => loadOrCreateDeviceId(),
145
+ endpoint = () => require('./config').getOnboardingEventsEndpoint(),
146
+ send = defaultSend,
147
+ now = () => Date.now(),
148
+ uuid = () => crypto.randomUUID(),
149
+ } = {}) {
150
+ if (!SURFACES.includes(surface)) throw new Error(`unknown funnel surface: ${surface}`);
151
+ let pending = [];
152
+ let lastTime = 0;
153
+ let seq = 0;
154
+ let ready = false;
155
+ let account = null;
156
+ let active = false;
157
+ let mcpClient = null;
158
+ let cachedDeviceId;
159
+ const seen = new Set();
160
+ const inflight = new Set();
161
+
162
+ function track(eventType, stepName, properties = {}) {
163
+ if (!active || !EVENT_TYPES.includes(eventType) || !STEP_INDEX[stepName]) return false;
164
+ if (ONCE_PER_STEP.has(eventType)) {
165
+ const key = `${eventType}:${stepName}`;
166
+ if (seen.has(key)) return false;
167
+ seen.add(key);
168
+ }
169
+ const common = { step_name: stepName, step_index: STEP_INDEX[stepName], flow_version: FLOW_VERSION, surface };
170
+ if (surface === 'mcp') common.mcp_client = mcpClient || 'other';
171
+ pending.push({ eventType, insertId: uuid(), at: now(), seq: seq++, properties: { ...cleanProperties(properties), ...common } });
172
+ return true;
173
+ }
174
+
175
+ // Funnel order, each time strictly after the previous one sent.
176
+ function stamp(batch) {
177
+ const ordered = [...batch].sort((a, b) => a.properties.step_index - b.properties.step_index || a.seq - b.seq);
178
+ const id = cachedDeviceId === undefined ? (cachedDeviceId = safe(deviceId)) : cachedDeviceId;
179
+ return ordered.map((e) => {
180
+ lastTime = Math.max(e.at, lastTime + 1);
181
+ return { event_type: e.eventType, insert_id: e.insertId, time: lastTime, ...(id ? { device_id: id } : {}), properties: e.properties };
182
+ });
183
+ }
184
+
185
+ function flush() {
186
+ if (!ready || !pending.length) return Promise.resolve();
187
+ const token = safe(hubToken);
188
+ const url = safe(endpoint);
189
+ // Never send under another account signed in since.
190
+ if (!token || !url || accountOf(token) !== account) return Promise.resolve();
191
+ const events = stamp(pending);
192
+ pending = [];
193
+ const sends = [];
194
+ for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
195
+ sends.push(Promise.resolve().then(() => send({ endpoint: url, events: events.slice(i, i + MAX_EVENTS_PER_REQUEST), hubAccessToken: token })).catch(() => null));
196
+ }
197
+ const done = Promise.all(sends).then(() => undefined, () => undefined);
198
+ inflight.add(done);
199
+ done.then(() => inflight.delete(done));
200
+ return done;
201
+ }
202
+
203
+ return {
204
+ surface,
205
+ begin() {
206
+ seen.clear();
207
+ active = true;
208
+ },
209
+ get active() { return active; },
210
+ setMcpClient(clientName) { mcpClient = mcpClientFor(clientName); },
211
+ track,
212
+ viewed: (step, props) => track('onboarding_step_viewed', step, props),
213
+ completed: (step, props) => track('onboarding_step_completed', step, props),
214
+ skipped: (step, props) => track('onboarding_step_skipped', step, props),
215
+ errored: (step, reason, props = {}) => track('onboarding_step_errored', step, { error_type: errorTypeFor(reason), ...props }),
216
+ identified() {
217
+ const token = safe(hubToken);
218
+ if (!token) return Promise.resolve();
219
+ account = accountOf(token);
220
+ ready = true;
221
+ return flush();
222
+ },
223
+ flush,
224
+ idle: () => Promise.all([...inflight]).then(() => undefined),
225
+ };
226
+ }
227
+
228
+ // A refreshed token keeps the same account.
229
+ function accountOf(token) {
230
+ const payload = require('./auth-session-store').decodeJwtPayload(token);
231
+ return (payload && (payload.sub || payload.email)) || token;
232
+ }
233
+
234
+ function safe(fn) {
235
+ try { return fn(); } catch { return null; }
236
+ }
237
+
238
+ const NOOP_FUNNEL = Object.freeze({
239
+ surface: null,
240
+ begin() {},
241
+ active: false,
242
+ setMcpClient() {},
243
+ track: () => false,
244
+ viewed: () => false,
245
+ completed: () => false,
246
+ skipped: () => false,
247
+ errored: () => false,
248
+ identified: () => Promise.resolve(),
249
+ flush: () => Promise.resolve(),
250
+ idle: () => Promise.resolve(),
251
+ });
252
+
253
+ module.exports = {
254
+ FLOW_VERSION,
255
+ STEP_INDEX,
256
+ EVENT_TYPES,
257
+ SURFACES,
258
+ MCP_CLIENTS,
259
+ SOURCE_TYPES,
260
+ ERROR_TYPES,
261
+ MAX_EVENTS_PER_REQUEST,
262
+ DEVICE_FILE_NAME,
263
+ NOOP_FUNNEL,
264
+ createFunnel,
265
+ mcpClientFor,
266
+ sourceTypeFor,
267
+ workSituationValue,
268
+ weeklyHours,
269
+ rateTypes,
270
+ errorTypeFor,
271
+ loadOrCreateDeviceId,
272
+ };
@@ -113,10 +113,21 @@ async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_T
113
113
  });
114
114
  }
115
115
 
116
+ // `works/users/me` -> the stored phone, null when hub has none.
117
+ async function fetchTelephone(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
118
+ const getEndpoint = deps.getUserMeEndpoint || require('./config').getUserMeEndpoint;
119
+ const req = deps.getMapped || getMapped;
120
+ return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => {
121
+ const t = d && d.telephone;
122
+ return { telephone: t && str(t.code) && str(t.number) ? { code: str(t.code), number: str(t.number) } : null };
123
+ });
124
+ }
125
+
116
126
  module.exports = {
117
127
  requestAiProfile,
118
128
  fetchAiProfile,
119
129
  fetchMeProfile,
120
130
  fetchTalentMeProfile,
121
131
  fetchLanguages,
132
+ fetchTelephone,
122
133
  };
package/src/share.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
 
5
- const { getIngestEndpoint, isTalentProfile } = require('./config');
5
+ const { getIngestEndpoint } = require('./config');
6
6
  const { requestBackend } = require('./backend-request');
7
7
  const { recordBackendAcceptance } = require('./report-store');
8
8
  const {
@@ -53,8 +53,9 @@ async function autoShare(report, maturity, { root, bypassThrottle = false } = {}
53
53
  if (!state.email) {
54
54
  return { ok: false, skipped: true, reason: 'no-email' };
55
55
  }
56
- // ADR-006: never SEND under an email whose ownership wasn't proven.
57
- if (state.emailVerified === false && isTalentProfile()) {
56
+ // ADR-006: never SEND under an email whose ownership wasn't proven — every
57
+ // profile, `external` included (legal requirement), must have verified.
58
+ if (state.emailVerified === false) {
58
59
  return { ok: false, skipped: true, reason: 'email-unverified' };
59
60
  }
60
61
  if (!bypassThrottle && isThrottled(state)) {