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
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.12.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": {
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ // Display-only authorship-transparency check (talents-ai-score).
4
+ //
5
+ // Warns the talent when their commits in the selected repos are authored under
6
+ // an email that is not their Shakers account email, so their own work would be
7
+ // attributed to nobody. This is a WARNING surface ONLY: it reads each repo's
8
+ // CONFIGURED commit identity (`git config user.email`) and compares counts the
9
+ // attribution path already produced — it NEVER feeds attribution/scoring.
10
+ // git-activity.js owns `authoredCommitCount`; nothing here changes any count.
11
+
12
+ const { execFileSync } = require('child_process');
13
+
14
+ // The repo's locally configured commit identity. A plain config read, NOT the
15
+ // authorship log parse — it only names who new commits here would be attributed to.
16
+ function readConfiguredEmail(root) {
17
+ try {
18
+ const out = execFileSync('git', ['-C', root, 'config', '--get', 'user.email'], {
19
+ encoding: 'utf8',
20
+ timeout: 5000,
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ });
23
+ const value = String(out || '').trim();
24
+ return value || null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function normalize(email) {
31
+ return String(email || '').trim().toLowerCase();
32
+ }
33
+
34
+ // Returns { accountEmail, emails: [configured emails that differ from the account] }
35
+ // when the talent's own commits are plausibly going unattributed, else null.
36
+ //
37
+ // Conservative on purpose (a false warning erodes trust more than a missed one):
38
+ // it fires only when the account email is known, the selection HAS commits, NONE
39
+ // are attributed to the account, and at least one repo is configured under a
40
+ // different email we can name.
41
+ function detectAuthorshipMismatch(toplevels, accountEmail, gitActivity, { readEmail = readConfiguredEmail } = {}) {
42
+ const account = normalize(accountEmail);
43
+ if (!account) return null;
44
+ if (!gitActivity) return null;
45
+ const commits = gitActivity.commitCount || 0;
46
+ const authored = gitActivity.authoredCommitCount || 0;
47
+ if (commits === 0 || authored > 0) return null;
48
+
49
+ const emails = [];
50
+ const seen = new Set();
51
+ for (const root of Array.isArray(toplevels) ? toplevels : []) {
52
+ const configured = readEmail(root);
53
+ const key = normalize(configured);
54
+ if (!key || key === account || seen.has(key)) continue;
55
+ seen.add(key);
56
+ emails.push(String(configured).trim());
57
+ }
58
+ if (emails.length === 0) return null;
59
+ return { accountEmail: String(accountEmail).trim(), emails };
60
+ }
61
+
62
+ module.exports = { detectAuthorshipMismatch, readConfiguredEmail };
@@ -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
  }
@@ -621,10 +632,18 @@ function getTranslationsEndpoint(env = process.env) {
621
632
  return deriveFromHub(env, 'translations');
622
633
  }
623
634
 
635
+ // The signed-in user as hub sees it (GET only): read back the phone after saving it.
636
+ function getUserMeEndpoint(env = process.env) {
637
+ const explicit = readEnv(env, 'USER_ME_ENDPOINT');
638
+ if (explicit && explicit.trim()) return explicit.trim();
639
+ return deriveFromHub(env, 'works/users/me');
640
+ }
641
+
642
+ // Talent base fields (PATCH name, phone...). works/users/me is GET only.
624
643
  function getTalentMeEndpoint(env = process.env) {
625
644
  const explicit = readEnv(env, 'TALENT_ME_ENDPOINT');
626
645
  if (explicit && explicit.trim()) return explicit.trim();
627
- return deriveFromHub(env, 'works/users/me');
646
+ return deriveFromHub(env, 'works/talent/me');
628
647
  }
629
648
 
630
649
  function getOnboardingInterviewsEndpoint(env = process.env) {
@@ -778,7 +797,7 @@ module.exports = {
778
797
  getMeAvailabilityEndpoint,
779
798
  getMeProfileEndpoint,
780
799
  getTalentMeProfileEndpoint,
781
- // Alma assistant (`ask` command / `ask` MCP tool) — separate service.
800
+ // Alma assistant (`ask` command / `alma_chat` MCP tool) — separate service.
782
801
  getAlmaBase,
783
802
  getAlmaChatEndpoint,
784
803
  getAlmaDecisionEndpoint,
@@ -808,6 +827,7 @@ module.exports = {
808
827
  getImportProfileEndpoint,
809
828
  getMcpSignupEndpoint,
810
829
  getMyImportStatusEndpoint,
830
+ getOnboardingEventsEndpoint,
811
831
  getOneTimeTokenEndpoint,
812
832
  getOneTimeLoginUrl,
813
833
  getProfessionalDetailsEndpoint,
@@ -819,6 +839,7 @@ module.exports = {
819
839
  getMePortfoliosEndpoint,
820
840
  getTranslationsEndpoint,
821
841
  getTalentMeEndpoint,
842
+ getUserMeEndpoint,
822
843
  getOnboardingInterviewsEndpoint,
823
844
  getCompleteOnboardingEndpoint,
824
845
  getOnboardingRestartEndpoint,
@@ -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
@@ -355,8 +355,10 @@ const catalogs = {
355
355
  subagents: (n) => `${n} ${n === 1 ? 'run' : 'runs'} de subagente`,
356
356
  hours: (h) => `${h} h activas (gap idle > 25 min = pausa)`,
357
357
  commits: (authored, total) => `${authored}/${total} commits tuyos`,
358
- lines: (added, deleted) => `+${added}/-${deleted} líneas`,
359
- velocity: (v) => `${v} commits/día`,
358
+ lines: (added, deleted) => `+${added}/-${deleted} líneas (del repo)`,
359
+ velocity: (v) => `${v} commits/día (del repo)`,
360
+ authorshipMismatch: (emails, account) =>
361
+ `Tus commits en ${emails.length === 1 ? 'este repo' : 'estos repos'} están a nombre de ${emails.join(', ')}, que no es tu email de Shakers (${account}). Puede que tu trabajo no se te atribuya: configura git con tu email de Shakers (git config user.email "${account}") para que cuente.`,
360
362
  workStreams: (streams, multiDay, maxSpan) =>
361
363
  `${streams} ${streams === 1 ? 'workstream' : 'workstreams'} (${multiDay} multi-día, máx ${maxSpan}d)`,
362
364
  tractionLabel: 'Actividad reciente',
@@ -531,7 +533,7 @@ const catalogs = {
531
533
  error: 'No se pudo generar el informe.',
532
534
  },
533
535
  frameworkIntroUsage:
534
- 'Esto mide tu fluidez con IA en dos ejes: Setup (madurez de tu tooling, T0–T7, determinista) y Usage (cómo lo usas, evaluado en entrevista + código). Te sitúa en el mapa 3×3 (Explorer → AI Native). Completarlo posiciona tu perfil agéntico para los proyectos que valoran cómo trabajas con IA.',
536
+ 'Esto mide tu fluidez con IA en dos ejes: Setup (madurez de tu tooling, T0–T7, determinista) y Usage (cómo lo usas, evaluado en entrevista + código). Te sitúa en el mapa 3×3 (Explorer → AI Native). Completarlo posiciona tu perfil agéntico para los proyectos que valoran cómo trabajas con IA. Por defecto leo el uso de IA de todo este equipo y asumo que es tuyo (un equipo = tú).',
535
537
  // Terminal progress feedback (talents-ai-score): stderr-only status
536
538
  // during the two slow phases (see src/terminal-progress.js).
537
539
  scanningLabel: 'Escaneando entorno y detectores…',
@@ -635,17 +637,17 @@ const catalogs = {
635
637
  + 'puede acarrear penalizaciones en tu cuenta de Shakers, incluida la '
636
638
  + 'posible suspensión. Consulta el README de este repositorio para '
637
639
  + 'más detalle.',
638
- persistQuestion: '¿Guardar este informe en Shakers? (s/n):',
640
+ persistQuestion: '¿Guardar este informe en Shakers? [s/N]:',
639
641
  invalidAnswer: 'Respuesta no reconocida. Responde "s" (sí) o "n" (no).',
640
642
  emailPrompt: 'Introduce tu correo:',
641
- 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}`,
642
645
  invalidEmail: 'Correo no válido, inténtalo de nuevo.',
643
646
  notObtained: 'No se ha podido registrar tu respuesta; se te volverá a preguntar la próxima vez.',
644
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.
645
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.',
646
649
  deniedSaved: 'Entendido, no se guardará nada. Puedes cambiar de opinión más adelante volviendo a ejecutar el comando.',
647
650
  grantedSaved: (email) => `Gracias. A partir de ahora este informe se guardará automáticamente en Shakers (correo: ${email}, máx. 1 vez por hora).`,
648
- grantedSavedExternal: (email) => `Gracias. Hemos guardado tu email (${email}) para que el equipo de Shakers pueda contactarte — no lo hemos verificado.`,
649
651
  // talents-ai-score, issue 130: un talento logueado NUNCA ve el prompt de email (su cuenta ya prueba su identidad).
650
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.',
651
653
  skipAlreadyDecided: (decision, path) =>
@@ -2001,8 +2003,10 @@ const catalogs = {
2001
2003
  subagents: (n) => `${n} subagent ${n === 1 ? 'run' : 'runs'}`,
2002
2004
  hours: (h) => `${h} active h (idle gap > 25 min = pause)`,
2003
2005
  commits: (authored, total) => `${authored}/${total} commits by you`,
2004
- lines: (added, deleted) => `+${added}/-${deleted} lines`,
2005
- velocity: (v) => `${v} commits/day`,
2006
+ lines: (added, deleted) => `+${added}/-${deleted} lines (repo-wide)`,
2007
+ velocity: (v) => `${v} commits/day (repo-wide)`,
2008
+ authorshipMismatch: (emails, account) =>
2009
+ `Your commits in ${emails.length === 1 ? 'this repo' : 'these repos'} are under ${emails.join(', ')}, which is not your Shakers email (${account}). Your work may not be attributed to you: set git to your Shakers email (git config user.email "${account}") so it counts.`,
2006
2010
  workStreams: (streams, multiDay, maxSpan) =>
2007
2011
  `${streams} ${streams === 1 ? 'workstream' : 'workstreams'} (${multiDay} multi-day, max ${maxSpan}d)`,
2008
2012
  tractionLabel: 'Traction',
@@ -2173,7 +2177,7 @@ const catalogs = {
2173
2177
  error: 'Could not generate the report.',
2174
2178
  },
2175
2179
  frameworkIntroUsage:
2176
- 'This measures your AI fluency across two axes: Setup (your tooling\'s maturity, T0–T7, deterministic) and Usage (how you use it, assessed via interview + code). It places you on the 3×3 map (Explorer → AI Native). Completing it positions your agentic profile for projects that value how you work with AI.',
2180
+ 'This measures your AI fluency across two axes: Setup (your tooling\'s maturity, T0–T7, deterministic) and Usage (how you use it, assessed via interview + code). It places you on the 3×3 map (Explorer → AI Native). Completing it positions your agentic profile for projects that value how you work with AI. By default it reads the AI usage across this whole machine and assumes it is yours (one machine = you).',
2177
2181
  scanningLabel: 'Scanning environment and detectors…',
2178
2182
  // Loader for the per-selected-repo aggregation phase (git + sessions + steering/decisions + evidence + agent org chart).
2179
2183
  aggregatingLabel: 'Aggregating signals across the selected repos…',
@@ -2273,17 +2277,17 @@ const catalogs = {
2273
2277
  + 'yours or that you are not authorized to analyze— may result in penalties '
2274
2278
  + "on your Shakers account, up to and including suspension. See this "
2275
2279
  + "repository's README for more detail.",
2276
- persistQuestion: 'Save this report in Shakers? (y/n):',
2280
+ persistQuestion: 'Save this report in Shakers? [y/N]:',
2277
2281
  invalidAnswer: 'Answer not recognized. Reply "y" (yes) or "n" (no).',
2278
2282
  emailPrompt: 'Enter your email:',
2279
- 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}`,
2280
2285
  invalidEmail: 'Invalid email, try again.',
2281
2286
  notObtained: "Couldn't record your answer; you'll be asked again next time.",
2282
2287
  // talents-ai-score, ADR-051: this question is now asked BEFORE any AI/egress call (agent-synthesis, agent-evaluation, roadmap personalization), not after.
2283
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.',
2284
2289
  deniedSaved: 'Understood, nothing will be saved. You can change your mind later by running the command again.',
2285
2290
  grantedSaved: (email) => `Thanks. From now on this report will be saved in Shakers automatically (email: ${email}, max. once per hour).`,
2286
- grantedSavedExternal: (email) => `Thanks. We've saved your email (${email}) so the Shakers team can reach out — we haven't verified it.`,
2287
2291
  // talents-ai-score, issue 130: a logged-in Talent is NEVER asked for an email (their account already proves identity).
2288
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.",
2289
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
  },