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
package/README.md CHANGED
@@ -379,7 +379,7 @@ scanned project** — the report can't slip into a commit by accident.
379
379
  Desktop) can drive the same features as tools. It exposes, among others:
380
380
 
381
381
  - **AI usage**: `ai_usage`, `ai_usage_result`.
382
- - **Auth**: `login`, `logout`, `social_signin_start`, `social_signin_poll`.
382
+ - **Auth**: `social_signin_start`, `social_signin_poll`, `logout`.
383
383
  - **Sign-up** (from the talent's own AI, ticket 39540; copy from UX review
384
384
  39574): every talent-facing text is fixed, comes from the tools in the
385
385
  talent's language (es/en/it/pt, fixed by their first message) and the AI shows
@@ -413,17 +413,36 @@ Desktop) can drive the same features as tools. It exposes, among others:
413
413
  pick directly; otherwise it returns `question` and `options` and the AI offers
414
414
  them as buttons or a numbered list. The legal texts are always shown in the
415
415
  chat before any consent dialog.
416
+ - **Asked before touching this computer**: `suggest_register_context` (look for
417
+ the CV), `read_cv` (read one CV file), `import_profile` with a CV file (send it)
418
+ and `add_project` (send the project folder's name, web address, dates and
419
+ client) each ask the talent themselves, naming exactly what they read or send,
420
+ the same way as the closed questions above. Without a dialog, the answer only
421
+ counts when it comes back for a question that tool asked for those exact
422
+ arguments; a yes the AI sets on its own is ignored and the question is asked
423
+ again. Nothing is read or sent before the yes.
416
424
  - **Profile / skills / agents**: `list_addable`, `add_skill`, `add_agent`,
417
425
  `add_project`, `get_profile`, `get_rate`, `get_languages`, `list_socials`,
418
426
  `list_experiences`, `list_portfolios`, `get_availability`.
419
427
  - **Marketplace**: `find_projects`, `show_project`, `save_project`,
420
428
  `unsave_project`, `list_invitations`, `list_applications`.
421
429
  - **Certifications**: `list_certifications`, `list_certifiable_dimensions`.
422
- - **Alma**: `ask`.
430
+ - **Alma**: `alma_chat`, `alma_decision` (a change Alma proposes is applied only
431
+ after the talent says yes).
432
+
433
+ Each tool has two audiences. Its `title` and `description` are what the talent
434
+ reads in the client: plain words saying what it does, what it reads on the
435
+ machine and what it sends to Shakers. `packaging/mcpb/manifest.json` declares the
436
+ same tools with the same descriptions, and a test keeps them equal. How the AI
437
+ must use a tool (order, verbatim texts, internal ids) travels in the
438
+ description of its input schema.
423
439
 
424
440
  MCP tool names are **stable** (renaming would break existing client configs); the
425
441
  role tools above were added, not renamed. `register`, `register_preview` and
