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
@@ -14,7 +14,7 @@ function makeRateTools(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 (get_rate is session-gated).');
17
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (get_rate is session-gated).');
18
18
  }
19
19
  return session;
20
20
  };
@@ -29,7 +29,9 @@ function makeRateTools(deps = {}) {
29
29
  return [
30
30
  {
31
31
  name: 'get_rate',
32
- description: "View the talent's rates as the web shows them: partTimePrice is the hourly rate (per hour) and fullTimePrice the annual target (per year); a variant not selected is not offered. Read-only. Requires an active session. Show only the human-facing amounts/currency; any internal ids in the result are handles for tools only — never show or mention them to the talent.",
32
+ title: 'Your rates',
33
+ description: 'Shows your hourly rate and your yearly target on Shakers.',
34
+ guidance: "View the talent's rates as the web shows them: partTimePrice is the hourly rate (per hour) and fullTimePrice the annual target (per year); a variant not selected is not offered. Read-only. Requires an active session. Show only the human-facing amounts/currency; any internal ids in the result are handles for tools only — never show or mention them to the talent.",
33
35
  inputSchema: GET_RATE_SCHEMA,
34
36
  handler: getRate,
35
37
  },
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const { TranscriptBuffer } = require('./interview-transcript');
4
- const { askChoice } = require('./mcp-choice');
4
+ const path = require('path');
5
+ const { askChoice, askConsent } = require('./mcp-choice');
6
+ const { expandHome, tildify } = require('./env-paths');
5
7
 
6
8
  const ANSWER_MODES = ['drafted', 'own', 'full'];
7
9
 
@@ -62,7 +64,7 @@ function withTimeout(promise, ms) {
62
64
  return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
63
65
  }
64
66
 
65
- // No telemetry ships in this package: stderr reaches the MCP client's server log, with what is needed to size the gap.
67
+ // Stays local (stderr, the MCP client's log); only the sign-up funnel is sent.
66
68
  function defaultReportLivekitFailure(kind, error) {
67
69
  const cause = error && error.cause && error.cause.message ? error.cause.message : (error && error.message) || '';
68
70
  process.stderr.write(` [mcp] livekit-unavailable kind=${kind} os=${process.platform} arch=${process.arch} node=${process.version} cause=${JSON.stringify(cause.slice(0, 300))}\n`);
@@ -85,6 +87,8 @@ function makeRegisterTools(deps = {}) {
85
87
  const textLang = () => require('./signup-language').signupLanguage(lang);
86
88
  const copy = () => require('./signup-copy').signupCopy(textLang());
87
89
  const checkCompleted = deps.checkOnboardingCompleted || require('./onboarding-availability').checkOnboardingCompleted;
90
+ const funnel = deps.funnel || require('./onboarding-funnel').NOOP_FUNNEL;
91
+ const track = (record) => { record(); funnel.flush(); };
88
92
 
89
93
  // The LiveKit room is a live WebRTC connection that must survive between the
90
94
  // separate start/turn/complete JSON-RPC calls of this long-lived stdio server.
@@ -115,6 +119,7 @@ function makeRegisterTools(deps = {}) {
115
119
  }
116
120
 
117
121
  function interviewLater() {
122
+ track(() => funnel.completed('pre_interview', { interview_choice: 'skip' }));
118
123
  return { ok: false, reason: 'interview-later', message: 'The talent does the interview later on the web: skip it here and go on with the main role, then open_web.' };
119
124
  }
120
125
 
@@ -149,7 +154,7 @@ function makeRegisterTools(deps = {}) {
149
154
  reason: 'onboarding-already-completed',
150
155
  alreadyCompleted: true,
151
156
  message:
152
- 'You have already completed the onboarding interview. Re-running it is optional. If the talent explicitly wants to repeat it, call again with confirmRepeat:true.',
157
+ 'The talent already completed the onboarding interview: skip it and say nothing about it to the talent, just go on with the next step. Only if the talent themselves asks to repeat it, call again with confirmRepeat:true.',
153
158
  };
154
159
  }
155
160
  if (!CHAT_INTERVIEW_LANGUAGES.has(textLang())) {
@@ -157,6 +162,7 @@ function makeRegisterTools(deps = {}) {
157
162
  }
158
163
  const acknowledged = args.disclaimerAcknowledged === true;
159
164
  if (!acknowledged) {
165
+ track(() => funnel.viewed('pre_interview'));
160
166
  const where = await askWhere(args, ctx);
161
167
  if (where.here === false) return interviewLater();
162
168
  if (where.chat) {
@@ -181,8 +187,10 @@ function makeRegisterTools(deps = {}) {
181
187
  message: `${answer.chat.choiceMessage} Then call onboarding_interview_start again with where:"here", disclaimerAcknowledged:true and answerMode: drafted for the first option, own for the second, full for the third.`,
182
188
  };
183
189
  }
190
+ track(() => funnel.completed('pre_interview', { interview_choice: 'start_now' }));
184
191
  const room = {};
185
192
  const opened = await withTimeout(openRoom(args, room), interviewTimeoutMs);
193
+ if (opened === TIMED_OUT || !opened.ok) track(() => funnel.errored('interview', opened === TIMED_OUT ? 'timeout' : opened.reason));
186
194
  if (opened === TIMED_OUT) {
187
195
  room.abandoned = true;
188
196
  if (room.client) room.client.disconnect().catch(() => {});
@@ -190,6 +198,7 @@ function makeRegisterTools(deps = {}) {
190
198
  }
191
199
  if (!opened.ok) return opened;
192
200
  interviewSessions.set(opened.interviewId, { client: opened.client, buffer: opened.buffer, connectAt: Date.now(), ended: opened.opening.ended === true });
201
+ track(() => funnel.viewed('interview', { interview_mode: 'text' }));
193
202
  return { ok: true, interviewId: opened.interviewId, answerMode: answer.mode, greeting: opened.opening.text, ended: opened.opening.ended === true };
194
203
  }
195
204
 
@@ -275,6 +284,7 @@ function makeRegisterTools(deps = {}) {
275
284
  const saved = await teardownInterview(args.interviewId, { persist: true });
276
285
  if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed' };
277
286
  lastCompletedInterview = { interviewId: args.interviewId, completedAt: Date.now() };
287
+ track(() => funnel.completed('interview', { interview_mode: 'text' }));
278
288
  return { ok: true, state: 'taken', message: catalog.interviewComplete };
279
289
  }
280
290
 
@@ -311,8 +321,12 @@ function makeRegisterTools(deps = {}) {
311
321
  };
312
322
  }
313
323
 
314
- // Content-free context the model may PROPOSE to the talent (never auto-write).
315
- async function suggestContext(args = {}) {
324
+ // Content-free context the model may PROPOSE to the talent (never auto-write), searched only after the talent says yes.
325
+ async function suggestContext(args = {}, ctx = {}) {
326
+ const c = copy().consent;
327
+ const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.yes, no: c.no, answer: args.answer });
328
+ if (asked.reply) return asked.reply;
329
+ if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was searched. Do not look for the CV on this computer; ask them to attach it in the chat.' };
316
330
  const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
317
331
  const suggestions = read({ talentName: args.talentName });
318
332
  const found = suggestions.cvCandidates && suggestions.cvCandidates.length > 0;
@@ -321,16 +335,21 @@ function makeRegisterTools(deps = {}) {
321
335
  suggestions,
322
336
  note: 'Propose these to the talent to confirm; never write them without confirmation.',
323
337
  message: found
324
- ? 'The talent already allowed the search: read_cv the first candidate now (the most likely), with talentConsent:true and talentName if you know it. nameMatch:null only means their name is unknown, never that the CV is not theirs. Move to the next candidate only if read_cv says mentionsTalent:false; say you did not find it only when none is left.'
338
+ ? 'Now call read_cv on the first candidate (the most likely), with talentName if you know it: read_cv asks the talent itself before reading it, so ask nothing in your own words. nameMatch:null only means their name is unknown, never that the CV is not theirs. Move to the next candidate only if read_cv says mentionsTalent:false; say you did not find it only when none is left.'
325
339
  : 'No CV was found on this machine: ask the talent to attach it.',
326
340
  };
327
341
  }
328
342
 
329
- // Text of the CV the talent confirmed as theirs, read only with their permission.
330
- async function readCv(args = {}) {
331
- if (args.talentConsent !== true) return { ok: false, reason: 'consent-required', message: 'Ask the talent first whether this CV is theirs and whether you may read it; call again with talentConsent:true only after they say yes.' };
343
+ // Text of the CV file the talent allowed this tool to read, in its own question.
344
+ async function readCv(args = {}, ctx = {}) {
345
+ if (typeof args.path !== 'string' || !args.path.trim()) return { ok: false, reason: 'cv-not-found', message: 'Pass the path of the CV file.' };
346
+ const file = path.resolve(expandHome(args.path.trim()));
347
+ const c = copy().consent;
348
+ const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.yes, no: c.no, answer: args.answer });
349
+ if (asked.reply) return asked.reply;
350
+ if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: the file was not read. Do not read it; ask them to attach their CV in the chat or tell you what it says.' };
332
351
  const read = deps.readCvText || require('./cv-text').readCvText;
333
- const result = read(args.path, { talentName: args.talentName });
352
+ const result = read(file, { talentName: args.talentName });
334
353
  if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
335
354
  // Someone else's CV (a candidate they downloaded): never hand its contents over.
336
355
  if (result.mentionsTalent === false) return { ok: true, mentionsTalent: false, note: "This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; try the next candidate." };
@@ -340,27 +359,33 @@ function makeRegisterTools(deps = {}) {
340
359
  return [
341
360
  {
342
361
  name: 'suggest_register_context',
343
- description: "SUGGESTIONS for the sign-up read on this machine (no network, no file contents): timezone, git name/email, the gh CLI GitHub login, and cvCandidates: PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's name, each with nameMatch (null when their name is unknown) and generic flags. A candidate is only a lead (people who hire keep other people's CVs): confirm it with read_cv. Pass talentName to sharpen the match. Use it when the talent said you may look for their CV on this machine.",
344
- inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." } } },
362
+ title: 'Look for your CV on this computer',
363
+ description: 'Asks you first, then lists the names of PDF and Word files in your Desktop, Documents, Downloads and iCloud Drive folders that look like a CV and reads your time zone and the name, email and GitHub user saved by your coding tools. It does not open those files or send anything.',
364
+ guidance: "SUGGESTIONS for the sign-up read on this machine (no network, no file contents): timezone, git name/email, the gh CLI GitHub login, and cvCandidates: PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's name, each with nameMatch (null when their name is unknown) and generic flags. A candidate is only a lead (people who hire keep other people's CVs): confirm it with read_cv. Pass talentName to sharpen the match. The tool asks the talent first (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is searched until they answer yes. After a `say`, call it again with answer set to the option they pick. Never answer for them.",
365
+ inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." }, answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' } } },
345
366
  handler: suggestContext,
346
367
  },
347
368
  {
348
369
  name: 'read_cv',
349
- description: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine, with the talent's permission (talentConsent:true) and talentName set. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; a CV that does not name them returns only mentionsTalent:false: it is someone else's, drop it, never describe it, try the next candidate. Nothing is uploaded by this tool: pass the confirmed path to signup_start (or import_profile) as cvPath, use the text for userQuery and the email, and links.linkedin as their LinkedIn URL.",
370
+ title: 'Read your CV',
371
+ description: 'Asks you first, then reads the text of the CV file you allow to prepare your profile draft. This step sends nothing to Shakers.',
372
+ guidance: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine, with talentName set. The tool asks the talent first, 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 read until they answer yes. After a `say`, call it again with the same path and answer set to the option they pick. Never answer for them. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; a CV that does not name them returns only mentionsTalent:false: it is someone else's, drop it, never describe it, try the next candidate. Nothing is uploaded by this tool: pass the confirmed path to signup_start (or import_profile) as cvPath, use the text for userQuery and the email, and links.linkedin as their LinkedIn URL.",
350
373
  inputSchema: {
351
374
  type: 'object',
352
375
  properties: {
353
376
  path: { type: 'string', description: 'Path to the CV file, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
354
- talentConsent: { type: 'boolean', description: 'true ONLY after the talent allowed you to search for and read their CV.' },
377
+ answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' },
355
378
  talentName: { type: 'string', description: "The talent's full name as you know it; used to tell their CV from other people's." },
356
379
  },
357
- required: ['path', 'talentConsent'],
380
+ required: ['path'],
358
381
  },
359
382
  handler: readCv,
360
383
  },
361
384
  {
362
385
  name: 'onboarding_interview_start',
363
- description: "Start the onboarding interview, here in the chat. Offer it only when signup_status says onboardingInterview.completed is not true. Call it first without disclaimerAcknowledged: it asks 'here now or later on the web?' (in a dialog when the client has one, otherwise it returns `say` to print word for word; default here), then how they want to answer, with the two interview disclaimers in that same question. reason 'interview-later' means they chose the web: skip the interview. reason 'interview-on-web' (a sign-up language the chat interview does not speak) is the same: print relayVerbatim and skip it. If it returns reason 'interview-already-started' (begun elsewhere and not finished), call open_web so they continue on the web. It runs over LiveKit and the server agent conducts it; the client does not decide its branch. When it returns `say`, print it word for word as your whole reply (it holds the disclaimers and the question) and, once they answer, call with where:'here', disclaimerAcknowledged:true and answerMode. Returns { interviewId, answerMode, greeting, ended }: relay the greeting VERBATIM, then drive it with onboarding_interview_turn. ANSWER MODES. For drafted and full, first gather everything you know (memory, a search of past conversations if you have that tool, their CV, the ai_usage report, the sign-up data) and show ONE short sheet 'esto es lo que se de ti' (roles and projects with concrete facts, stack, languages, what they want next); let them correct it once; those corrections override your memory. Your answers are first person, one or two short sentences in their plain voice, built only from facts on the confirmed sheet: no embellishment, no adjectives, metrics or claims they did not give, no repeating a fact already said unless asked; for wishes, preferences or opinions give your best guess and say it is a guess. (1) drafted: show each question VERBATIM with your proposed answer and send it only after the talent approves or edits it; if the sheet does not cover a question, ask them briefly instead of guessing. Only if the talent explicitly asks you to answer everything yourself (for example 'hazla entera' or 'contéstalas tú todas'), answer the remaining questions without asking for each approval, as in full. (2) full (they chose it, or asked for it as above): answer every question yourself without per-answer approval, still showing each question and your answer, and when the interview ends tell them in one line that you answered those questions yourself. (3) own: you are a pure CONDUIT: show every question VERBATIM and pass back only their REAL, VERBATIM words. In every mode never reword, summarize or translate the questions. `interviewId` is internal: never show it. If it fails with next:'open_web', print relayVerbatim word for word and call open_web so they do the interview on the web.",
386
+ title: 'Start your onboarding interview',
387
+ description: 'Starts the short Shakers interview here in the chat, after asking you where and how you want to answer it. Your answers go to Shakers to get to know your profile.',
388
+ guidance: "Start the onboarding interview, here in the chat. Offer it only when signup_status says onboardingInterview.completed is not true. Call it first without disclaimerAcknowledged: it asks 'here now or later on the web?' (in a dialog when the client has one, otherwise it returns `say` to print word for word; default here), then how they want to answer, with the two interview disclaimers in that same question. reason 'interview-later' means they chose the web: skip the interview. reason 'interview-on-web' (a sign-up language the chat interview does not speak) is the same: print relayVerbatim and skip it. If it returns reason 'interview-already-started' (begun elsewhere and not finished), call open_web so they continue on the web. It runs over LiveKit and the server agent conducts it; the client does not decide its branch. When it returns `say`, print it word for word as your whole reply (it holds the disclaimers and the question) and, once they answer, call with where:'here', disclaimerAcknowledged:true and answerMode. Returns { interviewId, answerMode, greeting, ended }: relay the greeting VERBATIM, then drive it with onboarding_interview_turn. ANSWER MODES. For drafted and full, first gather everything you know (memory, a search of past conversations if you have that tool, their CV, the ai_usage report, the sign-up data) and show ONE short sheet 'esto es lo que se de ti' (roles and projects with concrete facts, stack, languages, what they want next); let them correct it once; those corrections override your memory. Your answers are first person, one or two short sentences in their plain voice, built only from facts on the confirmed sheet: no embellishment, no adjectives, metrics or claims they did not give, no repeating a fact already said unless asked; for wishes, preferences or opinions give your best guess and say it is a guess. (1) drafted: show each question VERBATIM with your proposed answer and send it only after the talent approves or edits it; if the sheet does not cover a question, ask them briefly instead of guessing. Only if the talent explicitly asks you to answer everything yourself (for example 'hazla entera' or 'contéstalas tú todas'), answer the remaining questions without asking for each approval, as in full. (2) full (they chose it, or asked for it as above): answer every question yourself without per-answer approval, still showing each question and your answer, and when the interview ends tell them in one line that you answered those questions yourself. (3) own: you are a pure CONDUIT: show every question VERBATIM and pass back only their REAL, VERBATIM words. In every mode never reword, summarize or translate the questions. `interviewId` is internal: never show it. If it fails with next:'open_web', print relayVerbatim word for word and call open_web so they do the interview on the web.",
364
389
  inputSchema: INTERVIEW_START_SCHEMA,
365
390
  handler: interviewStart,
366
391
  available: async () => {
@@ -373,25 +398,33 @@ function makeRegisterTools(deps = {}) {
373
398
  },
374
399
  {
375
400
  name: 'onboarding_interview_turn',
376
- description: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the answerMode the talent chose at onboarding_interview_start. drafted: `message` is the answer the talent APPROVED or edited after seeing your proposal; never send one they have not confirmed, unless they explicitly asked you to answer the rest yourself ('hazla entera', 'contéstalas tú todas'): from then on, as in full, send your own answers without per-answer approval. full: send your own answer to each question, built only from what they confirmed, and when it ends tell them you answered those questions yourself. own: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. Outside full, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
401
+ title: 'Answer an interview question',
402
+ description: 'Sends the answer you gave or approved for the current interview question to Shakers and brings the next one.',
403
+ guidance: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the answerMode the talent chose at onboarding_interview_start. drafted: `message` is the answer the talent APPROVED or edited after seeing your proposal; never send one they have not confirmed, unless they explicitly asked you to answer the rest yourself ('hazla entera', 'contéstalas tú todas'): from then on, as in full, send your own answers without per-answer approval. full: send your own answer to each question, built only from what they confirmed, and when it ends tell them you answered those questions yourself. own: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. Outside full, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
377
404
  inputSchema: INTERVIEW_TURN_SCHEMA,
378
405
  handler: interviewTurn,
379
406
  },
380
407
  {
381
408
  name: 'onboarding_interview_complete',
382
- description: "Close the onboarding interview once the turns report ended:true: the buffered transcript is saved and the live room is disconnected. `interviewId` is internal: never show it. Returns { state:'taken' }. If it returns reason 'unknown-interview' the room was already gone (server restart): nothing to complete. In the sign-up flow, go on to the main role (list_my_roles with waitSeconds, then set_main_role) and finish with open_web; do NOT call onboarding_finish there.",
409
+ title: 'Finish the interview',
410
+ description: 'Closes the interview and saves it on Shakers.',
411
+ guidance: "Close the onboarding interview once the turns report ended:true: the buffered transcript is saved and the live room is disconnected. `interviewId` is internal: never show it. Returns { state:'taken' }. If it returns reason 'unknown-interview' the room was already gone (server restart): nothing to complete. In the sign-up flow, go on to the main role (list_my_roles with waitSeconds, then set_main_role) and finish with open_web; do NOT call onboarding_finish there.",
383
412
  inputSchema: INTERVIEW_COMPLETE_SCHEMA,
384
413
  handler: interviewComplete,
385
414
  },
386
415
  {
387
416
  name: 'repeat_onboarding_interview',
388
- description: "OVERWRITE-repeat the onboarding interview: resets the talent's completed onboarding interview IN PLACE; the previous evaluation is DISCARDED and CANNOT be recovered. Clearly warn the talent that this is irreversible and get their explicit go-ahead BEFORE calling this. On success run the interview again (onboarding_interview_start, onboarding_interview_turn until ended, onboarding_interview_complete), then onboarding_finish. Returns { interviewId, language } or reason 'onboarding-not-found' (no onboarding yet), 'http-401'/'http-403'.",
417
+ title: 'Repeat your onboarding interview',
418
+ description: 'Deletes your previous onboarding interview on Shakers so you can take it again. This cannot be undone.',
419
+ guidance: "OVERWRITE-repeat the onboarding interview: resets the talent's completed onboarding interview IN PLACE; the previous evaluation is DISCARDED and CANNOT be recovered. Clearly warn the talent that this is irreversible and get their explicit go-ahead BEFORE calling this. On success run the interview again (onboarding_interview_start, onboarding_interview_turn until ended, onboarding_interview_complete), then onboarding_finish. Returns { interviewId, language } or reason 'onboarding-not-found' (no onboarding yet), 'http-401'/'http-403'.",
389
420
  inputSchema: { type: 'object', properties: {} },
390
421
  handler: repeatOnboarding,
391
422
  },
392
423
  {
393
424
  name: 'onboarding_finish',
394
- description: "Mark the onboarding as COMPLETED on Shakers. NOT part of the sign-up flow (signup_start): there, never call it. Use it only to close a repeat_onboarding_interview run. Idempotent. Returns { finalized, onboardingStatus, registrationLevel, completedProfilePercentage, profileUrl }.",
425
+ title: 'Complete your onboarding',
426
+ description: 'Marks your onboarding as completed on Shakers after you repeat the interview.',
427
+ guidance: "Mark the onboarding as COMPLETED on Shakers. NOT part of the sign-up flow (signup_start): there, never call it. Use it only to close a repeat_onboarding_interview run. Idempotent. Returns { finalized, onboardingStatus, registrationLevel, completedProfilePercentage, profileUrl }.",
395
428
  inputSchema: FINISH_SCHEMA,
396
429
  handler: onboardingFinish,
397
430
  },
@@ -33,7 +33,7 @@ function makeReportTools(deps = {}) {
33
33
  const requireSession = () => {
34
34
  const session = loadAuthSession();
35
35
  if (sessionStatus(session) !== 'active') {
36
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first.');
36
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
37
37
  }
38
38
  return session;
39
39
  };
@@ -85,13 +85,17 @@ function makeReportTools(deps = {}) {
85
85
  return [
86
86
  {
87
87
  name: 'report',
88
- description: "Two modes. stored:true returns the talent's ENTIRE stored server report (inventory + activity + AI-fluency level + prompting/interaction level) as plain text, read from certs WITHOUT re-running any scan — says so cleanly when none is stored (this stored view does NOT include the Setup×Usage matrix or the vision/how-I-work narratives). Otherwise builds the local report for this repo from local state: it returns the full human-readable report in `text` (Markdown mirroring the CLI terminal — Setup level and rank rationale, tools, MCP servers, agents, activity, plus the Setup×Usage matrix and vision/how-I-work when available) AND the HTML file path (no browser). Render `text` verbatim to the talent; keep the fenced blocks fenced so the tables stay aligned. Requires an active session. Any internal ids/codes are handles for tools only — never show or mention them to the talent.",
88
+ title: 'Show your AI report',
89
+ description: 'Shows your AI report, either the one saved on Shakers or one built from your last AI analysis on this computer, which it also saves there as a file.',
90
+ guidance: "Two modes. stored:true returns the talent's ENTIRE stored server report (inventory + activity + AI-fluency level + prompting/interaction level) as plain text, read from certs WITHOUT re-running any scan — says so cleanly when none is stored (this stored view does NOT include the Setup×Usage matrix or the vision/how-I-work narratives). Otherwise builds the local report for this repo from local state: it returns the full human-readable report in `text` (Markdown mirroring the CLI terminal — Setup level and rank rationale, tools, MCP servers, agents, activity, plus the Setup×Usage matrix and vision/how-I-work when available) AND the HTML file path (no browser). Render `text` verbatim to the talent; keep the fenced blocks fenced so the tables stay aligned. Requires an active session. Any internal ids/codes are handles for tools only — never show or mention them to the talent.",
89
91
  inputSchema: REPORT_SCHEMA,
90
92
  handler: report,
91
93
  },
92
94
  {
93
95
  name: 'share',
94
- description: "Generate the Shakers-branded LinkedIn share card for this repo and return its file path. Does not open a browser. Requires an active session.",
96
+ title: 'Create your share card',
97
+ description: 'Creates on this computer a card with your AI report to share on LinkedIn. It sends nothing.',
98
+ guidance: "Generate the Shakers-branded LinkedIn share card for this repo and return its file path. Does not open a browser. Requires an active session.",
95
99
  inputSchema: SHARE_SCHEMA,
96
100
  handler: share,
97
101
  },
@@ -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
  };
@@ -282,10 +283,18 @@ function makeRoleTools(deps = {}) {
282
283
  // This call's own list: another call may replace `offered` while the dialog is open.
283
284
  const options = shown.map((m) => m.role);
284
285
  offered = options;
285
- const reasons = shown
286
- .filter((m) => m.byRole.length || m.byStack.length)
287
- .map((m) => [c.roleName(m.role.name), m.byRole.length ? c.catalogueReason.role(m.byRole.join(', ')) : null, m.byStack.length ? c.catalogueReason.stack(m.byStack.join(', ')) : null].filter(Boolean).join('\n'));
288
- const asked = await askChoice(ctx, { texts: reasons.length ? [reasons.join('\n\n')] : [], question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
286
+ // Same block as the recommended roles: intro, the best match marked, its reason, its open projects, then what the main profile is.
287
+ const fits = shown.filter((m) => m.byRole.length || m.byStack.length);
288
+ const session = loadAuthSession();
289
+ const counts = fits.length ? await openCounts(session && session.hubAccessToken, fits.map((m) => m.role)) : [];
290
+ const blocks = fits.map((m, i) => [
291
+ i === 0 ? c.roleRecommended(m.role.name) : c.roleName(m.role.name),
292
+ m.byRole.length ? c.catalogueReason.role(m.byRole.join(', ')) : null,
293
+ m.byStack.length ? c.catalogueReason.stack(m.byStack.join(', ')) : null,
294
+ typeof counts[i] === 'number' && counts[i] > 0 ? c.openProjects(counts[i]) : null,
295
+ ].filter(Boolean).join('\n'));
296
+ const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
297
+ const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
289
298
  if (asked.answer === c.mainRoleNotListed) return askOwnRole();
290
299
  const role = asked.answer && options.find((r) => r.name === asked.answer);
291
300
  if (role) {
@@ -293,7 +302,7 @@ function makeRoleTools(deps = {}) {
293
302
  return { role, add: true };
294
303
  }
295
304
  offered = options;
296
- return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, [...options.map((r) => r.name), c.mainRoleNotListed]) }, options, `Pass catalogue:true too: it adds the role first. If they pick the last option, call set_main_role with catalogue:true and answer = that option.`) };
305
+ return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, [...options.map((r) => r.name), c.mainRoleNotListed], texts) }, options, `Pass catalogue:true too: it adds the role first. If they pick the last option, call set_main_role with catalogue:true and answer = that option.`) };
297
306
  }
298
307
 
299
308
  async function setMainRole(args = {}, ctx = {}) {
@@ -309,37 +318,51 @@ function makeRoleTools(deps = {}) {
309
318
  const added = await addRole({ clusterId: picked.role.clusterId });
310
319
  if (!added.ok) return added;
311
320
  }
312
- const set = await setMainRole({ clusterId: picked.role.clusterId });
321
+ const set = await applyMainRole(session, picked.role.clusterId, { suggestion: !picked.add });
313
322
  return set.ok ? { ...set, name: picked.role.name } : set;
314
323
  }
315
- 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() });
316
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.' };
317
330
  if (!res.ok) return { ok: false, reason: res.reason };
318
- return { ok: true, clusterId: args.clusterId };
331
+ funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
332
+ funnel.flush();
333
+ return { ok: true, clusterId };
319
334
  }
320
335
 
321
336
  return [
322
337
  {
323
338
  name: 'list_available_roles',
324
- 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.",
325
342
  inputSchema: EMPTY_SCHEMA,
326
343
  handler: listAvailableRoles,
327
344
  },
328
345
  {
329
346
  name: 'add_role',
330
- 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? }.",
331
350
  inputSchema: CLUSTER_SCHEMA,
332
351
  handler: addRole,
333
352
  },
334
353
  {
335
354
  name: 'list_my_roles',
336
- 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.",
337
358
  inputSchema: LIST_MY_ROLES_SCHEMA,
338
359
  handler: listMyRoles,
339
360
  },
340
361
  {
341
362
  name: 'set_main_role',
342
- 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.",
343
366
  inputSchema: SET_MAIN_ROLE_SCHEMA,
344
367
  handler: setMainRole,
345
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') {
@@ -86,10 +86,14 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
86
86
  }
87
87
  }
88
88
 
89
- // An `elicitation` capability without `form` but with `url` only opens links, so it cannot ask a closed question.
89
+ // The spec (2025-06-18) advertises elicitation support as a presence flag: `capabilities.elicitation: {}`.
90
+ // So any advertised elicitation can ask a closed question — except an explicit url-only object
91
+ // (`{ url }` without `{ form }`), which only opens links and falls back to the chat.
90
92
  function canElicitForm() {
91
93
  const e = clientCapabilities.elicitation;
92
- return !!e && typeof e === 'object' && (e.form !== undefined || e.url === undefined);
94
+ if (!e) return false;
95
+ if (typeof e === 'object' && e.url !== undefined && e.form === undefined) return false;
96
+ return true;
93
97
  }
94
98
 
95
99
  // MCP elicitation/create: the client's { action, content }, or null when it has no dialog or the exchange failed.
@@ -103,11 +107,14 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
103
107
  }
104
108
  }
105
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.
106
111
  function shapeTool(t) {
112
+ const schema = t.inputSchema || { type: 'object' };
107
113
  return {
108
114
  name: t.name,
115
+ ...(t.title ? { title: t.title } : {}),
109
116
  description: t.description || '',
110
- inputSchema: t.inputSchema || { type: 'object' },
117
+ inputSchema: t.guidance ? { ...schema, description: [t.guidance, schema.description].filter(Boolean).join(' ') } : schema,
111
118
  };
112
119
  }
113
120
 
@@ -201,6 +208,12 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
201
208
  case 'initialize': {
202
209
  const requested = msg.params && msg.params.protocolVersion;
203
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
+ }
214
+ // One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
215
+ // declared elicitation shape and the resulting decision are visible in the MCP log.
216
+ process.stderr.write(` [mcp] initialize elicitation=${JSON.stringify(clientCapabilities.elicitation)} -> elicit=${canElicitForm()}\n`);
204
217
  const text = typeof instructions === 'function' ? instructions({ elicitation: canElicitForm() }) : instructions;
205
218
  return resultResponse(id, {
206
219
  protocolVersion: requested || advertisedVersion,
@@ -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
  },