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
@@ -11,7 +11,7 @@ function buildServerInstructions({ elicitation = false } = {}) {
11
11
  ? 'Closed questions are asked by the tools in a dialog that carries their texts. Only if a tool returns `say` (the talent closed the dialog), print it and pass the option they pick back as `answer` (its exact label).'
12
12
  : 'Closed questions come as `say`: the fixed texts, the question and its options as a list; print it, then pass the option they pick back as `answer` (its exact label).',
13
13
  'Sign-up flow:',
14
- '1. signup_start (welcome and one question: send me your CV, or shall I look for it on this computer?); a CV path in their first message goes as cvPath and skips the question. Only if they pick the search, find and read their CV (suggest_register_context, read_cv) without asking again; otherwise wait for the CV in the chat.',
14
+ '1. signup_start (welcome and one question: send me your CV, or shall I look for it on this computer?); a CV path in their first message goes as cvPath and skips the question. Only if they pick the search, find and read their CV (suggest_register_context, then read_cv); otherwise wait for the CV in the chat. suggest_register_context, read_cv, import_profile with a CV file and add_project each ask the talent themselves, naming what they read or send, before doing anything: never ask that in your own words and never pass an answer the talent did not give.',
15
15
  '2. LinkedIn: take it from the CV. If it has none, signup_start with linkedinMissing:true returns the question to print; never ask it in your own words. Only if they say they do not know where to find it, say the linkedinOpening text and call open_linkedin_profile.',
16
16
  '3. signup_email with the best email you know (CV first), or none.',
17
17
  '4. signup_draft with your draft of their profile (name, role, city, years, stack, languages, work mode, hours, hourly and annual rate in euros, both always: the talent\'s figures with ratesFromTalent:true, else your estimate; sources: cv and/or linkedin, only those you have). It shows the draft, the data notice and how the account window works, and asks them to confirm; if they want changes, ask what and call it again.',
@@ -14,7 +14,7 @@ function makeInvitationsTools(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_invitations is session-gated).');
17
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (list_invitations is session-gated).');
18
18
  }
19
19
  return session;
20
20
  };
@@ -29,7 +29,9 @@ function makeInvitationsTools(deps = {}) {
29
29
  return [
30
30
  {
31
31
  name: 'list_invitations',
32
- description: "List the talent's unread position invitations (projects that invited them). Requires an active session. Any projectId/chatId in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a project by its name.",
32
+ title: 'Your invitations',
33
+ description: 'Shows the projects that invited you and that you have not read yet.',
34
+ guidance: "List the talent's unread position invitations (projects that invited them). Requires an active session. Any projectId/chatId in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a project by its name.",
33
35
  inputSchema: LIST_INVITATIONS_SCHEMA,
34
36
  handler: listInvitations,
35
37
  },
@@ -14,7 +14,7 @@ function makeLangTools(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_languages is session-gated).');
17
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (get_languages is session-gated).');
18
18
  }
19
19
  return session;
20
20
  };
