shakers-cli-beta 0.11.0 → 0.13.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.
@@ -8,7 +8,7 @@ const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
8
8
  const { fixSignupLanguage, signupLanguage } = require('./signup-language');
9
9
  const { readLocalCv } = require('./cv-file');
10
10
  const { emailFromHubToken } = require('./auth-session-store');
11
- const { askAfterTexts, queueNotice, takeNotices } = require('./mcp-choice');
11
+ const { askAfterTexts, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
12
12
 
13
13
  // MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
14
14
 
@@ -36,10 +36,32 @@ const SECTION_HELP = {
36
36
  'bad-opinion': 'workSituation.opinion is required unless situation is FREELANCE: pick your best match from its enum.',
37
37
  'bad-motivation': 'workSituation.changeMotivators is required when situation is FREELANCE or opinion is WAS_FREELANCE_BEFORE or OPEN_TO_FREELANCE: pick your best match from its enum.',
38
38
  };
39
+ // What the closing words may say about the profile import, by the state read at that moment.
40
+ const IMPORT_CLOSING = {
41
+ done: 'The profile import finished: if you mention it, say it is done, never that it is pending.',
42
+ running: 'The profile import is still running: if you mention it, say it finishes on its own in a few minutes.',
43
+ failed: 'Say nothing more about the profile import than the fixed lines already shown.',
44
+ none: 'Say nothing about the state of the profile import.',
45
+ };
39
46
  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.';
40
47
 
48
+ // Providers that serve the same personal mailboxes under many country domains (hotmail.co.uk, yahoo.com.br, outlook.it).
49
+ 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']);
50
+ // Reserved for documentation and tests (RFC 2606, RFC 6761): never anybody's employer.
51
+ const RESERVED_EMAIL_DOMAIN_RE = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
52
+
53
+ function emailDomain(email) {
54
+ return String(email).split('@').pop().toLowerCase();
55
+ }
56
+
41
57
  function isPersonalEmail(email) {
42
- return PERSONAL_EMAIL_DOMAINS.has(String(email).split('@').pop().toLowerCase());
58
+ const domain = emailDomain(email);
59
+ return PERSONAL_EMAIL_DOMAINS.has(domain) || PERSONAL_EMAIL_PROVIDERS.has(domain.split('.')[0]);
60
+ }
61
+
62
+ // Only an address that looks like a company one gets the hint to prefer a personal email.
63
+ function looksLikeWorkEmail(email) {
64
+ return !isPersonalEmail(email) && !RESERVED_EMAIL_DOMAIN_RE.test(emailDomain(email));
43
65
  }
44
66
 
45
67
  function isLinkedinProfileUrl(url) {
@@ -134,7 +156,8 @@ const WELCOME_SCHEMA = {
134
156
  language: { type: 'string', description: "ISO code of the language the talent wrote their first message in (es, en, it, pt...). It fixes the language of every sign-up text from here on." },
135
157
  firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
136
158
  answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
137
- cvPath: { type: 'string', description: 'When the talent\'s answer (either option) already gives their CV as a path on this machine: that exact file is read, with no search and no request for the file.' },
159
+ 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.' },
160
+ 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.' },
138
161
  },
139
162
  required: ['language'],
140
163
  };
@@ -148,6 +171,9 @@ const EMAIL_SCHEMA = {
148
171
  },
149
172
  };
150
173
 
174
+ // In the order the data notice names them.
175
+ const DRAFT_SOURCES = ['linkedin', 'cv'];
176
+
151
177
  const DRAFT_SCHEMA = {
152
178
  type: 'object',
153
179
  description: 'Your draft of the profile, from the CV, LinkedIn and what you know. Every field is shown as you write it, in the talent\'s language.',
@@ -163,9 +189,10 @@ const DRAFT_SCHEMA = {
163
189
  hourlyRate: { type: 'number', description: `Euros per hour (part-time projects), at most ${MAX_HOURLY_RATE}: the talent's figure, else your estimate from role, seniority, stack and location.` },
164
190
  annualRate: { type: 'number', description: `Euros per year (full-time projects), at least ${MIN_ANNUAL_RATE}: the talent's figure, else your estimate.` },
165
191
  ratesFromTalent: { type: 'boolean', description: 'true only when the talent gave you both rates; omit it when you estimated them (they are shown as your estimate).' },
192
+ sources: { type: 'array', items: { type: 'string', enum: DRAFT_SOURCES }, description: "What the draft comes from: 'cv' when you read their CV, 'linkedin' only when you have their LinkedIn URL. The data notice names exactly these." },
166
193
  answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
167
194
  },
168
- required: ['name', 'role', 'hourlyRate', 'annualRate'],
195
+ required: ['name', 'role', 'hourlyRate', 'annualRate', 'sources'],
169
196
  };
