shakers-cli-beta 0.13.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 (39) 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/backend-request.js +12 -8
  6. package/src/config.js +17 -5
  7. package/src/consent-flow.js +10 -10
  8. package/src/i18n.js +6 -6
  9. package/src/mcp-ai-usage-result-tool.js +4 -2
  10. package/src/mcp-ai-usage-tool.js +3 -1
  11. package/src/mcp-alma-tools.js +7 -3
  12. package/src/mcp-applications-tools.js +4 -2
  13. package/src/mcp-availability-tools.js +4 -2
  14. package/src/mcp-certifications-tools.js +4 -2
  15. package/src/mcp-certify-dimension-tools.js +4 -2
  16. package/src/mcp-choice.js +27 -1
  17. package/src/mcp-device-login-tools.js +6 -2
  18. package/src/mcp-find-projects-tools.js +4 -2
  19. package/src/mcp-instructions.js +1 -1
  20. package/src/mcp-invitations-tools.js +4 -2
  21. package/src/mcp-lang-tools.js +4 -2
  22. package/src/mcp-onboarding-tools.js +41 -44
  23. package/src/mcp-portfolios-tools.js +7 -3
  24. package/src/mcp-profile-tools.js +4 -2
  25. package/src/mcp-prompts.js +2 -2
  26. package/src/mcp-rate-tools.js +4 -2
  27. package/src/mcp-register-tools.js +52 -19
  28. package/src/mcp-report-tools.js +7 -3
  29. package/src/mcp-role-tools.js +23 -8
  30. package/src/mcp-saved-positions-tools.js +7 -3
  31. package/src/mcp-server.js +8 -2
  32. package/src/mcp-show-project-tools.js +4 -2
  33. package/src/mcp-signup-tools.js +153 -20
  34. package/src/mcp-socials-tools.js +4 -2
  35. package/src/onboarding-flow.js +112 -13
  36. package/src/onboarding-funnel.js +272 -0
  37. package/src/share.js +4 -3
  38. package/src/signup-copy.js +55 -1
  39. package/src/mcp-ask-tools.js +0 -50
@@ -109,12 +109,13 @@ function makeRoleTools(deps = {}) {
109
109
  countOpenPositions = (opts) => require('./find-projects-client').countOpenPositions({}, opts),
110
110
  sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
111
111
  now = () => Date.now(),
112
+ funnel = require('./onboarding-funnel').NOOP_FUNNEL,
112
113
  } = deps;
113
114
 
114
115
  const requireSession = () => {
115
116
  const session = loadAuthSession();
116
117
  if (sessionStatus(session) !== 'active') {
117
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first.');
118
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
118
119
  }
119
120
  return session;
120
121
  };
@@ -317,37 +318,51 @@ function makeRoleTools(deps = {}) {
317
318
  const added = await addRole({ clusterId: picked.role.clusterId });
318
319
  if (!added.ok) return added;
319
320
  }
320
- const set = await setMainRole({ clusterId: picked.role.clusterId });
321
+ const set = await applyMainRole(session, picked.role.clusterId, { suggestion: !picked.add });
321
322
  return set.ok ? { ...set, name: picked.role.name } : set;
322
323
  }
