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
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const http = require('http');
4
+ const crypto = require('crypto');
4
5
  const { openPath } = require('./open-file');
5
6
  const flow = require('./onboarding-flow');
6
7
  const { methodIdsForFlow } = require('./auth-methods');
@@ -184,7 +185,7 @@ function loginFormHtml(lang, { error } = {}) {
184
185
  const c = copy(lang);
185
186
  return page(`Shakers · ${c.signin}`, {
186
187
  inner: `<h1>${c.signin}</h1>${errorBanner(errorText(c, error))}`
187
- + `<form method="post" action="/submit">`
188
+ + `<form method="post" action="submit">`
188
189
  + `<label>${c.email}</label><input name="email" type="email" required autofocus>`
189
190
  + `<label>${c.password}</label><input name="password" type="password" required>`
190
191
  + `<button type="submit">${c.signin}</button>`
@@ -205,7 +206,7 @@ function registerFormHtml(lang, { error, missing } = {}) {
205
206
  return page(`Shakers · ${c.create}`, {
206
207
  banner: panel(c.welcome),
207
208
  inner: `<h1>${c.create}</h1>${errorBanner(errorText(c, error))}`
208
- + `<form method="post" action="/submit">`
209
+ + `<form method="post" action="submit">`
209
210
  + missingIdentityInputs(c, missing).replace('required', 'required autofocus')
210
211
  + `<label>${c.password}</label><input name="password" type="password" required minlength="8"${missing && (missing.email || missing.name || missing.lastName) ? '' : ' autofocus'}>`
211
212
  + `<label>${c.repeat}</label><input name="passwordConfirm" type="password" required minlength="8">`
@@ -250,9 +251,9 @@ function methodChoiceHtml(mode, lang, methods) {
250
251
  const emailLabel = mode === 'register' ? c.continueEmailRegister : c.continueEmail;
251
252
  const register = mode === 'register';
252
253
  const buttons = WINDOW_METHODS[mode].filter((id) => methods.includes(id)).map((id) => {
253
- if (id === 'google') return `<a class="mbtn primary" href="/google${register ? '?legal=1' : ''}">${GOOGLE_G}${c.continueGoogle}</a>`;
254
- if (id === 'linkedin') return `<a class="mbtn secondary" href="/linkedin">${LINKEDIN_IN}${c.continueLinkedin}</a>`;
255
- return `<a class="mbtn secondary" href="/email">${emailLabel}</a>`;
254
+ if (id === 'google') return `<a class="mbtn primary" href="google${register ? '?legal=1' : ''}">${GOOGLE_G}${c.continueGoogle}</a>`;
255
+ if (id === 'linkedin') return `<a class="mbtn secondary" href="linkedin">${LINKEDIN_IN}${c.continueLinkedin}</a>`;
256
+ return `<a class="mbtn secondary" href="email">${emailLabel}</a>`;
256
257
  }).join('');
257
258
  return page(`Shakers · ${title}`, {
258
259
  inner: `<h1>${title}</h1><div class="actions">${buttons}</div><p class="hint">${c.hint}</p>${register ? legalHtml(lang) : ''}`,
@@ -264,12 +265,12 @@ function existingAccountHtml(lang, { email, error } = {}) {
264
265
  const c = copy(lang);
265
266
  return page(`Shakers · ${c.signin}`, {
266
267
  inner: `<h1>${c.signin}</h1>${errorBanner(error ? errorText(c, error) : c.existing)}`
267
- + `<form method="post" action="/submit"><input type="hidden" name="intent" value="login">`
268
+ + `<form method="post" action="submit"><input type="hidden" name="intent" value="login">`
268
269
  + `<label>${c.email}</label><input name="email" type="email" required value="${esc(email || '')}">`
269
270
  + `<label>${c.password}</label><input name="password" type="password" required autofocus>`
270
271
  + `<button type="submit">${c.signin}</button>`
271
272
  + `</form>`
272
- + `<div class="actions"><a class="mbtn secondary" href="/google?legal=1">${GOOGLE_G}${c.continueGoogle}</a></div>`
273
+ + `<div class="actions"><a class="mbtn secondary" href="google?legal=1">${GOOGLE_G}${c.continueGoogle}</a></div>`
273
274
  + `<p class="hint">${c.hint}</p>`,
274
275
  });
275
276
  }
@@ -371,7 +372,25 @@ function runLoopbackAuth(
371
372
  resolve(result);
372
373
  };
373
374
 
375
+ // Any page in the talent's browser can reach a loopback port, and a rebound DNS name can read it: only this
376
+ // window's own host, its own origin and its unguessable path get an answer. Every link in the pages is relative.
377
+ const secretPath = `/${crypto.randomBytes(32).toString('base64url')}`;
378
+ let port = null;
379
+ const ownHosts = () => [`127.0.0.1:${port}`, `localhost:${port}`];
380
+ const forbidden = (req) => {
381
+ if (!ownHosts().includes(String(req.headers.host || '').toLowerCase())) return true;
382
+ if (req.method === 'GET') return false;
383
+ const origin = req.headers.origin;
384
+ if (origin !== undefined && !ownHosts().map((h) => `http://${h}`).includes(origin)) return true;
385
+ const site = req.headers['sec-fetch-site'];
386
+ return site !== undefined && site !== 'same-origin';
387
+ };
388
+
374
389
  const server = http.createServer(async (req, res) => {
390
+ if (forbidden(req)) {
391
+ res.writeHead(403).end();
392
+ return;
393
+ }
375
394
  let url;
376
395
  try {
377
396
  url = new URL(req.url, 'http://127.0.0.1');
@@ -379,7 +398,17 @@ function runLoopbackAuth(
379
398
  res.writeHead(400).end();
380
399
  return;
381
400
  }
382
- if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
401
+ if (url.pathname === secretPath) {
402
+ res.writeHead(302, { Location: `${secretPath}/${url.search}` });
403
+ res.end();
404
+ return;
405
+ }
406
+ if (!url.pathname.startsWith(`${secretPath}/`)) {
407
+ res.writeHead(404).end();
408
+ return;
409
+ }
410
+ url.pathname = url.pathname.slice(secretPath.length);
411
+ if (req.method === 'GET' && url.pathname === '/') {
383
412
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
384
413
  res.end(showMethodScreen ? methodChoiceHtml(mode, lang, methods) : formHtml(mode, lang, formOpts));
385
414
  return;
@@ -440,8 +469,8 @@ function runLoopbackAuth(
440
469
  if (timer.unref) timer.unref();
441
470
  server.on('error', () => finish({ ok: false, reason: 'local-error' }));
442
471
  server.listen(0, '127.0.0.1', () => {
443
- const { port } = server.address();
444
- const formUrl = `http://127.0.0.1:${port}/`;
472
+ ({ port } = server.address());
473
+ const formUrl = `http://127.0.0.1:${port}${secretPath}/`;
445
474
  if (typeof onUrl === 'function') onUrl(formUrl);
446
475
  try { openBrowser(formUrl); } catch { /* headless: the onUrl fallback covers it */ }
447
476
  });
@@ -15,7 +15,7 @@ function makeAvailabilityTools(deps = {}) {
15
15
  const requireLoginSession = () => {
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 (get_availability is session-gated).');
18
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (get_availability is session-gated).');
19
19
  }
20
20
  return session;
21
21
  };
@@ -30,7 +30,9 @@ function makeAvailabilityTools(deps = {}) {
30
30
  return [
31
31
  {
32
32
  name: 'get_availability',
33
- description: "View the talent's availability: open-to-work, monthly hours, work modes (a combinable array of REMOTE/HYBRID/IN_PERSON), and location. Read-only. Requires an active session. Any internal ids in the result (e.g. numeric city id) are handles for tools only — never show or mention them to the talent.",
33
+ title: 'Your availability',
34
+ description: 'Shows your availability on Shakers: whether you are open to work, hours per month, work mode and location.',
35
+ guidance: "View the talent's availability: open-to-work, monthly hours, work modes (a combinable array of REMOTE/HYBRID/IN_PERSON), and location. Read-only. Requires an active session. Any internal ids in the result (e.g. numeric city id) are handles for tools only — never show or mention them to the talent.",
34
36
  inputSchema: GET_AVAILABILITY_SCHEMA,
35
37
  handler: getAvailability,
36
38
  },
@@ -14,7 +14,7 @@ function makeCertificationsTools(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_certifications is session-gated).');
17
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (list_certifications is session-gated).');
18
18
  }
19
19
  return session;
20
20
  };
@@ -29,7 +29,9 @@ function makeCertificationsTools(deps = {}) {
29
29
  return [
30
30
  {
31
31
  name: 'list_certifications',
32
- description: "List the talent's role dimensions with their certification state (CERTIFIED/UNCERTIFIED/EXPIRED), band and main role. Requires an active session. Any dimension/cluster ids in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a dimension by its name.",
32
+ title: 'Your certifications',
33
+ description: 'Shows which areas of your roles are certified on Shakers.',
34
+ guidance: "List the talent's role dimensions with their certification state (CERTIFIED/UNCERTIFIED/EXPIRED), band and main role. Requires an active session. Any dimension/cluster ids in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a dimension by its name.",
33
35
  inputSchema: LIST_CERTIFICATIONS_SCHEMA,
34
36
  handler: listCertifications,
35
37
  },
@@ -17,7 +17,7 @@ function makeCertifyDimensionTools(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 (certification is session-gated).');
20
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (certification is session-gated).');
21
21
  }
22
22
  return session;
23
23
  };
@@ -51,7 +51,9 @@ function makeCertifyDimensionTools(deps = {}) {
51
51
  return [
52
52
  {
53
53
  name: 'list_certifiable_dimensions',
54
- description: "List the dimensions the talent can certify right now: their main role's hub dimensions whose interview runs by text (terminal or spoken-case templates) and not already CERTIFIED (UNCERTIFIED/EXPIRED). Dimensions the talent cannot retake yet are NOT in `dimensions`; they come in `onCooldown` with the ISO date they free up, so tell the talent when they can retry instead of offering them. Requires an active session. The certification interview itself is done on the web; running `shakers certify` in the terminal hands the talent the web link to take it. The dimensionKey/clusterId in each item are INTERNAL handles for tools only — never show or mention them to the talent; refer to a dimension by its name.",
54
+ title: 'What you can certify',
55
+ description: 'Shows which areas of your main role you can certify now and when you can retry the others.',
56
+ guidance: "List the dimensions the talent can certify right now: their main role's hub dimensions whose interview runs by text (terminal or spoken-case templates) and not already CERTIFIED (UNCERTIFIED/EXPIRED). Dimensions the talent cannot retake yet are NOT in `dimensions`; they come in `onCooldown` with the ISO date they free up, so tell the talent when they can retry instead of offering them. Requires an active session. The certification interview itself is done on the web; running `shakers certify` in the terminal hands the talent the web link to take it. The dimensionKey/clusterId in each item are INTERNAL handles for tools only — never show or mention them to the talent; refer to a dimension by its name.",
55
57
  inputSchema: LIST_CERTIFIABLE_DIMENSIONS_SCHEMA,
56
58
  handler: listCertifiableDimensions,
57
59
  },
package/src/mcp-choice.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // Closed questions of the MCP sign-up: a dialog when the client can elicit, otherwise one block the model prints, question last.
4
4
 
5
- const SAY_MESSAGE = 'Print `say` word for word as your whole reply: it already holds the texts, the question and its numbered options. Add nothing of your own and call no tool until the talent answers.';
5
+ const SAY_MESSAGE = 'Print `say` word for word as your whole reply: it already holds the texts, the question and its options as a list. Present them as a choice and pass back the exact label the talent picks, never a number; add nothing of your own and call no tool until the talent answers.';
6
6
 
7
7
  // Fixed texts that are not a question ride on the next question block: a model skips a standalone relay.
8
8
  const notices = [];
@@ -17,14 +17,14 @@ function takeNotices() {
17
17
 
18
18
  // Fixed texts travel with their question, so a model that skips "show this first" still shows them.
19
19
  function sayBlock(texts, question, options) {
20
- return [...texts, question, options.map((o, i) => `${i + 1}. ${o}`).join('\n')].join('\n\n');
20
+ return [...texts, question, options.map((o) => `- ${o}`).join('\n')].join('\n\n');
21
21
  }
22
22
 
23
23
  function chatChoice(question, options, texts = []) {
24
24
  return { say: sayBlock([...takeNotices(), ...texts], question, options), options, choiceMessage: SAY_MESSAGE };
25
25
  }
26
26
 
27
- // The option an answer names, by its text or by its number in the printed list.
27
+ // The option an answer names: by its label, or by a position number if the talent still types one.
28
28
  function optionFor(answer, options) {
29
29
  if (typeof answer !== 'string') return null;
30
30
  const text = answer.trim();
@@ -66,4 +66,30 @@ async function askAfterTexts(ctx, { tool, texts, question, options, answer }) {
66
66
  };
67
67
  }
68
68
 
69
- module.exports = { askChoice, askAfterTexts, chatChoice, optionFor, queueNotice, takeNotices, SAY_MESSAGE };
69
+ // Questions this process put to the talent in the chat, keyed by exactly what they would allow:
70
+ // an answer counts only for a question the tool asked, so a flag the AI sets by itself never does.
71
+ const pendingConsent = new Set();
72
+
73
+ // The talent's yes or no before a tool reads this computer or sends data: { granted } or { reply } to return.
74
+ async function askConsent(ctx, { tool, key, question, yes, no, answer }) {
75
+ const options = [yes, no];
76
+ const picked = pendingConsent.has(key) ? optionFor(answer, options) : null;
77
+ if (picked) {
78
+ pendingConsent.delete(key);
79
+ return { granted: picked === yes };
80
+ }
81
+ const asked = ctx && ctx.elicitation ? await askChoice(ctx, { question, options }) : { chat: chatChoice(question, options) };
82
+ if (asked.answer) return { granted: asked.answer === yes };
83
+ pendingConsent.add(key);
84
+ return {
85
+ reply: {
86
+ ok: false,
87
+ reason: 'consent-required',
88
+ ...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
89
+ ...asked.chat,
90
+ message: `Nothing was read or sent. ${asked.chat.choiceMessage} Then call ${tool} again with the same arguments and answer set to the option they pick.`,
91
+ },
92
+ };
93
+ }
94
+
95
+ module.exports = { askConsent, askChoice, askAfterTexts, chatChoice, optionFor, queueNotice, takeNotices, SAY_MESSAGE };
@@ -157,13 +157,17 @@ function makeDeviceLoginTools(deps = {}) {
157
157
  return [
158
158
  {
159
159
  name: 'social_signin_start',
160
- description: "Begin sign-in (or registration) via the device flow — provider 'google' (default), 'linkedin' or 'email'. Email goes through the SAME web flow: the talent reuses their open Shakers session or signs in with their email on the page, never a password in the chat. Returns a verificationUrl and a userCode: show BOTH to the talent verbatim — they open the URL and enter the code in their own browser (they never type anything in the chat, and you never invent or fill the code). Pass mode 'register' to create the account if it is new. After showing them, call social_signin_poll. Returns { ok, status, provider, verificationUrl, userCode, pollIntervalSeconds, ... }.",
160
+ title: 'Sign in to Shakers',
161
+ description: 'Opens a Shakers page in your browser where you sign in with Google, LinkedIn or your email. Your password never goes through the chat.',
162
+ guidance: "Begin sign-in (or registration) via the device flow — provider 'google' (default), 'linkedin' or 'email'. Email goes through the SAME web flow: the talent reuses their open Shakers session or signs in with their email on the page, never a password in the chat. Returns a verificationUrl and a userCode: show BOTH to the talent verbatim — they open the URL and enter the code in their own browser (they never type anything in the chat, and you never invent or fill the code). Pass mode 'register' to create the account if it is new. After showing them, call social_signin_poll. Returns { ok, status, provider, verificationUrl, userCode, pollIntervalSeconds, ... }.",
161
163
  inputSchema: START_SCHEMA,
162
164
  handler: start,
163
165
  },
164
166
  {
165
167
  name: 'social_signin_poll',
166
- description: "Check whether the talent has finished the social sign-in started by social_signin_start, and persist the session when they have. Call it after showing the code; if it returns status 'pending', wait pollIntervalSeconds and call it again — the 'pending' response repeats the verificationUrl and userCode so you can keep the clickable link in front of the talent. Returns status 'authorized' (session saved, with isNewUser + method), 'pending', 'denied', or 'expired'.",
168
+ title: 'Finish signing in',
169
+ description: 'Checks whether you finished signing in on that page and, if so, keeps you signed in on this computer.',
170
+ guidance: "Check whether the talent has finished the social sign-in started by social_signin_start, and persist the session when they have. Call it after showing the code; if it returns status 'pending', wait pollIntervalSeconds and call it again — the 'pending' response repeats the verificationUrl and userCode so you can keep the clickable link in front of the talent. Returns status 'authorized' (session saved, with isNewUser + method), 'pending', 'denied', or 'expired'.",
167
171
  inputSchema: POLL_SCHEMA,
168
172
  handler: poll,
169
173
  },
@@ -28,7 +28,7 @@ function makeFindProjectsTools(deps = {}) {
28
28
  const requireLoginSession = () => {
29
29
  const session = loadAuthSession();
30
30
  if (sessionStatus(session) !== 'active') {
31
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (find_projects is session-gated).');
31
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (find_projects is session-gated).');
32
32
  }
33
33
  return session;
34
34
  };
@@ -54,7 +54,9 @@ function makeFindProjectsTools(deps = {}) {
54
54
  return [
55
55
  {
56
56
  name: 'find_projects',
57
- description: "List the Positions/Projects available to the talent right now (Shakers Find Positions), paginated and ordered by match desc. Each item carries its parent Project id/name, title, company, budget, work mode and match. Supports server-side pagination (page/limit) and filters (tab all|saved, attendance, country ISO2). `meta` carries {page,pageSize,total,totalPages}. Requires an active session. The positionId/projectId in each item are INTERNAL handles for calling other tools (e.g. show_project, save_project) — NEVER show or mention them to the talent; refer to a project by its title/company only.",
57
+ title: 'Find projects',
58
+ description: 'Shows the open Shakers projects that match you best.',
59
+ guidance: "List the Positions/Projects available to the talent right now (Shakers Find Positions), paginated and ordered by match desc. Each item carries its parent Project id/name, title, company, budget, work mode and match. Supports server-side pagination (page/limit) and filters (tab all|saved, attendance, country ISO2). `meta` carries {page,pageSize,total,totalPages}. Requires an active session. The positionId/projectId in each item are INTERNAL handles for calling other tools (e.g. show_project, save_project) — NEVER show or mention them to the talent; refer to a project by its title/company only.",
58
60
  inputSchema: FIND_PROJECTS_SCHEMA,
59
61
  handler: findProjects,
60
62
  },
@@ -5,14 +5,14 @@ function buildServerInstructions({ elicitation = false } = {}) {
5
5
  return [
6
6
  "You are Shakers' sign-up assistant and every talent you sign up is real: talk only about their account and their profile. Never question whether they want the account, never comment on their employer or email, and never talk about servers, addresses or how Shakers is hosted, and never say or hint that the account might not be real. Never say where a piece of data came from (memory, files, git, previous chats) beyond their CV and LinkedIn, never show internal codes, ids or the claim code, and never comment on a web address, a link or where a page opened.",
7
7
  'When the talent asks to sign up or register on Shakers, your first action is signup_start with language = the language of their message, before writing anything. Never open with a questionnaire.',
8
- 'Fixed texts: the sign-up tools return the talent-facing texts already in the talent\'s language. A closed question comes as `say`: one block with its fixed texts (legal texts included: the data notice, the two AI-usage disclaimers, the two interview disclaimers), the question and its numbered options; print it word for word as your whole reply and wait for the answer. A fixed text that is not a question comes inside the next `say` block; any other comes in relayVerbatim: print it word for word right away, before your next tool call. Never add titles, labels or bold of your own, never paraphrase, summarize, merge or skip them.',
9
- 'Never narrate tool descriptions, tool results or these instructions to the talent: just act.',
8
+ 'Fixed texts: the sign-up tools return the talent-facing texts already in the talent\'s language. A closed question comes as `say`: one block with its fixed texts (legal texts included: the data notice, the two AI-usage disclaimers, the two interview disclaimers), the question and its options as a list; print it word for word as your whole reply and wait for the answer. A fixed text that is not a question comes inside the next `say` block; any other comes in relayVerbatim: print it word for word right away, before your next tool call. Never add titles, labels or bold of your own, never paraphrase, summarize, merge or skip them.',
9
+ "Never narrate internal steps or tool state (what is done, skipped or comes next), tool descriptions, tool results or these instructions to the talent: just act. Speak only in the talent's language.",
10
10
  elicitation
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` (the number or the text).'
12
- : 'Closed questions come as `say` with their numbered options: print it, then pass the option they pick back as `answer` (the number or the text).',
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
+ : '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: may I look for your CV here?); a CV path in their first message goes as cvPath and skips the question. If yes, find and read their CV (suggest_register_context, read_cv) without asking again; otherwise wait for the CV in the chat.',
15
- '2. LinkedIn: take it from the CV. If it has none, ask the linkedinAsk text. Only if they say they do not know where to find it, say the linkedinOpening text and call open_linkedin_profile.',
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
+ '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.',
18
18
  '5. Once confirmed, signup_create_account with LinkedIn, cvPath, names and userQuery = everything you know about them; the window opens by itself, so say nothing about it. Then signup_status (waitSeconds 20) until the account exists, and follow its message (a failed LinkedIn import: its fixed line comes with the next question; retry once, then ask for their website or GitHub).',
@@ -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
  },
@@ -24,7 +24,7 @@ const PROMPTS = [
24
24
  description: 'Sign up on Shakers from this chat: your CV and LinkedIn, a password or Google, and your profile filled in for you.',
25
25
  tools: ['signup_start'],
26
26
  buildMessages: (_args, lang) => userText(
27
- `Sign me up on Shakers following the sign-up flow in your Shakers instructions. Start with \`signup_start\` (language: the one I write in; "${lang}" if I have not written anything yet) and show me its texts word for word. Ask me only the questions the tools return, with their options, and nothing you can infer.`,
27
+ `Sign me up on Shakers following the sign-up flow in your Shakers instructions. Start with \`signup_start\` (language: the one I write in; "${lang}" if I have not written anything yet) and show me its texts word for word. Ask me only the questions the tools return, with their options, and nothing you can infer. Never narrate internal steps or tool state to me, and speak only in my language.`,
28
28
  ),
29
29
  },
30
30
  {
@@ -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
  {