170
197
 
171
198
  const CREATE_SCHEMA = {
@@ -261,6 +288,7 @@ function makeSignupTools(deps = {}) {
261
288
  fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
262
289
  fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
263
290
  fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
291
+ fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
264
292
  openBrowser = require('./open-file').openPath,
265
293
  sleep = defaultSleep,
266
294
  now = () => Date.now(),
@@ -275,6 +303,10 @@ function makeSignupTools(deps = {}) {
275
303
  draft: null,
276
304
  draftConfirmed: false,
277
305
  keepProfile: false,
306
+ // Fields the update question already told the talent they keep.
307
+ toldKept: new Set(),
308
+ // The confirmed draft already said the account exists (this machine knew it).
309
+ toldExisting: false,
278
310
  sources: null,
279
311
  account: { state: 'not-started' },
280
312
  reported: { account: null, import: null },
@@ -385,28 +417,53 @@ function makeSignupTools(deps = {}) {
385
417
  return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
386
418
  }
387
419
 
388
- // Step 1: welcome, value proposal, the three steps and one question (may I look for your CV here?).
420
+ // Step 1: welcome, value proposal, the three steps and one question (send me your CV, or shall I look for it here?).
389
421
  async function signupStart(args = {}, ctx = {}) {
422
+ // The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
423
+ if (args.linkedinMissing === true && !nonEmpty(args.answer)) {
424
+ const c = copy();
425
+ return {
426
+ ok: true,
427
+ step: 'linkedin-ask',
428
+ say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
429
+ linkedinOpening: c.linkedinOpening,
430
+ 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.',
431
+ };
432
+ }
390
433
  if (!nonEmpty(args.answer)) {
391
434
  fixSignupLanguage(args.language);
392
435
  state.draft = null;
393
436
  state.draftConfirmed = false;
394
437
  state.email = null;
395
438
  state.failedSources.clear();
439
+ state.toldKept = new Set();
440
+ state.toldExisting = false;
396
441
  state.cvPath = null;
397
442
  takeNotices();
398
443
  }
399
444
  if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
400
445
  const c = copy();
446
+ 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.';
447
+ // A CV path in the first message already answers the CV question: that CV is read, and the welcome
448
+ // rides on the next question block, because models skip a relay that comes before another tool call.
449
+ if (!nonEmpty(args.answer) && state.cvPath) {
450
+ queueNotice(c.welcome(nonEmpty(args.firstName)));
451
+ return {
452
+ ok: true,
453
+ step: 'cv-given',
454
+ cvPath: state.cvPath,
455
+ language: lang(),
456
+ 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 read_cv cvPath with talentConsent:true, asking nothing about the CV. ${linkedin}`,
457
+ };
458
+ }
401
459
  const asked = await askAfterTexts(ctx, {
402
460
  tool: 'signup_start',
403
461
  texts: [c.welcome(nonEmpty(args.firstName))],
404
462
  question: c.welcomeQuestion,
405
- options: [c.cvSearch, c.cvAttach],
463
+ options: [c.cvAttach, c.cvSearch],
406
464
  answer: args.answer,
407
465
  });
408
466
  if (asked.reply) return { ...asked.reply, language: lang() };
409
- 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.`;
410
467
  // A CV path the talent gave wins over both answers: searching could read another candidate.
411
468
  const cvPath = state.cvPath;
412
469
  if (cvPath) {
@@ -414,8 +471,6 @@ function makeSignupTools(deps = {}) {
414
471
  ok: true,
415
472
  step: 'cv-given',
416
473
  cvPath,
417
- linkedinAsk: c.linkedinAsk,
418
- linkedinOpening: c.linkedinOpening,
419
474
  message: `The talent already gave their CV: read_cv it now with talentConsent:true, saying nothing about attaching it. ${linkedin}`,
420
475
  };
421
476
  }
@@ -424,8 +479,6 @@ function makeSignupTools(deps = {}) {
424
479
  ok: true,
425
480
  step: 'cv-search',
426
481
  cvNotFound: c.cvNotFound,
427
- linkedinAsk: c.linkedinAsk,
428
- linkedinOpening: c.linkedinOpening,
429
482
  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}`,
430
483
  };
431
484
  }
@@ -433,8 +486,6 @@ function makeSignupTools(deps = {}) {
433
486
  ok: true,
434
487
  step: 'cv-attach',
435
488
  relayVerbatim: c.cvAttachAsk,
436
- linkedinAsk: c.linkedinAsk,
437
- linkedinOpening: c.linkedinOpening,
438
489
  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}`,
439
490
  };
440
491
  }
@@ -451,7 +502,7 @@ function makeSignupTools(deps = {}) {
451
502
  if (!email) {
452
503
  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.` };
453
504
  }
454
- const question = isPersonalEmail(email) ? c.emailKnown(email) : `${c.emailKnown(email)} ${c.emailWorkHint}`;
505
+ const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
455
506
  const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
456
507
  if (asked.reply) return asked.reply;
457
508
  if (asked.answer === c.emailYes) {
@@ -461,6 +512,13 @@ function makeSignupTools(deps = {}) {
461
512
  return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} Then call signup_email with email set to what they write and typed:true.` };
462
513
  }
463
514
 
515
+ // A session this machine kept for the same email means the account exists, so the window will ask to sign in.
516
+ function knownAccount() {
517
+ const stored = resolved.loadAuthSession();
518
+ const email = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
519
+ return !!email && !!state.email && email === state.email.toLowerCase();
520
+ }
521
+
464
522
  // Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
465
523
  async function signupDraft(args = {}, ctx = {}) {
466
524
  const c = copy();
@@ -475,9 +533,14 @@ function makeSignupTools(deps = {}) {
475
533
  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.' };
476
534
  }
477
535
  if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
536
+ const sources = Array.isArray(args.sources) ? DRAFT_SOURCES.filter((s) => args.sources.includes(s)) : [];
537
+ // A confirmation of the printed block stands even if the model drops sources on the answer call.
538
+ if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
539
+ 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." };
540
+ }
478
541
  const asked = await askAfterTexts(ctx, {
479
542
  tool: 'signup_draft',
480
- texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice, c.windowNotice],
543
+ texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
481
544
  question: c.draftQuestion,
482
545
  options: [c.draftOk, c.draftChange],
483
546
  answer: args.answer,
@@ -489,6 +552,7 @@ function makeSignupTools(deps = {}) {
489
552
  }
490
553
  state.draft = draft;
491
554
  state.draftConfirmed = true;
555
+ state.toldExisting = knownAccount();
492
556
  return {
493
557
  ok: true,
494
558
  next: 'signup_create_account',
@@ -574,10 +638,10 @@ function makeSignupTools(deps = {}) {
574
638
  }
575
639
 
576
640
  function nextSteps(account) {
577
- 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.';
641
+ 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.';
578
642
  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).';
579
643
  if (account.state === 'existing') {
580
- 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}`;
644
+ 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}`;
581
645
  }
582
646
  const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
583
647
  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}`;
@@ -684,18 +748,32 @@ function makeSignupTools(deps = {}) {
684
748
  };
685
749
  }
686
750
 
687
- // Fill-empty only: what of the confirmed draft would land in an existing profile, plus the import that only adds.
751
+ // Fill-empty only: per draft field, what it fills or the value the profile keeps, plus the import that only adds.
688
752
  async function existingDiff() {
689
753
  const c = copy();
690
754
  const draft = state.draft || {};
691
755
  const current = await currentProfile();
692
756
  const lines = [];
757
+ const kept = new Set();
693
758
  const has = (v) => v !== undefined && v !== null && v !== '';
694
- if (current.pricing && current.pricing.hourly === null && has(draft.hourlyRate)) lines.push(c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)));
695
- if (current.pricing && current.pricing.annual === null && has(draft.annualRate)) lines.push(c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)));
696
- if (current.availability && !has(current.availability.monthlyHours) && has(draft.monthlyHours)) lines.push(c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)));
697
- lines.push(c.existingLists);
698
- return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
759
+ // An unreadable section (undefined) counts as set, as fillEmpty never writes it.
760
+ const field = (key, now, filled, label, shown) => {
761
+ if (!has(draft[key])) return;
762
+ if (now === null) { lines.push(filled); return; }
763
+ kept.add(key);
764
+ lines.push(c.existingStays(label, now === undefined ? null : shown(now), shown(draft[key])));
765
+ };
766
+ const pricing = current.pricing || { hourly: undefined, annual: undefined };
767
+ const hours = current.availability ? (has(current.availability.monthlyHours) ? current.availability.monthlyHours : null) : undefined;
768
+ field('hourlyRate', pricing.hourly, c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)), c.hourlyRateLabel, (v) => money(v, c.perHour));
769
+ field('annualRate', pricing.annual, c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)), c.annualRateLabel, (v) => money(v, c.perYear));
770
+ field('monthlyHours', hours, c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)), c.keptLabels.availability, (v) => c.hours(v));
771
+ if (has(draft.languages) && current.languageCount !== 0) { kept.add('languages'); lines.push(c.existingStays(c.keptLabels.languages)); }
772
+ if (kept.size) lines.push(c.existingStaysNote);
773
+ state.toldKept = kept;
774
+ const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
775
+ if (given.length) lines.push(c.existingLists(given));
776
+ return `${state.toldExisting ? c.existingUpdateQuestion : c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
699
777
  }
700
778
 
701
779
  // The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
@@ -731,25 +809,25 @@ function makeSignupTools(deps = {}) {
731
809
  return { details: out, kept };
732
810
  }
733
811
 
734
- // What the existing profile kept, for the model per field and for the talent as one fixed line.
812
+ // What the existing profile kept, for the model per field and for the talent as one fixed line without what the update question already told.
735
813
  function keptReport(kept) {
736
814
  const c = copy();
737
815
  const fields = [];
738
816
  const items = [];
739
- const value = (v, shown) => (v === null || v === undefined ? null : shown(v));
817
+ const told = (key) => state.toldKept.has(key);
740
818
  if (kept.workSituation) { fields.push('workSituation: not changed (the profile keeps what it had)'); items.push(c.keptLabels.workSituation); }
741
819
  for (const [key, label, unit] of [['hourlyRate', c.hourlyRateLabel, c.perHour], ['annualRate', c.annualRateLabel, c.perYear]]) {
742
820
  const k = kept.pricing && kept.pricing[key];
743
821
  if (!k) continue;
744
822
  fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had, not ${k.requested})` : `${key}: kept existing value ${k.kept} (not changed, not ${k.requested})`);
745
- items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
823
+ if (!told(key)) items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
746
824
  }
747
825
  for (const [key, k] of Object.entries(kept.availability || {})) {
748
826
  fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had)` : `${key}: kept existing value ${Array.isArray(k.kept) ? k.kept.join('/') : k.kept} (not changed, not ${Array.isArray(k.requested) ? k.requested.join('/') : k.requested})`);
749
827
  }
750
828
  const hours = kept.availability && kept.availability.monthlyHours;
751
- if (hours) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
752
- if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); items.push(c.keptLabels.languages); }
829
+ if (hours && !told('monthlyHours')) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
830
+ if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); if (!told('languages')) items.push(c.keptLabels.languages); }
753
831
  if (kept.phone) { fields.push('phone: not changed (the profile keeps what it had)'); items.push(c.keptLabels.phone); }