323
- const res = await requestSetMainRole({ clusterId: args.clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
324
+ return applyMainRole(session, args.clusterId, { suggestion: args.catalogue === true ? false : undefined });
325
+ }
326
+
327
+ async function applyMainRole(session, clusterId, { suggestion } = {}) {
328
+ const res = await requestSetMainRole({ clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
324
329
  if (res.ok === false && res.reason === 'http-400') return { ok: false, reason: res.reason, message: 'The talent does not hold that role yet: call add_role with it, then set_main_role again.' };
325
330
  if (!res.ok) return { ok: false, reason: res.reason };
326
- return { ok: true, clusterId: args.clusterId };
331
+ funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
332
+ funnel.flush();
333
+ return { ok: true, clusterId };
327
334
  }
328
335
 
329
336
  return [
330
337
  {
331
338
  name: 'list_available_roles',
332
- description: "List the roles the talent can add to their profile (the cluster catalog minus roles they already hold). Each row has clusterId (internal handle), name, category and an optional proposalKind (why it is suggested). Present roles to the talent by NAME; never show clusterId. Requires an active session.",
339
+ title: 'Roles you can add',
340
+ description: 'Shows the Shakers roles you can add to your profile.',
341
+ guidance: "List the roles the talent can add to their profile (the cluster catalog minus roles they already hold). Each row has clusterId (internal handle), name, category and an optional proposalKind (why it is suggested). Present roles to the talent by NAME; never show clusterId. Requires an active session.",
333
342
  inputSchema: EMPTY_SCHEMA,
334
343
  handler: listAvailableRoles,
335
344
  },
336
345
  {
337
346
  name: 'add_role',
338
- description: "Add a role to the talent's profile (a GROWTH role) by clusterId from list_available_roles. Requires an active session. Returns { clusterId, alreadyAssigned? }.",
347
+ title: 'Add a role',
348
+ description: 'Adds the role you pick to your Shakers profile.',
349
+ guidance: "Add a role to the talent's profile (a GROWTH role) by clusterId from list_available_roles. Requires an active session. Returns { clusterId, alreadyAssigned? }.",
339
350
  inputSchema: CLUSTER_SCHEMA,
340
351
  handler: addRole,
341
352
  },
342
353
  {
343
354
  name: 'list_my_roles',
344
- description: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (20): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
355
+ title: 'Your roles',
356
+ description: 'Shows your roles on Shakers and the ones suggested by your interview and your CV.',
357
+ guidance: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (20): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
345
358
  inputSchema: LIST_MY_ROLES_SCHEMA,
346
359
  handler: listMyRoles,
347
360
  },
348
361
  {
349
362
  name: 'set_main_role',
350
- description: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns `say` to print word for word and the roles, to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
363
+ title: 'Choose your main role',
364
+ description: 'Asks you which role is your main one and saves it on your Shakers profile.',
365
+ guidance: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns `say` to print word for word and the roles, to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
351
366
  inputSchema: SET_MAIN_ROLE_SCHEMA,
352
367
  handler: setMainRole,
353
368
  },
@@ -19,7 +19,7 @@ function makeSavedPositionsTools(deps = {}) {
19
19
  const requireLoginSession = () => {
20
20
  const session = loadAuthSession();
21
21
  if (sessionStatus(session) !== 'active') {
22
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (this tool is session-gated).');
22
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (this tool is session-gated).');
23
23
  }
24
24
  return session;
25
25
  };
@@ -36,13 +36,17 @@ function makeSavedPositionsTools(deps = {}) {
36
36
  return [
37
37
  {
38
38
  name: 'save_project',
39
- description: 'Bookmark a project/Position for the talent (Find Positions "Saved"). Idempotent — saving twice is a no-op. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
39
+ title: 'Save a project',
40
+ description: 'Saves a project to your list on Shakers.',
41
+ guidance: 'Bookmark a project/Position for the talent (Find Positions "Saved"). Idempotent — saving twice is a no-op. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
40
42
  inputSchema: ID_SCHEMA,
41
43
  handler: toggle(savePosition),
42
44
  },
43
45
  {
44
46
  name: 'unsave_project',
45
- description: 'Remove a project/Position bookmark for the talent. Idempotent — un-saving something not saved is not an error. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
47
+ title: 'Remove a saved project',
48
+ description: 'Removes a project from your saved list on Shakers.',
49
+ guidance: 'Remove a project/Position bookmark for the talent. Idempotent — un-saving something not saved is not an error. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
46
50
  inputSchema: ID_SCHEMA,
47
51
  handler: toggle(unsavePosition),
48
52
  },
package/src/mcp-server.js CHANGED
@@ -34,7 +34,7 @@ function errorResponse(id, code, message, data) {
34
34
  return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
35
35
  }
36
36
 
37
- function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
37
+ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
38
38
  const registry = new Map();
39
39
  for (const t of tools) {
40
40
  if (!t || !t.name || typeof t.handler !== 'function') {
@@ -107,11 +107,14 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
107
107
  }
108
108
  }
109
109
 
110
+ // `description` and `title` are what the talent reads in the client; `guidance` is how the model must use the tool, so it rides in the schema.
110
111
  function shapeTool(t) {
112
+ const schema = t.inputSchema || { type: 'object' };
111
113
  return {
112
114
  name: t.name,
115
+ ...(t.title ? { title: t.title } : {}),
113
116
  description: t.description || '',
114
- inputSchema: t.inputSchema || { type: 'object' },
117
+ inputSchema: t.guidance ? { ...schema, description: [t.guidance, schema.description].filter(Boolean).join(' ') } : schema,
115
118
  };
116
119
  }
117
120
 
@@ -205,6 +208,9 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
205
208
  case 'initialize': {
206
209
  const requested = msg.params && msg.params.protocolVersion;
207
210
  clientCapabilities = (msg.params && msg.params.capabilities) || {};
211
+ if (typeof onInitialize === 'function') {
212
+ try { onInitialize({ clientInfo: (msg.params && msg.params.clientInfo) || {} }); } catch { /* never breaks the handshake */ }
213
+ }
208
214
  // One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
209
215
  // declared elicitation shape and the resulting decision are visible in the MCP log.
210
216
  process.stderr.write(` [mcp] initialize elicitation=${JSON.stringify(clientCapabilities.elicitation)} -> elicit=${canElicitForm()}\n`);
@@ -20,7 +20,7 @@ function makeShowProjectTools(deps = {}) {
20
20
  const requireLoginSession = () => {
21
21
  const session = loadAuthSession();
22
22
  if (sessionStatus(session) !== 'active') {
23
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (show_project is session-gated).');
23
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (show_project is session-gated).');
24
24
  }
25
25
  return session;
26
26
  };
@@ -37,7 +37,9 @@ function makeShowProjectTools(deps = {}) {
37
37
  return [
38
38
  {
39
39
  name: 'show_project',
40
- description: "Get the full detail of one Position/Project available to the talent (Shakers Find Positions detail): title, parent project, company, budget, work mode, required skills/languages, description, goals, FAQs, and the talent's saved/applied/canApply state. Requires an active session and the position id. The `id` (and any positionId/projectId in the result) is an INTERNAL handle for calling tools — NEVER show or mention it to the talent; refer to the project by its title/company.",
40
+ title: 'See a project',
41
+ description: 'Shows the full details of one Shakers project.',
42
+ guidance: "Get the full detail of one Position/Project available to the talent (Shakers Find Positions detail): title, parent project, company, budget, work mode, required skills/languages, description, goals, FAQs, and the talent's saved/applied/canApply state. Requires an active session and the position id. The `id` (and any positionId/projectId in the result) is an INTERNAL handle for calling tools — NEVER show or mention it to the talent; refer to the project by its title/company.",
41
43
  inputSchema: SHOW_PROJECT_SCHEMA,
42
44
  handler: showProject,
43
45
  },
@@ -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
 
@@ -173,6 +176,15 @@ const EMAIL_SCHEMA = {
173
176
 
174
177
  // In the order the data notice names them.
175
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
+ };
176
188
 
177
189
  const DRAFT_SCHEMA = {
178
190
  type: 'object',
@@ -181,6 +193,9 @@ const DRAFT_SCHEMA = {
181
193
  name: { type: 'string', description: 'Full name.' },
182
194
  role: { type: 'string', description: 'Role, e.g. "Data Engineer".' },
183
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.' },
184
199
  yearsOfExperience: { type: 'number' },
185
200
  stack: { type: 'string', description: 'Main technologies, comma separated, at most five.' },
186
201
  languages: { type: 'string', description: 'Languages and level, e.g. "Español nativo, inglés C1".' },
@@ -230,6 +245,7 @@ const IMPORT_SCHEMA = {
230
245
  websiteUrl: { type: 'string' },
231
246
  userQuery: { type: 'string', description: 'Everything you know about the talent, as free text.' },
232
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.' },
233
249
  },
234
250
  };
235
251
 
@@ -288,6 +304,7 @@ function makeSignupTools(deps = {}) {
288
304
  fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
289
305
  fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
290
306
  fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
307
+ funnel = NOOP_FUNNEL,
291
308
  fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
292
309
  openBrowser = require('./open-file').openPath,
293
310
  sleep = defaultSleep,
@@ -301,6 +318,7 @@ function makeSignupTools(deps = {}) {
301
318
  sessionToken: null,
302
319
  email: null,
303
320
  draft: null,
321
+ firstDraft: null,
304
322
  draftConfirmed: false,
305
323
  keepProfile: false,
306
324
  // Fields the update question already told the talent they keep.
@@ -313,11 +331,18 @@ function makeSignupTools(deps = {}) {
313
331
  failedSources: new Set(),
314
332
  cvPath: null,
315
333
  wake: null,
334
+ importStartedAt: null,
335
+ importReported: new Set(),
316
336
  };
317
337
  currentSignup = state;
318
338
 
319
339
  function setAccount(next) {
340
+ const before = state.account.state;
320
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
+ }
321
346
  if (state.wake) state.wake();
322
347
  }
323
348
 
@@ -432,7 +457,12 @@ function makeSignupTools(deps = {}) {
432
457
  }
433
458
  if (!nonEmpty(args.answer)) {
434
459
  fixSignupLanguage(args.language);
460
+ funnel.begin();
461
+ funnel.viewed('start');
435
462
  state.draft = null;
463
+ state.firstDraft = null;
464
+ state.importStartedAt = null;
465
+ state.importReported = new Set();
436
466
  state.draftConfirmed = false;
437
467
  state.email = null;
438
468
  state.failedSources.clear();
@@ -447,13 +477,14 @@ function makeSignupTools(deps = {}) {
447
477
  // A CV path in the first message already answers the CV question: that CV is read, and the welcome
448
478
  // rides on the next question block, because models skip a relay that comes before another tool call.
449
479
  if (!nonEmpty(args.answer) && state.cvPath) {
480
+ funnel.completed('start');
450
481
  queueNotice(c.welcome(nonEmpty(args.firstName)));
451
482
  return {
452
483
  ok: true,
453
484
  step: 'cv-given',
454
485
  cvPath: state.cvPath,
455
486
  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}`,
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}`,
457
488
  };
458
489
  }
459
490
  const asked = await askAfterTexts(ctx, {
@@ -464,6 +495,7 @@ function makeSignupTools(deps = {}) {
464
495
  answer: args.answer,
465
496
  });
466
497
  if (asked.reply) return { ...asked.reply, language: lang() };
498
+ funnel.completed('start');
467
499
  // A CV path the talent gave wins over both answers: searching could read another candidate.
468
500
  const cvPath = state.cvPath;
469
501
  if (cvPath) {
@@ -471,7 +503,7 @@ function makeSignupTools(deps = {}) {
471
503
  ok: true,
472
504
  step: 'cv-given',
473
505
  cvPath,
474
- 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}`,
475
507
  };
476
508
  }
477
509
  if (asked.answer === c.cvSearch) {
@@ -479,7 +511,7 @@ function makeSignupTools(deps = {}) {
479
511
  ok: true,
480
512
  step: 'cv-search',
481
513
  cvNotFound: c.cvNotFound,
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}`,
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}`,
483
515
  };
484
516
  }
485
517
  return {
@@ -525,9 +557,10 @@ function makeSignupTools(deps = {}) {
525
557
  const pricing = pricingFromRates(args);
526
558
  if (!pricing.ok) return pricing;
527
559
  const draft = {};
528
- for (const key of ['name', 'role', 'city', 'yearsOfExperience', 'stack', 'languages', 'workMode', 'monthlyHours', 'hourlyRate', 'annualRate']) {
560
+ for (const key of DRAFT_FIELDS) {
529
561
  if (args[key] !== undefined && args[key] !== null && args[key] !== '') draft[key] = args[key];
530
562
  }
563
+ if (typeof draft.country === 'string') draft.country = draft.country.trim().toUpperCase();
531
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.' };
532
565
  if (draft.hourlyRate === undefined || draft.annualRate === undefined) {
533
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.' };
@@ -538,6 +571,7 @@ function makeSignupTools(deps = {}) {
538
571
  if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
539
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." };
540
573
  }
574
+ if (!state.firstDraft) state.firstDraft = draft;
541
575
  const asked = await askAfterTexts(ctx, {
542
576
  tool: 'signup_draft',
543
577
  texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
@@ -617,7 +651,16 @@ function makeSignupTools(deps = {}) {
617
651
  state.sources = sources;
618
652
  state.reported = { account: 'waiting', import: 'none' };
619
653
  const reused = state.account.state === 'waiting';
620
- 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
+ }
621
664
  return {
622
665
  ok: true,
623
666
  status: 'window-open',
@@ -628,13 +671,35 @@ function makeSignupTools(deps = {}) {
628
671
  };
629
672
  }
630
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
+
631
693
  async function readImport() {
632
694
  // An account that already existed got no import from this sign-up: its last import is not news.
633
695
  if (state.account.state === 'existing') return { state: 'none' };
634
696
  const session = resolved.loadAuthSession();
635
697
  if (resolved.sessionStatus(session) !== 'active' || !session || !session.hubAccessToken) return { state: 'none' };
636
698
  const res = await requestMyImportStatus({ hubAccessToken: session.hubAccessToken }, { endpoint: getMyImportStatusEndpoint() });
637
- 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;
638
703
  }
639
704
 
640
705
  function nextSteps(account) {
@@ -644,7 +709,7 @@ function makeSignupTools(deps = {}) {
644
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}`;
645
710
  }
646
711
  const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
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}`;
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}`;
648
713
  }
649
714
 
650
715
  // A source that did not import gets its fixed line once, on the next question block.
@@ -697,6 +762,19 @@ function makeSignupTools(deps = {}) {
697
762
  return out;
698
763
  }
699
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
+
700
778
  async function importProfile(args = {}) {
701
779
  if (nonEmpty(args.linkedinUrl) && !isLinkedinProfileUrl(nonEmpty(args.linkedinUrl))) return { ok: false, reason: 'invalid-linkedin-url', message: INVALID_LINKEDIN_MESSAGE };
702
780
  const res = await flow.startProfileImport(resolved, {
@@ -710,7 +788,7 @@ function makeSignupTools(deps = {}) {
710
788
  });
711
789
  if (!res.ok) {
712
790
  if (res.reason === 'no-session') {
713
- 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.' };
714
792
  }
715
793
  return { ok: false, reason: res.reason };
716
794
  }
@@ -869,6 +947,40 @@ function makeSignupTools(deps = {}) {
869
947
  return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
870
948
  }
871
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
+
872
984
  async function saveProfileDetails(args = {}) {
873
985
  const { saved } = copy();
874
986
  const existing = state.account.state === 'existing';
@@ -902,6 +1014,7 @@ function makeSignupTools(deps = {}) {
902
1014
  results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
903
1015
  }
904
1016
  }
1017
+ trackSavedSteps(details, results);
905
1018
  const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
906
1019
  const ok = Object.values(results).every((r) => r.ok);
907
1020
  const report = keptReport(kept);
@@ -974,61 +1087,81 @@ function makeSignupTools(deps = {}) {
974
1087
  return [
975
1088
  {
976
1089
  name: 'signup_start',
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.",
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.",
978
1093
  inputSchema: WELCOME_SCHEMA,
979
1094
  handler: signupStart,
980
1095
  },
981
1096
  {
982
1097
  name: 'signup_email',
983
- 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.',
984
1101
  inputSchema: EMAIL_SCHEMA,
985
1102
  handler: signupEmail,
986
1103
  },
987
1104
  {
988
1105
  name: 'signup_draft',
989
- 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.',
990
1109
  inputSchema: DRAFT_SCHEMA,
991
1110
  handler: signupDraft,
992
1111
  },
993
1112
  {
994
1113
  name: 'signup_create_account',
995
- 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.",
996
1117
  inputSchema: CREATE_SCHEMA,
997
1118
  handler: signupCreateAccount,
998
1119
  },
999
1120
  {
1000
1121
  name: 'signup_status',
1001
- 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.",
1002
1125
  inputSchema: STATUS_SCHEMA,
1003
1126
  handler: signupStatus,
1004
1127
  },
1005
1128
  {
1006
1129
  name: 'update_existing_profile',
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.',
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.',
1008
1133
  inputSchema: UPDATE_EXISTING_SCHEMA,
1009
1134
  handler: updateExistingProfile,
1010
1135
  },
1011
1136
  {
1012
1137
  name: 'import_profile',
1013
- 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.",
1014
1141
  inputSchema: IMPORT_SCHEMA,
1015
- handler: importProfile,
1142
+ handler: importProfileAsked,
1016
1143
  },
1017
1144
  {
1018
1145
  name: 'save_profile_details',
1019
- 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.",
1020
1149
  inputSchema: buildDetailsSchema(deps.lang || 'en'),
1021
1150
  handler: saveProfileDetails,
1022
1151
  },
1023
1152
  {
1024
1153
  name: 'open_web',
1025
- 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.",
1026
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.' } } },
1027
1158
  handler: openWeb,
1028
1159
  },
1029
1160
  {
1030
1161
  name: 'open_linkedin_profile',
1031
- 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.",
1032
1165
  inputSchema: { type: 'object', properties: {} },
1033
1166
  handler: openLinkedinProfile,
1034
1167
  },
@@ -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
  },