@@ -29,7 +29,9 @@ function makeLangTools(deps = {}) {
29
29
  return [
30
30
  {
31
31
  name: 'get_languages',
32
- description: "View the talent's languages and proficiency levels (NATIVE/ADVANCED/INTERMEDIATE/INTERMEDIATE_WRITTEN). Read-only. Requires an active session. Any language ids in the result are internal handles for tools only — never show or mention them to the talent; refer to a language by its name.",
32
+ title: 'Your languages',
33
+ description: 'Shows the languages and levels on your Shakers profile.',
34
+ guidance: "View the talent's languages and proficiency levels (NATIVE/ADVANCED/INTERMEDIATE/INTERMEDIATE_WRITTEN). Read-only. Requires an active session. Any language ids in the result are internal handles for tools only — never show or mention them to the talent; refer to a language by its name.",
33
35
  inputSchema: GET_LANGUAGES_SCHEMA,
34
36
  handler: getLanguages,
35
37
  },
@@ -1,13 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('path');
4
-
5
- const LOGIN_SCHEMA = {
6
- type: 'object',
7
- properties: {
8
- method: { type: 'string', enum: ['email', 'google', 'linkedin'], description: "Optional auth method. None of them sign in here: this tool points you to social_signin_start/social_signin_poll (the device flow) with that provider. 'email' reuses the talent's open Shakers web session or signs them in on the page (never a password in the chat); 'google'/'linkedin' open that social sign-in. Omit it to default to email (the page still offers Google and LinkedIn)." },
9
- },
10
- };
4
+ const { askConsent } = require('./mcp-choice');
5
+ const { tildify } = require('./env-paths');
11
6
 
12
7
  const LOGOUT_SCHEMA = { type: 'object', properties: {} };
13
8
 
@@ -58,6 +53,7 @@ const ADD_PROJECT_SCHEMA = {
58
53
  clientName: { type: 'string', description: 'Client name (optional, e.g. for freelance work).' },
59
54
  clientDomain: { type: 'string', description: "Client website or domain (optional, e.g. 'acme.com' or a full URL — normalized to a bare domain). Hub generates the client logo from it." },
60
55
  skillIds: { type: 'array', items: { type: 'integer' } },
56
+ answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' },
61
57
  },
62
58
  };
63
59
 
@@ -89,6 +85,7 @@ function makeOnboardingTools(deps = {}) {
89
85
  requestUpdatePortfolioSkills = require('./portfolio-client').requestUpdatePortfolioSkills,
90
86
  getRemoteUrl = require('./portfolio-git-info').getRemoteUrl,
91
87
  getCommitDateRange = require('./portfolio-git-info').getCommitDateRange,
88
+ consentCopy = () => require('./signup-copy').signupCopy(require('./signup-language').signupLanguage(require('./i18n').detectFlowLang())).consent,
92
89
  normalizeDomain = require('./start-add-portfolio').normalizeDomain,
93
90
  isValidEmail = require('./share').isValidEmail,
94
91
  normalizeEmail = require('./share').normalizeEmail,
@@ -97,7 +94,7 @@ function makeOnboardingTools(deps = {}) {
97
94
  const requireSession = () => {
98
95
  const session = loadAuthSession();
99
96
  if (sessionStatus(session) !== 'active') {
100
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first.');
97
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
101
98
  }
102
99
  return session;
103
100
  };
@@ -117,25 +114,6 @@ function makeOnboardingTools(deps = {}) {
117
114
  return endpoint;
118
115
  };
119
116
 
120
- // Every method runs through the device flow (start/poll tools); a blocking form
121
- // can't surface the code, and email now reuses the web session there instead of
122
- // prompting for a password. The start/poll pair persists the session.
123
- const socialSigninPointer = (mode, provider = 'email') => ({
124
- ok: false,
125
- reason: 'use-device-flow',
126
- next: 'social_signin_start',
127
- mode,
128
- provider,
129
- message: `For ${provider}, call social_signin_start (provider "${provider}", mode "${mode}") to get a URL and code to show the talent, then social_signin_poll.`,
130
- });
131
-
132
- // Email, Google and LinkedIn all hand off to social_signin_start/poll. Email
133
- // defaults when no method is given; the /cli-login page still offers the others.
134
- async function login(args = {}) {
135
- const provider = args.method === 'google' || args.method === 'linkedin' ? args.method : 'email';
136
- return socialSigninPointer('login', provider);
137
- }
138
-
139
117
  async function logout() {
140
118
  const wasLoggedIn = loadAuthSession() !== null;
141
119
  try { clearAuthSession(); } catch { /* idempotent — nothing to clear */ }
@@ -299,10 +277,11 @@ function makeOnboardingTools(deps = {}) {
299
277
  return { ...base, portfolios, related, relateFailures };
300
278
  }
301
279
 
302
- async function addProject(args = {}) {
280
+ // Sends the project only after the talent says yes to exactly what goes: name, web address, dates, client.
281
+ async function addProject(args = {}, ctx = {}) {
303
282
  const session = requireSession();
304
283
  const endpoint = requireEndpoint(getPortfoliosDeclareEndpoint, 'portfolios-declare');
305
- const root = typeof args.root === 'string' && args.root ? args.root : process.cwd();
284
+ const root = path.resolve(typeof args.root === 'string' && args.root ? args.root : process.cwd());
306
285
  const name = typeof args.name === 'string' && args.name ? args.name : path.basename(root);
307
286
  const type = args.type === 'EXPERIENCE' ? 'EXPERIENCE' : 'PORTFOLIO';
308
287
  let url = typeof args.url === 'string' && args.url ? args.url : null;
@@ -312,14 +291,29 @@ function makeOnboardingTools(deps = {}) {
312
291
  let startDate;
313
292
  let endDate;
314
293
  try { ({ startDate, endDate } = getCommitDateRange(root)); } catch { startDate = null; endDate = null; }
294
+ const clientName = typeof args.clientName === 'string' && args.clientName ? args.clientName : null;
295
+ const clientDomain = normalizeDomain(args.clientDomain) || null;
296
+ const description = typeof args.description === 'string' && args.description ? args.description : null;
297
+ const sent = { name, type, url, startDate, endDate, clientName, clientDomain, description };
298
+ const c = consentCopy();
299
+ const asked = await askConsent(ctx, {
300
+ tool: 'add_project',
301
+ key: `add_project:${root}:${JSON.stringify(sent)}`,
302
+ question: c.project({ folder: tildify(root), name, url, startDate, endDate, client: [clientName, clientDomain].filter(Boolean).join(', '), description }),
303
+ yes: c.yes,
304
+ no: c.no,
305
+ answer: args.answer,
306
+ });
307
+ if (asked.reply) return asked.reply;
308
+ if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was sent. Do not add the project.' };
315
309
  const result = await requestDeclarePortfolio(
316
310
  {
317
311
  name,
318
312
  type,
319
- description: typeof args.description === 'string' ? args.description : undefined,
313
+ description: description || undefined,
320
314
  url: url || undefined,
321
- clientName: typeof args.clientName === 'string' && args.clientName ? args.clientName : undefined,
322
- clientDomain: normalizeDomain(args.clientDomain) || undefined,
315
+ clientName: clientName || undefined,
316
+ clientDomain: clientDomain || undefined,
323
317
  startDate: startDate || undefined,
324
318
  endDate: endDate || undefined,
325
319
  skillIds: Array.isArray(args.skillIds) ? args.skillIds : undefined,
@@ -332,39 +326,43 @@ function makeOnboardingTools(deps = {}) {
332
326
  }
333
327
 
334
328
  return [
335
- {
336
- name: 'login',
337
- description: "Start signing the talent in to Shakers so the add_* and certify tools can run. This tool does not sign in itself: it points you to social_signin_start + social_signin_poll (the device flow), which open the Shakers web page and persist the session. Email (the default) reuses the talent's open web session or signs them in on the page — never a password in the chat; 'google'/'linkedin' open that social sign-in. Returns a { ok: false, reason: 'use-device-flow', next, provider } pointer; call social_signin_start next.",
338
- inputSchema: LOGIN_SCHEMA,
339
- handler: login,
340
- },
341
329
  {
342
330
  name: 'logout',
343
- description: "Sign the talent out of Shakers by clearing the persisted session. Idempotent — returns { ok: true, wasLoggedIn } whether or not a session existed.",
331
+ title: 'Sign out of Shakers',
332
+ description: 'Signs you out by deleting the Shakers sign-in saved on this computer.',
333
+ guidance: "Sign the talent out of Shakers by clearing the persisted session. Idempotent — returns { ok: true, wasLoggedIn } whether or not a session existed.",
344
334
  inputSchema: LOGOUT_SCHEMA,
345
335
  handler: logout,
346
336
  },
347
337
  {
348
338
  name: 'list_addable',
349
- description: "List the skills and AI agents that Shakers discovered from the talent's submitted ai_usage report and that can be added to their Shakers profile. Use it to offer choices before add_skill / add_agent. Present skills and agents to the talent by NAME only; skillId, agent code and catalogId are internal handles for the add_* tools — never show raw IDs or codes. Requires an active session.",
339
+ title: 'Skills and agents you can add',
340
+ description: 'Shows the skills and AI agents found in the AI analysis you sent to Shakers, so you can pick which ones go on your profile.',
341
+ guidance: "List the skills and AI agents that Shakers discovered from the talent's submitted ai_usage report and that can be added to their Shakers profile. Use it to offer choices before add_skill / add_agent. Present skills and agents to the talent by NAME only; skillId, agent code and catalogId are internal handles for the add_* tools — never show raw IDs or codes. Requires an active session.",
350
342
  inputSchema: LIST_ADDABLE_SCHEMA,
351
343
  handler: listAddable,
352
344
  },
353
345
  {
354
346
  name: 'add_skill',
355
- description: "Add a discovered skill to the talent's Shakers profile by skillId (taken from list_addable). skillId is an internal handle — refer to the skill by its name when talking to the talent, never show the id. Optionally pass relatedPortfolioIds to relate it to one or more of the talent's portfolio experiences (matches the web's 'Relate to an experience' step) — the response always includes `portfolios` (id/name/type) so you can ask the talent which one(s) on a later call. If the talent has no portfolios yet, the skill is still added, unrelated. Requires an active session.",
347
+ title: 'Add a skill to your profile',
348
+ description: 'Adds the skill you pick to your Shakers profile.',
349
+ guidance: "Add a discovered skill to the talent's Shakers profile by skillId (taken from list_addable). skillId is an internal handle — refer to the skill by its name when talking to the talent, never show the id. Optionally pass relatedPortfolioIds to relate it to one or more of the talent's portfolio experiences (matches the web's 'Relate to an experience' step) — the response always includes `portfolios` (id/name/type) so you can ask the talent which one(s) on a later call. If the talent has no portfolios yet, the skill is still added, unrelated. Requires an active session.",
356
350
  inputSchema: ADD_SKILL_SCHEMA,
357
351
  handler: addSkill,
358
352
  },
359
353
  {
360
354
  name: 'add_agent',
361
- description: "Add a discovered AI agent to the talent's Shakers profile by name (from list_addable). Optionally pass whatItDoes and humanDecides (what the talent does around the agent — supervision/validation). Optionally pass relatedPortfolios ({ portfolioId }) to relate it to the talent's experiences (matches the web's 'Relate to an experience' step) — the response always includes `portfolios` (id/name/type) so you can ask which one(s) on a later call. If the talent has no experiences yet, the agent is still added, unrelated. Requires an active session.",
355
+ title: 'Add an AI agent to your profile',
356
+ description: 'Adds the AI agent you pick, with the description you agree on, to your Shakers profile.',
357
+ guidance: "Add a discovered AI agent to the talent's Shakers profile by name (from list_addable). Optionally pass whatItDoes and humanDecides (what the talent does around the agent — supervision/validation). Optionally pass relatedPortfolios ({ portfolioId }) to relate it to the talent's experiences (matches the web's 'Relate to an experience' step) — the response always includes `portfolios` (id/name/type) so you can ask which one(s) on a later call. If the talent has no experiences yet, the agent is still added, unrelated. Requires an active session.",
362
358
  inputSchema: ADD_AGENT_SCHEMA,
363
359
  handler: addAgent,
364
360
  },
365
361
  {
366
362
  name: 'add_project',
367
- description: "Add the current repository to the talent's Shakers portfolio as a project. Name and URL default to the repo directory and its git remote, and the start/end dates are derived from the repo's git history. Optionally pass clientName and clientDomain (the client's website/domain, e.g. 'acme.com' — Shakers fetches the client logo from it). Requires an active session. Any ids/codes in the result are internal handles for tools only — never show or mention them to the talent.",
363
+ title: 'Add this project to your portfolio',
364
+ description: 'Reads the name of the project folder open here, the web address of its code and the dates of its first and last change. It asks you first, naming all of them, and only then sends them to Shakers as a new portfolio project.',
365
+ guidance: "Add the current repository to the talent's Shakers portfolio as a project. Name and URL default to the repo directory and its git remote, and the start/end dates are derived from the repo's git history. Optionally pass clientName and clientDomain (the client's website/domain, e.g. 'acme.com' — Shakers fetches the client logo from it). The tool asks the talent first, naming the folder, web address, dates and client it will send (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. Requires an active session. Any ids/codes in the result are internal handles for tools only — never show or mention them to the talent.",
368
366
  inputSchema: ADD_PROJECT_SCHEMA,
369
367
  handler: addProject,
370
368
  },
@@ -373,7 +371,6 @@ function makeOnboardingTools(deps = {}) {
373
371
 
374
372
  module.exports = {
375
373
  makeOnboardingTools,
376
- LOGIN_SCHEMA,
377
374
  LOGOUT_SCHEMA,
378
375
  LIST_ADDABLE_SCHEMA,
379
376
  ADD_SKILL_SCHEMA,
@@ -15,7 +15,7 @@ function makePortfolioTools(deps = {}) {
15
15
  const requireLoginSession = (tool) => {
16
16
  const session = loadAuthSession();
17
17
  if (sessionStatus(session) !== 'active') {
18
- throw new Error(`no active Shakers session — call the login tool (or run \`shakers login\`) first (${tool} is session-gated).`);
18
+ throw new Error(`no active Shakers session — sign in with social_signin_start (or run \`shakers login\`) first (${tool} is session-gated).`);
19
19
  }
20
20
  return session;
21
21
  };
@@ -30,13 +30,17 @@ function makePortfolioTools(deps = {}) {
30
30
  return [
31
31
  {
32
32
  name: 'list_experiences',
33
- description: "List the talent's work experiences (company, role/title, dates, location, skills). Read-only. Requires an active session. Any ids in the result are internal handles for tools only — never show or mention them to the talent.",
33
+ title: 'Your work experience',
34
+ description: 'Shows the work experience on your Shakers profile.',
35
+ guidance: "List the talent's work experiences (company, role/title, dates, location, skills). Read-only. Requires an active session. Any ids in the result are internal handles for tools only — never show or mention them to the talent.",
34
36
  inputSchema: SCHEMA,
35
37
  handler: lister('experiences', 'list_experiences'),
36
38
  },
37
39
  {
38
40
  name: 'list_portfolios',
39
- description: "List the talent's portfolio showcase pieces (name, dates, location, skills, link). Read-only. Requires an active session. Any ids in the result are internal handles for tools only — never show or mention them to the talent.",
41
+ title: 'Your portfolio',
42
+ description: 'Shows the portfolio projects on your Shakers profile.',
43
+ guidance: "List the talent's portfolio showcase pieces (name, dates, location, skills, link). Read-only. Requires an active session. Any ids in the result are internal handles for tools only — never show or mention them to the talent.",
40
44
  inputSchema: SCHEMA,
41
45
  handler: lister('portfolios', 'list_portfolios'),
42
46
  },
@@ -17,7 +17,7 @@ function makeProfileTools(deps = {}) {
17
17
  const requireLoginSession = () => {
18
18
  const session = loadAuthSession();
19
19
  if (sessionStatus(session) !== 'active') {
20
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (get_profile is session-gated).');
20
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (get_profile is session-gated).');
21
21
  }
22
22
  return session;
23
23
  };
@@ -33,7 +33,9 @@ function makeProfileTools(deps = {}) {
33
33
  return [
34
34
  {
35
35
  name: 'get_profile',
36
- description: "Get the talent's summary: main role, per-project rate, and the 'My work with AI' read model (setup/usage tier+level, 3x3 cell, vision, how-I-work). Requires an active session. Present it in human terms; any internal ids/codes in the result are handles for tools only — never show or mention them to the talent.",
36
+ title: 'Your Shakers profile',
37
+ description: 'Shows your main role, your rate and your "My work with AI" summary on Shakers.',
38
+ guidance: "Get the talent's summary: main role, per-project rate, and the 'My work with AI' read model (setup/usage tier+level, 3x3 cell, vision, how-I-work). Requires an active session. Present it in human terms; any internal ids/codes in the result are handles for tools only — never show or mention them to the talent.",
37
39
  inputSchema: GET_PROFILE_SCHEMA,
38
40
  handler: getProfile,
39
41
  },
@@ -46,10 +46,10 @@ const PROMPTS = [
46
46
  {
47
47
  name: 'ask',
48
48
  description: 'Ask Alma, the Shakers AI assistant, a question.',
49
- tools: ['ask'],
49
+ tools: ['alma_chat'],
50
50
  arguments: [{ name: 'message', description: 'What to ask Alma.', required: false }],
51
51
  buildMessages: (args = {}) => userText(
52
- `Ask Alma (the Shakers assistant) and relay her answer as-is. My question: ${typeof args.message === 'string' && args.message ? args.message : '(ask me what I want to know first)'}`,
52
+ `Ask Alma (the Shakers assistant) with \`alma_chat\` and relay her answer as-is. My question: ${typeof args.message === 'string' && args.message ? args.message : '(ask me what I want to know first)'}`,
53
53
  ),
54
54
  },
55
55
  {
@@ -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
 
@@ -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
  },