754
832
  return { fields, line: items.length ? c.existingUnchanged(items) : null };
755
833
  }
@@ -805,17 +883,26 @@ function makeSignupTools(deps = {}) {
805
883
  ['availability', details.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
806
884
  ['languages', Array.isArray(details.languages) && details.languages.length ? details.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
807
885
  ['phone', details.phone, (v) => flow.savePhone(resolved, v), saved.phone],
808
- ];
886
+ ].filter(([, value]) => value && typeof value === 'object');
887
+ // Hub's async profile-completion recalculation saves the whole users row from a copy it read earlier, so a
888
+ // phone written while it runs comes back NULL: the phone goes first, and is read back once the rest is saved.
889
+ const phoneFirst = [...sections.filter(([name]) => name === 'phone'), ...sections.filter(([name]) => name !== 'phone')];
809
890
  const results = {};
810
- const confirmations = [];
811
- for (const [name, value, save, saved] of sections) {
812
- if (!value || typeof value !== 'object') continue;
891
+ for (const [name, value, save] of phoneFirst) {
813
892
  const res = await save(value);
814
- if (res.ok) confirmations.push(saved);
815
893
  if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
816
894
  const help = res.message || SECTION_HELP[res.reason];
817
895
  results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(help ? { message: help } : {}) };
818
896
  }
897
+ if (results.phone && results.phone.ok) {
898
+ const session = resolved.loadAuthSession();
899
+ const stored = await fetchTelephone({ hubAccessToken: session && session.hubAccessToken }).catch(() => ({ ok: false }));
900
+ if (stored.ok && !stored.telephone) {
901
+ const again = await flow.savePhone(resolved, details.phone);
902
+ results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
903
+ }
904
+ }
905
+ const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
819
906
  const ok = Object.values(results).every((r) => r.ok);
820
907
  const report = keptReport(kept);
821
908
  if (report.line) queueNotice(report.line);
@@ -845,27 +932,32 @@ function makeSignupTools(deps = {}) {
845
932
  if (!url) return { ok: false, reason: 'no-endpoint' };
846
933
  // The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
847
934
  const opened = args.open !== false && openBrowser(url) !== false;
935
+ // Read right before the closing words, so they never state an import state that is already stale.
936
+ const imp = await readImport();
937
+ // A source that failed after the last signup_status gets its fixed line here, as there is no question left.
938
+ const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
939
+ noticeFailed(failed);
848
940
  // The flow ends here, so a fixed line no question carried is printed now.
849
941
  const unseen = takeNotices();
850
942
  const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
851
- const relayFirst = unseen.length ? `${VERBATIM} ` : '';
852
- if (opened) {
853
- return {
854
- ok: true,
855
- opened: true,
856
- loggedIn,
857
- ...relay,
858
- 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.')
859
- + ' If they say it did not open, call open_web with open:false and give them its link.',
860
- };
861
- }
943
+ const importLine = failed.length
944
+ ? `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.`
945
+ : IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
946
+ const where = opened
947
+ ? `Shakers is open in their browser${loggedIn ? ', already signed in' : ' at the sign-in page, where they sign in'}`
948
+ : `they open Shakers from \`link\` (give it to click, with no comment on the address${loggedIn ? '; it works once, for 2 minutes' : ''})`;
949
+ const closing = `one short closing sentence of your own, in the talent's language, saying ${where}.`;
950
+ const reply = unseen.length
951
+ ? `Your reply has two parts and needs both: first relayVerbatim word for word as its own block, then ${closing}`
952
+ : `End with ${closing}`;
862
953
  return {
863
954
  ok: true,
864
- opened: false,
955
+ opened,
865
956
  loggedIn,
866
- link: url,
957
+ ...(opened ? {} : { link: url }),
958
+ import: { state: imp.state },
867
959
  ...relay,
868
- message: relayFirst + 'Give the talent `link` to click, with no comment on the address.' + (loggedIn ? ' It works once, for 2 minutes.' : ''),
960
+ message: `${reply}${opened ? ' If they say it did not open, call open_web with open:false and give them its link.' : ''} ${importLine}`,
869
961
  };
870
962
  }
871
963
 
@@ -882,7 +974,7 @@ function makeSignupTools(deps = {}) {
882
974
  return [
883
975
  {
884
976
  name: 'signup_start',
885
- 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.",
977
+ 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 (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.",
886
978
  inputSchema: WELCOME_SCHEMA,
887
979
  handler: signupStart,
888
980
  },
@@ -912,7 +1004,7 @@ function makeSignupTools(deps = {}) {
912
1004
  },
913
1005
  {
914
1006
  name: 'update_existing_profile',
915
- 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 asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
1007
+ 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.',
916
1008
  inputSchema: UPDATE_EXISTING_SCHEMA,
917
1009
  handler: updateExistingProfile,
918
1010
  },
@@ -943,4 +1035,4 @@ function makeSignupTools(deps = {}) {
943
1035
  ];
944
1036
  }
945
1037
 
946
- module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
1038
+ module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // AI-profile client (hub `works/me/ai-profile`, talent Bearer). The "My work
3
+ // AI-profile client. The "My work
4
4
  // with AI" read model: setup/usage tier+level, 3x3 cell, vision, howIWork.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -87,14 +87,12 @@ async function getMapped(endpoint, hubAccessToken, timeoutMs, map) {
87
87
  return { ok: true, ...map(data) };
88
88
  }
89
89
 
90
- // `works/me/profile` -> headline (basic profile text).
91
90
  async function fetchMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
92
91
  const getEndpoint = deps.getMeProfileEndpoint || require('./config').getMeProfileEndpoint;
93
92
  const req = deps.getMapped || getMapped;
94
93
  return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => ({ headline: str(d && d.headline) }));
95
94
  }