426
- `register_status` were removed with the sign-up redesign, not renamed.
442
+ `register_status` were removed with the sign-up redesign, not renamed. `login`
443
+ (it only pointed to `social_signin_start`) and `ask` (a copy of `alma_chat` that
444
+ dropped Alma's confirmation requests) were removed to show the talent fewer
445
+ tools; the `/ask` prompt now uses `alma_chat`.
427
446
 
428
447
  ## Try it
429
448
 
@@ -885,6 +904,12 @@ history directory is absent or unreadable.
885
904
  whether it's silently dropped or degraded on failure, is unaffected by
886
905
  consent either way. The local **usage** read (Claude Code history) is not
887
906
  even a network call — it never leaves the machine, consent or not.
907
+ - **Registration funnel analytics.** `shakers register` and the MCP sign-up
908
+ send one event per registration step to the Hub, which relays them to
909
+ Amplitude. Only steps and categories travel (never an exact rate or free
910
+ text), with a random device id kept in
911
+ `~/.config/shakers/analytics-device.json` (permissions `600`). A failed
912
+ send never affects the registration.
888
913
  - The consent decision lives in `~/.config/shakers/consent.json`
889
914
  (permissions `600`).
890
915
  - An **unfinished interview** (agent certification, or the skill-code
package/bin/mcp.js CHANGED
@@ -14,7 +14,6 @@ const { makeAvailabilityTools } = require('../src/mcp-availability-tools');
14
14
  const { makeCertificationsTools } = require('../src/mcp-certifications-tools');
15
15
  const { makeProfileTools } = require('../src/mcp-profile-tools');
16
16
  const { makeRateTools } = require('../src/mcp-rate-tools');
17
- const { makeAskTools } = require('../src/mcp-ask-tools');
18
17
  const { makeAlmaTools } = require('../src/mcp-alma-tools');
19
18
  const { makeLangTools } = require('../src/mcp-lang-tools');
20
19
  const { makeSocialsTools } = require('../src/mcp-socials-tools');
@@ -29,6 +28,7 @@ const { makeReportTools } = require('../src/mcp-report-tools');
29
28
  const { makePrompts } = require('../src/mcp-prompts');
30
29
  const { buildServerInstructions } = require('../src/mcp-instructions');
31
30
  const { ensureFreshSession } = require('../src/session-refresh');
31
+ const { createFunnel } = require('../src/onboarding-funnel');
32
32
 
33
33
  let VERSION = '';
34
34
  try {
@@ -43,7 +43,7 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
43
43
  + ` Usage: shakers mcp\n\n`
44
44
  + ` This command speaks JSON-RPC on stdio; it is meant to be launched by an\n`
45
45
  + ` MCP client, not run by hand.\n`
46
- + ` Tools: ai_usage, ai_usage_result, login, logout, social_signin_start, social_signin_poll,\n`
46
+ + ` Tools: ai_usage, ai_usage_result, logout, social_signin_start, social_signin_poll,\n`
47
47
  + ` list_addable, add_skill, add_agent,\n`
48
48
  + ` add_project, signup_start, signup_email, signup_draft, signup_create_account,\n`
49
49
  + ` signup_status, update_existing_profile, import_profile, save_profile_details, open_web,\n`
@@ -57,7 +57,7 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
57
57
  + ` save_project, unsave_project, list_invitations,\n`
58
58
  + ` list_applications, get_availability,\n`
59
59
  + ` list_certifications, get_profile, get_rate, get_languages,\n`
60
- + ` list_socials, list_experiences, list_portfolios, ask.\n\n`;
60
+ + ` list_socials, list_experiences, list_portfolios, alma_chat, alma_decision.\n\n`;
61
61
 
62
62
  // The LOCAL (filesystem/machine) tools — the only ones the .mcpb bundle exposes; the
63
63
  // server-backed tools belong to the remote connector.
@@ -65,9 +65,9 @@ const LOCAL_ONLY_TOOL_NAMES = new Set(['ai_usage', 'ai_usage_result', 'read_cv',
65
65
 
66
66
  // `localOnly` builds the scan-only server for the Claude .mcpb bundle, reusing the SAME
67
67
  // tool registry (just filtered) — no duplicate registration.
68
- function buildServer({ localOnly = false } = {}) {
68
+ function buildServer({ localOnly = false, funnel = createFunnel({ surface: 'mcp' }) } = {}) {
69
69
  const lang = detectFlowLang();
70
- const allTools = [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools({ lang }), ...makeDeviceLoginTools(), ...makeSignupTools({ lang }), ...makeRegisterTools({ lang }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAskTools(), ...makeAlmaTools()];
70
+ const allTools = [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools({ lang, funnel }), ...makeDeviceLoginTools(), ...makeSignupTools({ lang, funnel }), ...makeRegisterTools({ lang, funnel }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAlmaTools()];
71
71
  const tools = localOnly ? allTools.filter((t) => LOCAL_ONLY_TOOL_NAMES.has(t.name)) : allTools;
72
72
  // Slash-command prompts, kept to the tools actually registered in this mode.
73
73
  const prompts = makePrompts(new Set(tools.map((t) => t.name)), lang);
@@ -79,9 +79,15 @@ function buildServer({ localOnly = false } = {}) {
79
79
  // Long-lived server: refresh the email session's hub JWT from the stored
80
80
  // cookie before each tool call so a stale (~15-min) JWT never reaches a tool.
81
81
  beforeCall: () => ensureFreshSession(process.env),
82
+ onInitialize: ({ clientInfo }) => funnel.setMcpClient(clientInfo.name),
82
83
  });
83
84
  }
84
85
 
86
+ function prepareProcess(env = process.env) {
87
+ require('../src/config').sanitizeStaleBakedEndpoints(env);
88
+ require('../src/backend-request').setClientIdentity('mcp');
89
+ }
90
+
85
91
  async function run(argv = process.argv.slice(2)) {
86
92
  // The installer's last step; not a command the Talent needs to know.
87
93
  if (argv[0] === 'install') {
@@ -93,7 +99,7 @@ async function run(argv = process.argv.slice(2)) {
93
99
  return;
94
100
  }
95
101
 
96
- require('../src/config').sanitizeStaleBakedEndpoints(process.env);
102
+ prepareProcess();
97
103
 
98
104
  // `--local` = the scan-only server shipped in the Claude .mcpb bundle.
99
105
  const server = buildServer({ localOnly: argv.includes('--local') });
@@ -111,7 +117,7 @@ async function run(argv = process.argv.slice(2)) {
111
117
  });
112
118
  }
113
119
 
114
- module.exports = { run, buildServer, buildServerInstructions };
120
+ module.exports = { run, buildServer, buildServerInstructions, prepareProcess };
115
121
 
116
122
  if (require.main === module) {
117
123
  run();
package/bin/register.js CHANGED
@@ -20,6 +20,7 @@ const { typeOut } = require('../src/typewriter');
20
20
  const { withSpinner } = require('../src/terminal-progress');
21
21
  const { openPath } = require('../src/open-file');
22
22
  const { c: fmt } = require('../src/terminal-format');
23
+ const { createFunnel } = require('../src/onboarding-funnel');
23
24
  const red = (s) => `${fmt.danger}${s}${fmt.reset}`;
24
25
 
25
26
  const VALID_LANGS = new Set(['es', 'en']);
@@ -285,7 +286,7 @@ function makeIo({ ask, lang, out, acceptDisclaimer, stdinIsTTY = false }) {
285
286
 
286
287
  async function run(
287
288
  argv = process.argv.slice(2),
288
- { ask: injectedAsk = null, stdinIsTTY: ttyOverride = undefined, deps = null, out = null, openBrowser = undefined, input = undefined, output = undefined } = {},
289
+ { ask: injectedAsk = null, stdinIsTTY: ttyOverride = undefined, deps = null, out = null, openBrowser = undefined, input = undefined, output = undefined, funnel = undefined } = {},
289
290
  ) {
290
291
  const o = parseArgs(argv);
291
292
  const lang = o.lang || detectFlowLang();
@@ -316,7 +317,9 @@ async function run(
316
317
  const io = makeIo({ ask, lang, out: write, acceptDisclaimer: o.acceptDisclaimer, stdinIsTTY });
317
318
  // At a terminal the profile opens in the browser at the end; piped runs, tests and --no-open only print it.
318
319
  const openProfile = stdinIsTTY && !o.noOpen ? (openBrowser || openPath) : undefined;
319
- const result = await runOnboarding(io, deps || makeDeps(), { method, openBrowser, onAuthUrl: io.onGoogleAuthUrl, openProfile });
320
+ // Injected deps (tests) bring their own funnel or none.
321
+ const registrationFunnel = funnel || (deps ? undefined : createFunnel({ surface: 'cli' }));
322
+ const result = await runOnboarding(io, deps || makeDeps(), { method, openBrowser, onAuthUrl: io.onGoogleAuthUrl, openProfile, funnel: registrationFunnel });
320
323
  if (!result.ok) {
321
324
  process.stderr.write(`\n ${red(`register: ${result.reason}${result.step ? ` (${result.step})` : ''}`)}\n\n`);
322
325
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -8,6 +8,14 @@ const crypto = require('crypto');
8
8
 
9
9
  const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
10
10
 
11
+ const CLIENT_IDENTITIES = ['cli', 'mcp'];
12
+ let clientIdentity = 'cli';
13
+
14
+ function setClientIdentity(identity) {
15
+ if (!CLIENT_IDENTITIES.includes(identity)) throw new Error(`unknown client identity: ${identity}`);
16
+ clientIdentity = identity;
17
+ }
18
+
11
19
  // Extra request headers, sanitized (talents-ai-score, issue 038 — the agent certification interview needs `Authorization: Bearer <sessionToken>`).
12
20
  const UNSAFE_HEADER_VALUE_RE = /[\r\n\0]/;
13
21
 
@@ -39,13 +47,8 @@ function postJsonWithTimeout(url, body, timeoutMs, method = 'POST', idempotencyK
39
47
  method,
40
48
  headers: {
41
49
  Accept: 'application/json',
42
- // Lets the backend (and Datadog) tell CLI/MCP traffic apart from the
43
- // browser without touching auth — same value regardless of which
44
- // bin/ entrypoint is running, since none of them plumb through here
45
- // separately today. Set unconditionally so no caller can drop it via
46
- // extraHeaders (sanitizeExtraHeaders runs after this, so it can only
47
- // add headers, not remove this one).
48
- 'X-Shakers-Client': 'cli',
50
+ // Tells CLI and MCP traffic apart from the browser.
51
+ 'X-Shakers-Client': clientIdentity,
49
52
  // Only declared when there IS a body — see the bodyless note above.
50
53
  ...(data ? { 'Content-Type': 'application/json', 'Content-Length': data.length } : {}),
51
54
  ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
@@ -125,7 +128,7 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
125
128
  headers: {
126
129
  Accept: 'application/json',
127
130
  // See postJsonWithTimeout's identical header for why.
128
- 'X-Shakers-Client': 'cli',
131
+ 'X-Shakers-Client': clientIdentity,
129
132
  'Content-Type': `multipart/form-data; boundary=${boundary}`,
130
133
  'Content-Length': data.length,
131
134
  ...sanitizeExtraHeaders(extraHeaders),
@@ -184,4 +187,5 @@ module.exports = {
184
187
  postMultipartWithTimeout,
185
188
  extractSetCookie,
186
189
  requestBackend,
190
+ setClientIdentity,
187
191
  };
package/src/config.js CHANGED
@@ -124,10 +124,17 @@ function resolveIngestEndpoint(env = process.env) {
124
124
  // Only the DEFAULT bases; per-endpoint SHAKERS_CLI_* env and config.json overrides
125
125
  // still win at each getter (env > config.json > baked profile).
126
126
  const ENV_PROFILES = {
127
+ prod: {
128
+ certsBase: 'https://api.certifications.shakersworks.com/api/v1',
129
+ hubBase: 'https://api.hub.shakersworks.com/api/v1',
130
+ profileUrl: 'https://app.shakersworks.com/login',
131
+ almaBase: 'https://app.shakersworks.com',
132
+ },
127
133
  staging: {
128
134
  certsBase: 'https://api.staging.certifications.shakersworks.com/api/v1',
129
135
  hubBase: 'https://api.hub.staging.shakersworks.com/api/v1',
130
136
  profileUrl: 'https://works.staging.shakersworks.com/login',
137
+ almaBase: 'https://works.staging.shakersworks.com',
131
138
  },
132
139
  dev: {
133
140
  certsBase: 'https://api.dev.certifications.shakersworks.com/api/v1',
@@ -142,8 +149,8 @@ const ENV_PROFILES = {
142
149
  };
143
150
 
144
151
  // Which flavor is baked: SHAKERS_CLI_ENV override wins, else derived from the
145
- // published package name (beta -> staging, private -> dev), else dev (safe default).
146
- const PACKAGE_NAME_ENV = { 'shakers-cli-beta': 'staging', '@shakers/shakers-cli': 'dev' };
152
+ // published package name (public -> prod, beta -> staging, private -> dev), else dev (safe default).
153
+ const PACKAGE_NAME_ENV = { 'shakers-cli': 'prod', 'shakers-cli-beta': 'staging', '@shakers/shakers-cli': 'dev' };
147
154
  const DEFAULT_ENV_PROFILE = 'dev';
148
155
 
149
156
  function bakedEnvProfileName() {
@@ -395,13 +402,13 @@ function getTalentMeProfileEndpoint(env = process.env) {
395
402
  }
396
403
 
397
404
  // Alma (the AI assistant) is a SEPARATE service — NOT derived from hubBase.
398
- // Default to the local/dev container; override with SHAKERS_CLI_ALMA_BASE.
405
+ // SHAKERS_CLI_ALMA_BASE > the baked profile's almaBase > the local/dev container.
399
406
  const DEFAULT_ALMA_BASE = 'http://127.0.0.1:5030';
400
407
 
401
408
  function getAlmaBase(env = process.env) {
402
409
  const explicit = readEnv(env, 'ALMA_BASE');
403
410
  if (explicit && explicit.trim()) return explicit.trim().replace(/\/+$/, '');
404
- return DEFAULT_ALMA_BASE;
411
+ return getEnvProfile(env).almaBase || DEFAULT_ALMA_BASE;
405
412
  }
406
413
 
407
414
  function getAlmaChatEndpoint(env = process.env) {
@@ -553,6 +560,10 @@ function getMyImportStatusEndpoint(env = process.env) {
553
560
  return deriveFromHub(env, 'works/talent/me/import-profile/status');
554
561
  }
555
562
 
563
+ function getOnboardingEventsEndpoint(env = process.env) {
564
+ return deriveFromHub(env, 'works/talent/me/onboarding-events');
565
+ }
566
+
556
567
  function getOneTimeTokenEndpoint(env = process.env) {
557
568
  return deriveFromHub(env, 'auth/one-time-token/generate');
558
569
  }
@@ -786,7 +797,7 @@ module.exports = {
786
797
  getMeAvailabilityEndpoint,
787
798
  getMeProfileEndpoint,
788
799
  getTalentMeProfileEndpoint,
789
- // Alma assistant (`ask` command / `ask` MCP tool) — separate service.
800
+ // Alma assistant (`ask` command / `alma_chat` MCP tool) — separate service.
790
801
  getAlmaBase,
791
802
  getAlmaChatEndpoint,
792
803
  getAlmaDecisionEndpoint,
@@ -816,6 +827,7 @@ module.exports = {
816
827
  getImportProfileEndpoint,
817
828
  getMcpSignupEndpoint,
818
829
  getMyImportStatusEndpoint,
830
+ getOnboardingEventsEndpoint,
819
831
  getOneTimeTokenEndpoint,
820
832
  getOneTimeLoginUrl,
821
833
  getProfessionalDetailsEndpoint,
@@ -15,9 +15,10 @@ const MAX_ATTEMPTS = 5;
15
15
  const YES_RE = /^(y|yes|s|si|sí)$/i;
16
16
  const NO_RE = /^(n|no)$/i;
17
17
 
18
- async function askYesNo(ask, question, catalog) {
18
+ async function askYesNo(ask, question, catalog, { defaultNo = false } = {}) {
19
19
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
20
20
  const raw = String(await ask(question)).trim();
21
+ if (defaultNo && raw === '') return false;
21
22
  if (YES_RE.test(raw)) return true;
22
23
  if (NO_RE.test(raw)) return false;
23
24
  process.stdout.write(` ${catalog.consent.invalidAnswer}\n`);
@@ -62,7 +63,12 @@ async function runConsentPrompt({
62
63
 
63
64
  if (drainBeforeAsk && typeof ask.drain === 'function') ask.drain();
64
65
 
65
- const accepted = await askYesNo(ask, c.persistQuestion, catalog);
66
+ if (loggedIn && sessionEmail && isValidEmail(sessionEmail)) {
67
+ process.stdout.write(` ${c.savedUnderEmail(sessionEmail)}\n`);
68
+ }
69
+
70
+ // defaultNo: a blank Enter means "do not save" (legal requirement).
71
+ const accepted = await askYesNo(ask, c.persistQuestion, catalog, { defaultNo: true });
66
72
  if (accepted === null) {
67
73
  process.stdout.write(` ${c.notObtained}\n\n`);
68
74
  return null;
@@ -92,13 +98,9 @@ async function runConsentPrompt({
92
98
  return null;
93
99
  }
94
100
 
95
- if (profile === 'external') {
96
- recordConsent('granted', email, { verified: false });
97
- process.stdout.write(` ${c.grantedSavedExternal(email)}\n\n`);
98
- return 'granted';
99
- }
101
+ process.stdout.write(` ${c.savedUnderEmail(email)}\n`);
100
102
 
101
- // Prove email ownership BEFORE persisting anything (ADR-006).
103
+ // Prove email ownership BEFORE persisting (ADR-006) — every profile, external included.
102
104
  const verification = await verifyEmail({ email, ask, catalog, env });
103
105
  if (verification && verification.verified) {
104
106
  recordConsent('granted', email, { verified: true });
@@ -106,8 +108,6 @@ async function runConsentPrompt({
106
108
  return 'granted';
107
109
  }
108
110
 
109
- // Not verified: persist nothing, re-ask next run. runEmailVerification
110
- // already printed the specific reason (cancelled / technical / exhausted).
111
111
  process.stdout.write(` ${c.notObtained}\n\n`);
112
112
  return null;
113
113
  }
package/src/i18n.js CHANGED
@@ -637,17 +637,17 @@ const catalogs = {
637
637
  + 'puede acarrear penalizaciones en tu cuenta de Shakers, incluida la '
638
638
  + 'posible suspensión. Consulta el README de este repositorio para '
639
639
  + 'más detalle.',
640
- persistQuestion: '¿Guardar este informe en Shakers? (s/n):',
640
+ persistQuestion: '¿Guardar este informe en Shakers? [s/N]:',
641
641
  invalidAnswer: 'Respuesta no reconocida. Responde "s" (sí) o "n" (no).',
642
642
  emailPrompt: 'Introduce tu correo:',
643
- emailPromptExternal: 'Déjanos tu email para que el equipo de Shakers pueda contactarte (no lo verificamos):',
643
+ emailPromptExternal: 'Déjanos tu email para que el equipo de Shakers pueda contactarte:',
644
+ savedUnderEmail: (email) => `El informe se guardará a nombre de: ${email}`,
644
645
  invalidEmail: 'Correo no válido, inténtalo de nuevo.',
645
646
  notObtained: 'No se ha podido registrar tu respuesta; se te volverá a preguntar la próxima vez.',
646
647
  // talents-ai-score, ADR-051: esta pregunta se pide ahora ANTES de cualquier llamada de IA/egress (agent-synthesis, agent-evaluation, personalización de roadmap), no después.
647
648
  noReportWithoutConsent: 'Sin tu consentimiento, este informe no incluirá síntesis, evaluación ni roadmap generados por IA esta vez (el resto del informe se muestra igual). Vuelve a ejecutar `usage` y acepta para incluirlos, o usa --consent-reset si quieres que se te pregunte de nuevo.',
648
649
  deniedSaved: 'Entendido, no se guardará nada. Puedes cambiar de opinión más adelante volviendo a ejecutar el comando.',
649
650
  grantedSaved: (email) => `Gracias. A partir de ahora este informe se guardará automáticamente en Shakers (correo: ${email}, máx. 1 vez por hora).`,
650
- grantedSavedExternal: (email) => `Gracias. Hemos guardado tu email (${email}) para que el equipo de Shakers pueda contactarte — no lo hemos verificado.`,
651
651
  // talents-ai-score, issue 130: un talento logueado NUNCA ve el prompt de email (su cuenta ya prueba su identidad).
652
652
  noSessionEmail: 'No hay un correo asociado a tu sesión todavía, así que no se puede guardar el informe esta vez. Se te preguntará de nuevo la próxima ejecución.',
653
653
  skipAlreadyDecided: (decision, path) =>
@@ -2277,17 +2277,17 @@ const catalogs = {
2277
2277
  + 'yours or that you are not authorized to analyze— may result in penalties '
2278
2278
  + "on your Shakers account, up to and including suspension. See this "
2279
2279
  + "repository's README for more detail.",
2280
- persistQuestion: 'Save this report in Shakers? (y/n):',
2280
+ persistQuestion: 'Save this report in Shakers? [y/N]:',
2281
2281
  invalidAnswer: 'Answer not recognized. Reply "y" (yes) or "n" (no).',
2282
2282
  emailPrompt: 'Enter your email:',
2283
- emailPromptExternal: "Leave us your email so the Shakers team can reach out (we don't verify it):",
2283
+ emailPromptExternal: 'Leave us your email so the Shakers team can reach out:',
2284
+ savedUnderEmail: (email) => `The report will be saved under: ${email}`,
2284
2285
  invalidEmail: 'Invalid email, try again.',
2285
2286
  notObtained: "Couldn't record your answer; you'll be asked again next time.",
2286
2287
  // talents-ai-score, ADR-051: this question is now asked BEFORE any AI/egress call (agent-synthesis, agent-evaluation, roadmap personalization), not after.
2287
2288
  noReportWithoutConsent: 'Without your consent, this report will not include AI-generated synthesis, evaluation, or roadmap this time (the rest of the report is shown as usual). Run `usage` again and accept to include them, or use --consent-reset if you want to be asked again.',
2288
2289
  deniedSaved: 'Understood, nothing will be saved. You can change your mind later by running the command again.',
2289
2290
  grantedSaved: (email) => `Thanks. From now on this report will be saved in Shakers automatically (email: ${email}, max. once per hour).`,
2290
- grantedSavedExternal: (email) => `Thanks. We've saved your email (${email}) so the Shakers team can reach out — we haven't verified it.`,
2291
2291
  // talents-ai-score, issue 130: a logged-in Talent is NEVER asked for an email (their account already proves identity).
2292
2292
  noSessionEmail: "There's no email on file for your session yet, so this report can't be saved this time. You'll be asked again next run.",
2293
2293
  skipAlreadyDecided: (decision, path) =>
@@ -70,7 +70,7 @@ function makeAiUsageResultTool(deps = {}) {
70
70
  async function handler() {
71
71
  const session = loadAuthSession();
72
72
  if (sessionStatus(session) !== 'active') {
73
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first.');
73
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
74
74
  }
75
75
  const endpoint = getUsageDiscoveredInventoryEndpoint();
76
76
  if (!endpoint) {
@@ -94,7 +94,9 @@ function makeAiUsageResultTool(deps = {}) {
94
94
 
95
95
  return {
96
96
  name: 'ai_usage_result',
97
- description:
97
+ title: 'Results of your AI analysis',
98
+ description: 'Fetches from Shakers the description of each AI agent found in the analysis you approved. It reads nothing on this computer.',
99
+ guidance:
98
100
  "Retrieve the enriched Shakers classification for the talent's agents after ai_usage. Shakers classifies agents and writes their descriptions server-side (no client LLM), so this reads the result: per agent { name, tools, model, category, role, level, code, whatItDoes }. Session-keyed, no arguments. Server-side classification can take a few minutes, so this does a bounded internal poll (~90s max, never a long-held call): returns { status: 'ready', agents } once every agent has been classified, or { status: 'pending', agents } while Shakers is still processing (call it again to keep waiting — 'pending' is normal progress, not an error). Present agents to the talent by NAME and category/role only; the `code` field is an internal handle for add_agent, never show it. Requires an active session.",
99
101
  inputSchema: RESULT_INPUT_SCHEMA,
100
102
  handler,
@@ -385,7 +385,9 @@ function makeAiUsageTool(deps = {}) {
385
385
 
386
386
  return {
387
387
  name: 'ai_usage',
388
- description:
388
+ title: 'Analyse how you work with AI',
389
+ description: 'Asks you first and, only if you say yes, looks at which AI tools you use on this computer and the history of your code projects. With your yes it sends a summary with anonymised excerpts to your Shakers profile; your code and prompts never leave your computer.',
390
+ guidance:
389
391
  "Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity), scores their AI-maturity tier (T0-T7), and, with the talent's consent, submits the report to Shakers so their profile reflects it. Runs entirely on the talent's own machine. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. In the sign-up it is the step right after the account exists and before the interview: on yes it runs and uploads in the background, never wait for it. Submission also requires an active, email-verified Shakers session (via `shakers login` / registration OTP): without a verified session the report stays on this machine and nothing is sent — check the `send` field for the outcome. Always fast and deterministic: it runs no LLM on the machine. Agent descriptions and catalog classification are produced by Shakers server-side. This waits briefly (~30s) and, if classification finishes, returns the enriched agents inline; otherwise it returns enrichment:'pending' (normal progress, not an error) and you should call `ai_usage_result` to get them. The `text` field is a ready-to-show plain-text 'My work with AI' report (Markdown, no ANSI) mirroring the CLI terminal — render it verbatim to the talent; the matrix grid is inside a fenced code block, keep it fenced so it stays aligned. Use the `display` block for structured access: show the tier (with the tierLegend explaining T0-T7) and the agent list by NAME and category/role. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show raw IDs or codes to the talent.",
390
392
  inputSchema: AI_USAGE_INPUT_SCHEMA,
391
393
  handler,
@@ -27,7 +27,7 @@ function makeAlmaTools(deps = {}) {
27
27
  const requireSession = () => {
28
28
  const session = loadAuthSession();
29
29
  if (sessionStatus(session) !== 'active') {
30
- throw new Error('no active Shakers session — call the login tool (or run `shakers login`) first (alma is session-gated).');
30
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (alma is session-gated).');
31
31
  }
32
32
  return session;
33
33
  };
@@ -75,13 +75,17 @@ function makeAlmaTools(deps = {}) {
75
75
  return [
76
76
  {
77
77
  name: 'alma_chat',
78
- description: "Send a message to Alma, the Shakers AI assistant, and get her answer (about the talent's profile, rate, availability, skills, positions, applications). Continuity is automatic across turns (same session). When Alma proposes a profile change she returns `pendingConfirmation { text, irreversible }` — DO NOT auto-approve: show the talent `pendingConfirmation.text` (and warn when irreversible), ask them yes/no, then call `alma_decision` with their answer. `webLinks` are web-only navigation targets. Relay the answer as-is; never show internal Shakers ids/codes.",
78
+ title: 'Chat with Alma',
79
+ description: 'Sends your message to Alma, the Shakers assistant, and shows her answer. If she proposes a change to your profile, you are asked before it is made.',
80
+ guidance: "Send a message to Alma, the Shakers AI assistant, and get her answer (about the talent's profile, rate, availability, skills, positions, applications). Continuity is automatic across turns (same session). When Alma proposes a profile change she returns `pendingConfirmation { text, irreversible }` — DO NOT auto-approve: show the talent `pendingConfirmation.text` (and warn when irreversible), ask them yes/no, then call `alma_decision` with their answer. `webLinks` are web-only navigation targets. Relay the answer as-is; never show internal Shakers ids/codes.",
79
81
  inputSchema: ALMA_CHAT_SCHEMA,
80
82
  handler: almaChat,
81
83
  },
82
84
  {
83
85
  name: 'alma_decision',
84
- description: "Relay the talent's decision on the pending Alma action from alma_chat: approved=true makes Alma perform the change, approved=false cancels it. Call this ONLY after the talent has explicitly answered yes/no to the pendingConfirmation — never decide on their behalf.",
86
+ title: "Confirm Alma's change",
87
+ description: 'Sends your yes or no to a profile change Alma proposed.',
88
+ guidance: "Relay the talent's decision on the pending Alma action from alma_chat: approved=true makes Alma perform the change, approved=false cancels it. Call this ONLY after the talent has explicitly answered yes/no to the pendingConfirmation — never decide on their behalf.",
85
89
  inputSchema: ALMA_DECISION_SCHEMA,
86
90
  handler: almaDecision,
87
91
  },
@@ -14,7 +14,7 @@ function makeApplicationsTools(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_applications is session-gated).');
17
+ throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (list_applications is session-gated).');
18
18
  }
19
19
  return session;
20
20
  };
@@ -29,7 +29,9 @@ function makeApplicationsTools(deps = {}) {
29
29
  return [
30
30
  {
31
31
  name: 'list_applications',
32
- description: "List the positions the talent has applied to, with each candidature's status. Read-only. Requires an active session. Any positionId/projectId in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a project by its title/company.",
32
+ title: 'Your applications',
33
+ description: 'Shows the projects you applied to and how each application is going.',
34
+ guidance: "List the positions the talent has applied to, with each candidature's status. Read-only. Requires an active session. Any positionId/projectId in the result are INTERNAL handles for calling tools — never show or mention them to the talent; refer to a project by its title/company.",
33
35
  inputSchema: LIST_APPLICATIONS_SCHEMA,
34
36
  handler: listApplications,
35
37
  },
@@ -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
@@ -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
  },