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
@@ -6,9 +6,12 @@ const {
6
6
  } = require('./onboarding-flow');
7
7
  const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
8
8
  const { fixSignupLanguage, signupLanguage } = require('./signup-language');
9
+ const path = require('path');
9
10
  const { readLocalCv } = require('./cv-file');
10
11
  const { emailFromHubToken } = require('./auth-session-store');
11
- const { askAfterTexts, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
12
+ const { askAfterTexts, askConsent, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
13
+ const { expandHome, tildify } = require('./env-paths');
14
+ const { NOOP_FUNNEL, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
12
15
 
13
16
  // MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
14
17
 
@@ -45,8 +48,23 @@ const IMPORT_CLOSING = {
45
48
  };
46
49
  const VERBATIM = 'Print relayVerbatim word for word now, as its own block, before any other tool call, with nothing of your own added to it.';
47
50
 
51
+ // Providers that serve the same personal mailboxes under many country domains (hotmail.co.uk, yahoo.com.br, outlook.it).
52
+ const PERSONAL_EMAIL_PROVIDERS = new Set(['gmail', 'googlemail', 'outlook', 'hotmail', 'live', 'msn', 'yahoo', 'ymail', 'icloud', 'proton', 'protonmail', 'gmx', 'aol', 'zoho', 'tutanota', 'libero', 'virgilio', 'tiscali', 'sapo', 'yandex', 'fastmail', 'uol', 'bol', 'terra']);
53
+ // Reserved for documentation and tests (RFC 2606, RFC 6761): never anybody's employer.
54
+ const RESERVED_EMAIL_DOMAIN_RE = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
55
+
56
+ function emailDomain(email) {
57
+ return String(email).split('@').pop().toLowerCase();
58
+ }
59
+
48
60
  function isPersonalEmail(email) {
49
- return PERSONAL_EMAIL_DOMAINS.has(String(email).split('@').pop().toLowerCase());
61
+ const domain = emailDomain(email);
62
+ return PERSONAL_EMAIL_DOMAINS.has(domain) || PERSONAL_EMAIL_PROVIDERS.has(domain.split('.')[0]);
63
+ }
64
+
65
+ // Only an address that looks like a company one gets the hint to prefer a personal email.
66
+ function looksLikeWorkEmail(email) {
67
+ return !isPersonalEmail(email) && !RESERVED_EMAIL_DOMAIN_RE.test(emailDomain(email));
50
68
  }
51
69
 
52
70
  function isLinkedinProfileUrl(url) {
@@ -142,6 +160,7 @@ const WELCOME_SCHEMA = {
142
160
  firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
143
161
  answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
144
162
  cvPath: { type: 'string', description: 'When the talent\'s first message or answer already gives their CV as a path on this machine: that exact file is read, with no CV question, no search and no request for the file.' },
163
+ linkedinMissing: { type: 'boolean', description: 'true only after read_cv, when the CV has no LinkedIn URL: returns the LinkedIn question to print as your whole reply.' },
145
164
  },
146
165
  required: ['language'],
147
166
  };
@@ -157,6 +176,15 @@ const EMAIL_SCHEMA = {
157
176
 
158
177
  // In the order the data notice names them.
159
178
  const DRAFT_SOURCES = ['linkedin', 'cv'];
179
+ const DRAFT_FIELDS = ['name', 'role', 'city', 'country', 'situation', 'participation', 'yearsOfExperience', 'stack', 'languages', 'workMode', 'monthlyHours', 'hourlyRate', 'annualRate'];
180
+ // Draft fields per funnel step, for was_edited.
181
+ const STEP_DRAFT_FIELDS = {
182
+ work_situation: ['situation', 'participation'],
183
+ availability: ['monthlyHours', 'workMode'],
184
+ rates: ['hourlyRate', 'annualRate'],
185
+ languages: ['languages'],
186
+ location: ['country', 'city'],
187
+ };
160
188
 
161
189
  const DRAFT_SCHEMA = {
162
190
  type: 'object',
@@ -165,6 +193,9 @@ const DRAFT_SCHEMA = {
165
193
  name: { type: 'string', description: 'Full name.' },
166
194
  role: { type: 'string', description: 'Role, e.g. "Data Engineer".' },
167
195
  city: { type: 'string' },
196
+ country: { type: 'string', description: 'ISO-2 country code (ES, US, GB): the same country save_profile_details will write.' },
197
+ situation: { type: 'string', enum: WORK_SITUATIONS, description: 'Current work situation, the same code save_profile_details will write.' },
198
+ participation: { type: 'string', enum: EMPLOYMENT_PARTICIPATIONS, description: 'Only when situation is EMPLOYED.' },
168
199
  yearsOfExperience: { type: 'number' },
169
200
  stack: { type: 'string', description: 'Main technologies, comma separated, at most five.' },
170
201
  languages: { type: 'string', description: 'Languages and level, e.g. "Español nativo, inglés C1".' },
@@ -214,6 +245,7 @@ const IMPORT_SCHEMA = {
214
245
  websiteUrl: { type: 'string' },
215
246
  userQuery: { type: 'string', description: 'Everything you know about the talent, as free text.' },
216
247
  language: { type: 'string', enum: [...IMPORT_LANGUAGES] },
248
+ answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned about the CV file. Never set it yourself.' },
217
249
  },
218
250
  };
219
251
 
@@ -272,6 +304,8 @@ function makeSignupTools(deps = {}) {
272
304
  fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
273
305
  fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
274
306
  fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
307
+ funnel = NOOP_FUNNEL,
308
+ fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
275
309
  openBrowser = require('./open-file').openPath,
276
310
  sleep = defaultSleep,
277
311
  now = () => Date.now(),
@@ -284,21 +318,31 @@ function makeSignupTools(deps = {}) {
284
318
  sessionToken: null,
285
319
  email: null,
286
320
  draft: null,
321
+ firstDraft: null,
287
322
  draftConfirmed: false,
288
323
  keepProfile: false,
289
324
  // Fields the update question already told the talent they keep.
290
325
  toldKept: new Set(),
326
+ // The confirmed draft already said the account exists (this machine knew it).
327
+ toldExisting: false,
291
328
  sources: null,
292
329
  account: { state: 'not-started' },
293
330
  reported: { account: null, import: null },
294
331
  failedSources: new Set(),
295
332
  cvPath: null,
296
333
  wake: null,
334
+ importStartedAt: null,
335
+ importReported: new Set(),
297
336
  };
298
337
  currentSignup = state;
299
338
 
300
339
  function setAccount(next) {
340
+ const before = state.account.state;
301
341
  state.account = next;
342
+ if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
343
+ funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
344
+ funnel.identified();
345
+ }
302
346
  if (state.wake) state.wake();
303
347
  }
304
348
 
@@ -398,42 +442,60 @@ function makeSignupTools(deps = {}) {
398
442
  return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
399
443
  }
400
444
 
401
- // Step 1: welcome, value proposal, the three steps and one question (may I look for your CV here?).
445
+ // Step 1: welcome, value proposal, the three steps and one question (send me your CV, or shall I look for it here?).
402
446
  async function signupStart(args = {}, ctx = {}) {
447
+ // The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
448
+ if (args.linkedinMissing === true && !nonEmpty(args.answer)) {
449
+ const c = copy();
450
+ return {
451
+ ok: true,
452
+ step: 'linkedin-ask',
453
+ say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
454
+ linkedinOpening: c.linkedinOpening,
455
+ message: 'Print `say` word for word as your whole reply, with nothing of your own before or after it, and call no tool until the talent answers. With their URL, call signup_email. Only if they say they do not know where to find it, say `linkedinOpening` word for word and call open_linkedin_profile.',
456
+ };
457
+ }
403
458
  if (!nonEmpty(args.answer)) {
404
459
  fixSignupLanguage(args.language);
460
+ funnel.begin();
461
+ funnel.viewed('start');
405
462
  state.draft = null;
463
+ state.firstDraft = null;
464
+ state.importStartedAt = null;
465
+ state.importReported = new Set();
406
466
  state.draftConfirmed = false;
407
467
  state.email = null;
408
468
  state.failedSources.clear();
409
469
  state.toldKept = new Set();
470
+ state.toldExisting = false;
410
471
  state.cvPath = null;
411
472
  takeNotices();
412
473
  }
413
474
  if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
414
475
  const c = copy();
415
- const linkedin = `LinkedIn: use the URL in the CV (read_cv returns links.linkedin). Only if it has none, ask \`linkedinAsk\` word for word; only if they say they do not know where to find it, say \`linkedinOpening\` and call open_linkedin_profile. Then call signup_email.`;
416
- // A CV path in the first message already answers the CV question: welcome only, then that CV.
476
+ const linkedin = 'LinkedIn: use the URL in the CV (read_cv returns links.linkedin) and call signup_email. Only if the CV has none, call signup_start with linkedinMissing:true: it returns the LinkedIn question to print. Never ask for LinkedIn in your own words.';
477
+ // A CV path in the first message already answers the CV question: that CV is read, and the welcome
478
+ // rides on the next question block, because models skip a relay that comes before another tool call.
417
479
  if (!nonEmpty(args.answer) && state.cvPath) {
480
+ funnel.completed('start');
481
+ queueNotice(c.welcome(nonEmpty(args.firstName)));
418
482
  return {
419
483
  ok: true,
420
484
  step: 'cv-given',
421
485
  cvPath: state.cvPath,
422
- relayVerbatim: c.welcome(nonEmpty(args.firstName)),
423
- linkedinAsk: c.linkedinAsk,
424
- linkedinOpening: c.linkedinOpening,
425
486
  language: lang(),
426
- message: `${VERBATIM} Then read_cv cvPath with talentConsent:true, asking nothing about the CV. ${linkedin}`,
487
+ message: `Never explain to the talent what you are doing or what the CV holds: every text they read in this step comes from the tools, the welcome included. Now call read_cv with cvPath: it asks the talent itself before reading the file, so ask nothing about the CV in your own words. ${linkedin}`,
427
488
  };
428
489
  }
429
490
  const asked = await askAfterTexts(ctx, {
430
491
  tool: 'signup_start',
431
492
  texts: [c.welcome(nonEmpty(args.firstName))],
432
493
  question: c.welcomeQuestion,
433
- options: [c.cvSearch, c.cvAttach],
494
+ options: [c.cvAttach, c.cvSearch],
434
495
  answer: args.answer,
435
496
  });
436
497
  if (asked.reply) return { ...asked.reply, language: lang() };
498
+ funnel.completed('start');
437
499
  // A CV path the talent gave wins over both answers: searching could read another candidate.
438
500
  const cvPath = state.cvPath;
439
501
  if (cvPath) {
@@ -441,9 +503,7 @@ function makeSignupTools(deps = {}) {
441
503
  ok: true,
442
504
  step: 'cv-given',
443
505
  cvPath,
444
- linkedinAsk: c.linkedinAsk,
445
- linkedinOpening: c.linkedinOpening,
446
- message: `The talent already gave their CV: read_cv it now with talentConsent:true, saying nothing about attaching it. ${linkedin}`,
506
+ message: `The talent already gave their CV: call read_cv with cvPath now (it asks the talent itself before reading the file), saying nothing about attaching it. ${linkedin}`,
447
507
  };
448
508
  }
449
509
  if (asked.answer === c.cvSearch) {
@@ -451,17 +511,13 @@ function makeSignupTools(deps = {}) {
451
511
  ok: true,
452
512
  step: 'cv-search',
453
513
  cvNotFound: c.cvNotFound,
454
- linkedinAsk: c.linkedinAsk,
455
- linkedinOpening: c.linkedinOpening,
456
- message: `The talent allowed you to look for their CV: call suggest_register_context, then read_cv on its first candidate without asking again, even when nameMatch is null (their name is just unknown). Only if there is no candidate, or every one comes back mentionsTalent:false, show \`cvNotFound\` word for word and wait for the file. ${linkedin}`,
514
+ message: `The talent chose to look for their CV on this computer: call suggest_register_context, then read_cv on its first candidate, even when nameMatch is null (their name is just unknown). Each of them asks the talent itself before searching or reading, so ask nothing in your own words. Only if there is no candidate, or every one comes back mentionsTalent:false, show \`cvNotFound\` word for word and wait for the file. ${linkedin}`,
457
515
  };
458
516
  }
459
517
  return {
460
518
  ok: true,
461
519
  step: 'cv-attach',
462
520
  relayVerbatim: c.cvAttachAsk,
463
- linkedinAsk: c.linkedinAsk,
464
- linkedinOpening: c.linkedinOpening,
465
521
  message: `If their message already holds the CV (attached, pasted or as a path), do not print relayVerbatim: use that CV. Otherwise ${VERBATIM} Then wait for the CV in the chat. ${linkedin}`,
466
522
  };
467
523
  }
@@ -478,7 +534,7 @@ function makeSignupTools(deps = {}) {
478
534
  if (!email) {
479
535
  return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} It is the whole question. Then call signup_email with email set to what they write and typed:true.` };
480
536
  }
481
- const question = isPersonalEmail(email) ? c.emailKnown(email) : `${c.emailKnown(email)} ${c.emailWorkHint}`;
537
+ const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
482
538
  const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
483
539
  if (asked.reply) return asked.reply;
484
540
  if (asked.answer === c.emailYes) {
@@ -501,9 +557,10 @@ function makeSignupTools(deps = {}) {
501
557
  const pricing = pricingFromRates(args);
502
558
  if (!pricing.ok) return pricing;
503
559
  const draft = {};
504
- for (const key of ['name', 'role', 'city', 'yearsOfExperience', 'stack', 'languages', 'workMode', 'monthlyHours', 'hourlyRate', 'annualRate']) {
560
+ for (const key of DRAFT_FIELDS) {
505
561
  if (args[key] !== undefined && args[key] !== null && args[key] !== '') draft[key] = args[key];
506
562
  }
563
+ if (typeof draft.country === 'string') draft.country = draft.country.trim().toUpperCase();
507
564
  if (!draft.name || !draft.role) return { ok: false, reason: 'draft-incomplete', message: 'The draft needs at least name and role: read the CV first.' };
508
565
  if (draft.hourlyRate === undefined || draft.annualRate === undefined) {
509
566
  return { ok: false, reason: 'draft-rates-required', message: 'The draft needs both rates: if the talent gave them, send them with ratesFromTalent:true; otherwise estimate hourlyRate (€/h) and annualRate (€/year) from role, seniority, stack and location, and send them without it. Then call signup_draft again.' };
@@ -514,6 +571,7 @@ function makeSignupTools(deps = {}) {
514
571
  if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
515
572
  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." };
516
573
  }
574
+ if (!state.firstDraft) state.firstDraft = draft;
517
575
  const asked = await askAfterTexts(ctx, {
518
576
  tool: 'signup_draft',
519
577
  texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
@@ -528,6 +586,7 @@ function makeSignupTools(deps = {}) {
528
586
  }
529
587
  state.draft = draft;
530
588
  state.draftConfirmed = true;
589
+ state.toldExisting = knownAccount();
531
590
  return {
532
591
  ok: true,
533
592
  next: 'signup_create_account',
@@ -592,7 +651,16 @@ function makeSignupTools(deps = {}) {
592
651
  state.sources = sources;
593
652
  state.reported = { account: 'waiting', import: 'none' };
594
653
  const reused = state.account.state === 'waiting';
595
- if (!reused) await openWindow(identity);
654
+ if (!state.importStartedAt) {
655
+ const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
656
+ state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
657
+ for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
658
+ funnel.completed('import_sources', { sources_attempted: attempted });
659
+ }
660
+ if (!reused) {
661
+ funnel.viewed('account_creation');
662
+ await openWindow(identity);
663
+ }
596
664
  return {
597
665
  ok: true,
598
666
  status: 'window-open',
@@ -603,23 +671,45 @@ function makeSignupTools(deps = {}) {
603
671
  };
604
672
  }
605
673
 
674
+ function trackImportOutcome(imp) {
675
+ if (!state.importStartedAt) return;
676
+ let tracked = false;
677
+ for (const [source, startedAt] of Object.entries(state.importStartedAt)) {
678
+ if (state.importReported.has(source)) continue;
679
+ const outcome = imp.sources && imp.sources[source] ? imp.sources[source].state : (imp.state === 'failed' ? 'failed' : null);
680
+ if (outcome !== 'done' && outcome !== 'failed') continue;
681
+ state.importReported.add(source);
682
+ const code = imp.sources && imp.sources[source] && imp.sources[source].code ? imp.sources[source].code : imp.code;
683
+ funnel.track(outcome === 'done' ? 'source_import_completed' : 'source_import_failed', 'import_sources', {
684
+ source_type: source,
685
+ duration_ms: Math.max(0, now() - startedAt),
686
+ ...(outcome === 'failed' && code ? { failure_reason: code } : {}),
687
+ });
688
+ tracked = true;
689
+ }
690
+ if (tracked) funnel.flush();
691
+ }
692
+
606
693
  async function readImport() {
607
694
  // An account that already existed got no import from this sign-up: its last import is not news.
608
695
  if (state.account.state === 'existing') return { state: 'none' };
609
696
  const session = resolved.loadAuthSession();
610
697
  if (resolved.sessionStatus(session) !== 'active' || !session || !session.hubAccessToken) return { state: 'none' };
611
698
  const res = await requestMyImportStatus({ hubAccessToken: session.hubAccessToken }, { endpoint: getMyImportStatusEndpoint() });
612
- return res.ok ? { state: res.state, sources: res.sources, ...(res.code ? { code: res.code } : {}) } : { state: 'unknown', reason: res.reason };
699
+ if (!res.ok) return { state: 'unknown', reason: res.reason };
700
+ const imp = { state: res.state, sources: res.sources, ...(res.code ? { code: res.code } : {}) };
701
+ trackImportOutcome(imp);
702
+ return imp;
613
703
  }
614
704
 
615
705
  function nextSteps(account) {
616
- const interview = 'Then the onboarding interview: if onboardingInterview.completed is true do not offer 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 20), then set_main_role without clusterId only when its next says so. Finally open_web.';
706
+ 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 20), then set_main_role without clusterId only when its next says so. Finally open_web.';
617
707
  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).';
618
708
  if (account.state === 'existing') {
619
- return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update. ${aiUsage} ${interview}`;
709
+ 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}`;
620
710
  }
621
711
  const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
622
- return `The account exists.${unclaimed} Now, without asking: save_profile_details with the confirmed draft (situation, motivation, both rates, availability and location, languages). ${aiUsage} ${interview}`;
712
+ return `The account exists.${unclaimed} Now, without asking: save_profile_details with the confirmed draft (situation, motivation, both rates, availability and location with its country, languages). ${aiUsage} ${interview}`;
623
713
  }
624
714
 
625
715
  // A source that did not import gets its fixed line once, on the next question block.
@@ -672,6 +762,19 @@ function makeSignupTools(deps = {}) {
672
762
  return out;
673
763
  }
674
764
 
765
+ // The tool: a CV file from this computer is sent only after the talent says yes to that file.
766
+ async function importProfileAsked(args = {}, ctx = {}) {
767
+ if (nonEmpty(args.cvPath)) {
768
+ const file = path.resolve(expandHome(nonEmpty(args.cvPath)));
769
+ const c = copy().consent;
770
+ const asked = await askConsent(ctx, { tool: 'import_profile', key: `import_profile:${file}`, question: c.cvSend(tildify(file)), yes: c.yes, no: c.no, answer: args.answer });
771
+ if (asked.reply) return asked.reply;
772
+ if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was sent. Do not send that file; call import_profile again without cvPath if the other sources still need importing.' };
773
+ return importProfile({ ...args, cvPath: file });
774
+ }
775
+ return importProfile(args);
776
+ }
777
+
675
778
  async function importProfile(args = {}) {
676
779
  if (nonEmpty(args.linkedinUrl) && !isLinkedinProfileUrl(nonEmpty(args.linkedinUrl))) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
677
780
  const res = await flow.startProfileImport(resolved, {
@@ -685,7 +788,7 @@ function makeSignupTools(deps = {}) {
685
788
  });
686
789
  if (!res.ok) {
687
790
  if (res.reason === 'no-session') {
688
- return { ok: false, reason: isSignupPending() ? 'signup-pending' : 'no-session', message: isSignupPending() ? 'The account does not exist yet: wait for signup_status to report it.' : 'The talent is not signed in: use login first.' };
791
+ return { ok: false, reason: isSignupPending() ? 'signup-pending' : 'no-session', message: isSignupPending() ? 'The account does not exist yet: wait for signup_status to report it.' : 'The talent is not signed in: sign them in with social_signin_start first.' };
689
792
  }
690
793
  return { ok: false, reason: res.reason };
691
794
  }
@@ -748,7 +851,7 @@ function makeSignupTools(deps = {}) {
748
851
  state.toldKept = kept;
749
852
  const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
750
853
  if (given.length) lines.push(c.existingLists(given));
751
- return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
854
+ return `${state.toldExisting ? c.existingUpdateQuestion : c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
752
855
  }
753
856
 
754
857
  // The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
@@ -844,6 +947,40 @@ function makeSignupTools(deps = {}) {
844
947
  return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
845
948
  }
846
949
 
950
+ function wasEdited(step) {
951
+ if (!state.firstDraft || !state.draft) return undefined;
952
+ return STEP_DRAFT_FIELDS[step].some((key) => String(state.firstDraft[key] ?? '') !== String(state.draft[key] ?? ''));
953
+ }
954
+
955
+ function trackSavedSteps(details, results) {
956
+ const saved = (section) => results[section] && results[section].ok === true;
957
+ const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name) });
958
+ if (saved('workSituation')) {
959
+ step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
960
+ }
961
+ if (saved('availability')) {
962
+ const a = details.availability;
963
+ step('availability', { availability_hours_per_week: weeklyHours(a.monthlyHours) });
964
+ const country = typeof a.country === 'string' && /^[A-Za-z]{2}$/.test(a.country.trim()) ? a.country.trim().toUpperCase() : null;
965
+ const proposed = state.firstDraft && state.firstDraft.country;
966
+ const modes = Array.isArray(a.workModes) ? a.workModes.map((m) => String(m).toUpperCase()) : [];
967
+ step('location', {
968
+ location_country: country ? country.toLowerCase() : undefined,
969
+ location_method: country && proposed === country ? 'prefilled' : 'manual',
970
+ is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
971
+ });
972
+ }
973
+ if (saved('pricing')) {
974
+ const types = rateTypes(pricingFromRates(details.pricing).input);
975
+ 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') });
976
+ }
977
+ if (saved('languages')) {
978
+ const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
979
+ step('languages', { languages_count: codes.length, languages: codes });
980
+ }
981
+ funnel.flush();
982
+ }
983
+
847
984
  async function saveProfileDetails(args = {}) {
848
985
  const { saved } = copy();
849
986
  const existing = state.account.state === 'existing';
@@ -858,17 +995,27 @@ function makeSignupTools(deps = {}) {
858
995
  ['availability', details.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
859
996
  ['languages', Array.isArray(details.languages) && details.languages.length ? details.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
860
997
  ['phone', details.phone, (v) => flow.savePhone(resolved, v), saved.phone],
861
- ];
998
+ ].filter(([, value]) => value && typeof value === 'object');
999
+ // Hub's async profile-completion recalculation saves the whole users row from a copy it read earlier, so a
1000
+ // phone written while it runs comes back NULL: the phone goes first, and is read back once the rest is saved.
1001
+ const phoneFirst = [...sections.filter(([name]) => name === 'phone'), ...sections.filter(([name]) => name !== 'phone')];
862
1002
  const results = {};
863
- const confirmations = [];
864
- for (const [name, value, save, saved] of sections) {
865
- if (!value || typeof value !== 'object') continue;
1003
+ for (const [name, value, save] of phoneFirst) {
866
1004
  const res = await save(value);
867
- if (res.ok) confirmations.push(saved);
868
1005
  if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
869
1006
  const help = res.message || SECTION_HELP[res.reason];
870
1007
  results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(help ? { message: help } : {}) };
871
1008
  }
1009
+ if (results.phone && results.phone.ok) {
1010
+ const session = resolved.loadAuthSession();
1011
+ const stored = await fetchTelephone({ hubAccessToken: session && session.hubAccessToken }).catch(() => ({ ok: false }));
1012
+ if (stored.ok && !stored.telephone) {
1013
+ const again = await flow.savePhone(resolved, details.phone);
1014
+ results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
1015
+ }
1016
+ }
1017
+ trackSavedSteps(details, results);
1018
+ const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
872
1019
  const ok = Object.values(results).every((r) => r.ok);
873
1020
  const report = keptReport(kept);
874
1021
  if (report.line) queueNotice(report.line);
@@ -898,32 +1045,32 @@ function makeSignupTools(deps = {}) {
898
1045
  if (!url) return { ok: false, reason: 'no-endpoint' };
899
1046
  // The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
900
1047
  const opened = args.open !== false && openBrowser(url) !== false;
1048
+ // Read right before the closing words, so they never state an import state that is already stale.
1049
+ const imp = await readImport();
1050
+ // A source that failed after the last signup_status gets its fixed line here, as there is no question left.
1051
+ const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
1052
+ noticeFailed(failed);
901
1053
  // The flow ends here, so a fixed line no question carried is printed now.
902
1054
  const unseen = takeNotices();
903
1055
  const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
904
- const relayFirst = unseen.length ? `${VERBATIM} ` : '';
905
- // Read right before the closing words, so they never state an import state that is already stale.
906
- const imp = await readImport();
907
- const importLine = IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
908
- if (opened) {
909
- return {
910
- ok: true,
911
- opened: true,
912
- loggedIn,
913
- import: { state: imp.state },
914
- ...relay,
915
- message: relayFirst + (loggedIn ? 'Shakers opened in the talent\'s browser, signed in.' : 'Shakers opened in the talent\'s browser at the sign-in page; they sign in there.')
916
- + ` If they say it did not open, call open_web with open:false and give them its link. ${importLine}`,
917
- };
918
- }
1056
+ const importLine = failed.length
1057
+ ? `The import of ${failed.join(' and ')} failed and its fixed line is the only thing to say about it: never say or imply it was imported; if you mention the import, name only what did import.`
1058
+ : IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
1059
+ const where = opened
1060
+ ? `Shakers is open in their browser${loggedIn ? ', already signed in' : ' at the sign-in page, where they sign in'}`
1061
+ : `they open Shakers from \`link\` (give it to click, with no comment on the address${loggedIn ? '; it works once, for 2 minutes' : ''})`;
1062
+ const closing = `one short closing sentence of your own, in the talent's language, saying ${where}.`;
1063
+ const reply = unseen.length
1064
+ ? `Your reply has two parts and needs both: first relayVerbatim word for word as its own block, then ${closing}`
1065
+ : `End with ${closing}`;
919
1066
  return {
920
1067
  ok: true,
921
- opened: false,
1068
+ opened,
922
1069
  loggedIn,
923
- link: url,
1070
+ ...(opened ? {} : { link: url }),
924
1071
  import: { state: imp.state },
925
1072
  ...relay,
926
- message: `${relayFirst}Give the talent \`link\` to click, with no comment on the address.${loggedIn ? ' It works once, for 2 minutes.' : ''} ${importLine}`,
1073
+ message: `${reply}${opened ? ' If they say it did not open, call open_web with open:false and give them its link.' : ''} ${importLine}`,
927
1074
  };
928
1075
  }
929
1076
 
@@ -940,65 +1087,85 @@ function makeSignupTools(deps = {}) {
940
1087
  return [
941
1088
  {
942
1089
  name: 'signup_start',
943
- description: "FIRST call when the talent asks to sign up or register on Shakers, before you write anything: pass `language` = the language of their message. It returns the welcome to show word for word and its one question (may I look for your CV on this computer?). Then follow its message: CV, LinkedIn, signup_email, signup_draft, signup_create_account, signup_status. Every text it and the next sign-up tools return in `say` or relayVerbatim is printed word for word, in that language.",
1090
+ title: 'Start your Shakers sign-up',
1091
+ description: 'Starts your sign-up here in the chat and asks whether you send your CV or want it looked for on this computer.',
1092
+ guidance: "FIRST call when the talent asks to sign up or register on Shakers, before you write anything: pass `language` = the language of their message. It returns the welcome to show word for word and its one question (send me your CV, or shall I look for it on this computer?). Then follow its message: CV, LinkedIn, signup_email, signup_draft, signup_create_account, signup_status. Every text it and the next sign-up tools return in `say` or relayVerbatim is printed word for word, in that language.",
944
1093
  inputSchema: WELCOME_SCHEMA,
945
1094
  handler: signupStart,
946
1095
  },
947
1096
  {
948
1097
  name: 'signup_email',
949
- description: 'Sign-up step after the CV and LinkedIn: asks which email to create the account with (the one they read most). Pass the best email you know, or none. Never comment on the email yourself.',
1098
+ title: 'Choose your account email',
1099
+ description: 'Asks which email your Shakers account will use.',
1100
+ guidance: 'Sign-up step after the CV and LinkedIn: asks which email to create the account with (the one they read most). Pass the best email you know, or none. Never comment on the email yourself.',
950
1101
  inputSchema: EMAIL_SCHEMA,
951
1102
  handler: signupEmail,
952
1103
  },
953
1104
  {
954
1105
  name: 'signup_draft',
955
- description: 'Sign-up step before the account: shows the talent the profile draft you prepared from their CV, LinkedIn and what you know, with the data notice and how the account window works, and asks them to confirm it. The window to create the account opens only after they confirm.',
1106
+ title: 'Review your profile draft',
1107
+ description: 'Shows the profile draft prepared from your CV and LinkedIn, explains how your data is used and asks you to confirm it. Your account is not created until you confirm.',
1108
+ guidance: 'Sign-up step before the account: shows the talent the profile draft you prepared from their CV, LinkedIn and what you know, with the data notice and how the account window works, and asks them to confirm it. The window to create the account opens only after they confirm.',
956
1109
  inputSchema: DRAFT_SCHEMA,
957
1110
  handler: signupDraft,
958
1111
  },
959
1112
  {
960
1113
  name: 'signup_create_account',
961
- description: "After the talent confirmed the draft: starts the profile import on Shakers and, a few seconds later, opens a window in the talent's browser where they create the account with Google or a password, or sign in if they already have one. No password ever goes through the chat. Returns next: signup_status.",
1114
+ title: 'Create your account',
1115
+ description: 'Once you confirm the draft, sends your CV, LinkedIn and draft to Shakers and opens a browser window where you create your account with Google or a password. Your password never goes through the chat.',
1116
+ guidance: "After the talent confirmed the draft: starts the profile import on Shakers and, a few seconds later, opens a window in the talent's browser where they create the account with Google or a password, or sign in if they already have one. No password ever goes through the chat. Returns next: signup_status.",
962
1117
  inputSchema: CREATE_SCHEMA,
963
1118
  handler: signupCreateAccount,
964
1119
  },
965
1120
  {
966
1121
  name: 'signup_status',
967
- description: "State of the sign-up: account (waiting | created | existing | failed) and, once it exists, the profile import per source (linkedin, cv: running | done | failed). Pass waitSeconds (up to 25) to wait for a change. Follow `message`: it gives the remaining steps. Never show internal codes to the talent.",
1122
+ title: 'Sign-up progress',
1123
+ description: 'Shows whether your account is ready and how the import of your profile is going.',
1124
+ guidance: "State of the sign-up: account (waiting | created | existing | failed) and, once it exists, the profile import per source (linkedin, cv: running | done | failed). Pass waitSeconds (up to 25) to wait for a change. Follow `message`: it gives the remaining steps. Never show internal codes to the talent.",
968
1125
  inputSchema: STATUS_SCHEMA,
969
1126
  handler: signupStatus,
970
1127
  },
971
1128
  {
972
1129
  name: 'update_existing_profile',
973
- description: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and the value each field they already had keeps, and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
1130
+ title: 'Update your existing profile',
1131
+ description: 'If you already had an account, shows what your draft would add to the empty fields of your profile and asks before changing anything. It never overwrites what you already filled in.',
1132
+ guidance: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and the value each field they already had keeps, and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
974
1133
  inputSchema: UPDATE_EXISTING_SCHEMA,
975
1134
  handler: updateExistingProfile,
976
1135
  },
977
1136
  {
978
1137
  name: 'import_profile',
979
- description: "Import LinkedIn, CV, GitHub or website into the SIGNED-IN talent's profile, filling only what is empty: existing texts and languages are kept, lists are added without duplicates. Use it when the sign-up import failed for a source, or for a non-PDF CV. Takes up to ~90 s.",
1138
+ title: 'Import into your profile',
1139
+ description: 'Sends your LinkedIn, GitHub or website to Shakers to fill the empty parts of your profile, and a CV file from this computer only after asking you first. What you already wrote is kept.',
1140
+ guidance: "Import LinkedIn, CV, GitHub or website into the SIGNED-IN talent's profile, filling only what is empty: existing texts and languages are kept, lists are added without duplicates. Use it when the sign-up import failed for a source, or for a non-PDF CV. With cvPath the tool first asks the talent, naming the file (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is sent until they answer yes. After a `say`, call it again with the same arguments and answer set to the option they pick. Never answer for them. Takes up to ~90 s.",
980
1141
  inputSchema: IMPORT_SCHEMA,
981
- handler: importProfile,
1142
+ handler: importProfileAsked,
982
1143
  },
983
1144
  {
984
1145
  name: 'save_profile_details',
985
- description: "After the account exists, write the confirmed draft — work situation and motivation, hourly rate and annual target, availability and location, languages, phone — WITHOUT asking again: they can edit every field on the web. Each section is saved on its own; returns per-section results. On an account that already existed it only fills what the profile leaves empty, never overwrites a value the talent set.",
1146
+ title: 'Save your profile details',
1147
+ description: 'Saves the details you confirmed in your draft (work situation, rates, availability, location, languages and phone) to your Shakers profile. On an existing account it only fills empty fields.',
1148
+ guidance: "After the account exists, write the confirmed draft — work situation and motivation, hourly rate and annual target, availability and location, languages, phone — WITHOUT asking again: they can edit every field on the web. Each section is saved on its own; returns per-section results. On an account that already existed it only fills what the profile leaves empty, never overwrites a value the talent set.",
986
1149
  inputSchema: buildDetailsSchema(deps.lang || 'en'),
987
1150
  handler: saveProfileDetails,
988
1151
  },
989
1152
  {
990
1153
  name: 'open_web',
991
- description: "Open Shakers in the talent's browser already signed in (one-time link, valid 2 minutes, single use). Use it at the end of the sign-up, or when the onboarding interview has to be done on the web. Returns { opened, loggedIn }, plus `link` only when the browser was not opened.",
1154
+ title: 'Open Shakers in your browser',
1155
+ description: 'Opens Shakers in your browser, already signed in.',
1156
+ guidance: "Open Shakers in the talent's browser already signed in (one-time link, valid 2 minutes, single use). Use it at the end of the sign-up, or when the onboarding interview has to be done on the web. Returns { opened, loggedIn }, plus `link` only when the browser was not opened.",
992
1157
  inputSchema: { type: 'object', properties: { open: { type: 'boolean', description: 'false returns a fresh link without opening the browser: only when the talent says the browser did not open.' } } },
993
1158
  handler: openWeb,
994
1159
  },
995
1160
  {
996
1161
  name: 'open_linkedin_profile',
997
- description: "Open the talent's own LinkedIn profile in their browser (linkedin.com/in/me redirects to it) so they copy the address bar. Use it ONLY when the talent says they do not know where to find their LinkedIn URL, and only after showing them, word for word, the `linkedinOpening` text signup_start returned.",
1162
+ title: 'Open your LinkedIn profile',
1163
+ description: 'Opens your own LinkedIn profile in your browser so you can copy its address.',
1164
+ guidance: "Open the talent's own LinkedIn profile in their browser (linkedin.com/in/me redirects to it) so they copy the address bar. Use it ONLY when the talent says they do not know where to find their LinkedIn URL, and only after showing them, word for word, the `linkedinOpening` text signup_start returned.",
998
1165
  inputSchema: { type: 'object', properties: {} },
999
1166
  handler: openLinkedinProfile,
1000
1167
  },
1001
1168
  ];
1002
1169
  }
1003
1170
 
1004
- module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
1171
+ module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
@@ -14,7 +14,7 @@ function makeSocialsTools(deps = {}) {
14
14
  const requireLoginSession = () => {
15
15
  const session = loadAuthSession();
16
16
  if (sessionStatus(session) !== 'active') {
17
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (list_socials is session-gated).');
17
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (list_socials is session-gated).');
18
18
  }
19
19
  return session;
20
20
  };
@@ -29,7 +29,9 @@ function makeSocialsTools(deps = {}) {
29
29
  return [
30
30
  {
31
31
  name: 'list_socials',
32
- description: "View the talent's social links (linkedin/github/website/twitter/instagram/facebook/dribbble/behance — null when unset). Read-only. Requires an active session.",
32
+ title: 'Your social links',
33
+ description: 'Shows the social and web links on your Shakers profile.',
34
+ guidance: "View the talent's social links (linkedin/github/website/twitter/instagram/facebook/dribbble/behance — null when unset). Read-only. Requires an active session.",
33
35
  inputSchema: LIST_SOCIALS_SCHEMA,
34
36
  handler: listSocials,
35
37
  },