96
95
 
97
- // `works/talents/me/profile` -> completion %, freelance type, onboarding status.
98
96
  async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
99
97
  const getEndpoint = deps.getTalentMeProfileEndpoint || require('./config').getTalentMeProfileEndpoint;
100
98
  const req = deps.getMapped || getMapped;
@@ -105,7 +103,6 @@ async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEF
105
103
  }));
106
104
  }
107
105
 
108
- // `works/talents/me/work-details/languages` -> count + codes.
109
106
  async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
110
107
  const getEndpoint = deps.getLanguagesEndpoint || require('./config').getLanguagesEndpoint;
111
108
  const req = deps.getMapped || getMapped;
@@ -116,10 +113,21 @@ async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_T
116
113
  });
117
114
  }
118
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
+
119
126
  module.exports = {
120
127
  requestAiProfile,
121
128
  fetchAiProfile,
122
129
  fetchMeProfile,
123
130
  fetchTalentMeProfile,
124
131
  fetchLanguages,
132
+ fetchTelephone,
125
133
  };
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Pricing-rate client (hub `works/talents/me/work-details/pricing-rate`, talent
4
- // Bearer). READ-ONLY here: setting the rate is a financial PUT, left to register.
3
+ // Pricing-rate client. READ-ONLY here: setting the rate is a financial PUT, left to register.
5
4
 
6
5
  const { postJsonWithTimeout } = require('./backend-request');
7
6
  const { reasonForError } = require('./onboarding-client');
@@ -43,7 +43,6 @@ function normalizeRole(r) {
43
43
  };
44
44
  }
45
45
 
46
- // GET works/certifications/me/available-roles -> { roles:[{clusterId,name,category,proposalKind}] }.
47
46
  async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
48
47
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
49
48
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -60,7 +59,6 @@ async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeou
60
59
  return { ok: true, roles: rows.map(normalizeRole).filter(Boolean) };
61
60
  }
62
61
 
63
- // GET works/talents/me/work-details/assigned-clusters -> the talent's GROWTH/RECOMMENDED roles.
64
62
  async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
65
63
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
66
64
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -77,7 +75,6 @@ async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, time
77
75
  return { ok: true, clusters: rows.map(normalizeRole).filter(Boolean) };
78
76
  }
79
77
 
80
- // POST works/talents/me/work-details/assigned-clusters { clusterId } -> adds a GROWTH role.
81
78
  async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
82
79
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
83
80
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -92,7 +89,6 @@ async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoi
92
89
  return { ok: true, clusterId };
93
90
  }
94
91
 
95
- // PATCH works/certifications/me/main-role { clusterId } -> sets the preferred main role.
96
92
  async function requestSetMainRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
97
93
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
98
94
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Saved-positions client (hub `works/saved-positions`, talent Bearer). Both
3
+ // Saved-positions client. Both
4
4
  // operations are idempotent server-side, so no read-before-write is needed.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Position detail client (hub `works/positions/:id/detail`, talent Bearer).
3
+ // Position detail client.
4
4
  // A "project" here is the Position + its parent project's fields.
5
5
 
6
6
  const { postJsonWithTimeout } = require('./backend-request');