shakers-cli-beta 0.8.0-beta.5 → 0.10.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 (45) hide show
  1. package/README.md +39 -9
  2. package/bin/ai-usage.js +37 -19
  3. package/bin/login.js +1 -1
  4. package/bin/mcp.js +25 -21
  5. package/bin/mcpb.js +5 -0
  6. package/package.json +3 -2
  7. package/src/auth-session-store.js +1 -0
  8. package/src/authorship.js +7 -6
  9. package/src/backend-request.js +2 -2
  10. package/src/certify-dimension-client.js +0 -54
  11. package/src/certify-dimension-flow.js +20 -81
  12. package/src/config.js +29 -8
  13. package/src/cv-file.js +62 -0
  14. package/src/device-login.js +5 -3
  15. package/src/find-projects-client.js +16 -6
  16. package/src/git-activity.js +8 -7
  17. package/src/git-log-prefetch.js +53 -0
  18. package/src/i18n.js +33 -58
  19. package/src/mcp-ai-usage-tool.js +150 -65
  20. package/src/mcp-auth-form.js +213 -44
  21. package/src/mcp-certify-dimension-tools.js +2 -2
  22. package/src/mcp-choice.js +47 -0
  23. package/src/mcp-install.js +32 -3
  24. package/src/mcp-instructions.js +27 -0
  25. package/src/mcp-onboarding-tools.js +3 -3
  26. package/src/mcp-prompts.js +73 -0
  27. package/src/mcp-rate-tools.js +2 -2
  28. package/src/mcp-register-tools.js +89 -313
  29. package/src/mcp-role-tools.js +177 -16
  30. package/src/mcp-server.js +108 -4
  31. package/src/mcp-signup-client.js +118 -0
  32. package/src/mcp-signup-tools.js +818 -0
  33. package/src/onboarding-client.js +28 -52
  34. package/src/onboarding-flow.js +3 -147
  35. package/src/onboarding-status-client.js +16 -1
  36. package/src/register-context.js +1 -17
  37. package/src/roles-client.js +3 -1
  38. package/src/scanner.js +13 -1
  39. package/src/session-normalizers.js +174 -0
  40. package/src/session-scan.js +149 -10
  41. package/src/signup-client.js +33 -21
  42. package/src/signup-copy.js +338 -0
  43. package/src/signup-language.js +21 -0
  44. package/src/usage-run-steps.js +3 -2
  45. package/src/work-streams.js +7 -2
package/README.md CHANGED
@@ -97,7 +97,7 @@ The **usage** signal is entirely local (no network, no model). See
97
97
  One line:
98
98
 
99
99
  ```bash
100
- curl -fsSL https://raw.githubusercontent.com/devShakers/ai-usage-evaluator/main/install.sh | bash
100
+ curl -fsSL https://raw.githubusercontent.com/devShakers/ai-usage-evaluator/release/install.sh | bash
101
101
  ```
102
102
 
103
103
  The installer:
@@ -139,7 +139,7 @@ env vars and `config.json` always win over the baked default.
139
139
  | Local | (checkout, `@shakers/shakers-cli`) | — | dev, or `SHAKERS_CLI_ENV=local` for localhost | run from the clone; no publish |
140
140
 
141
141
  Both publishes bump `version` first (the registry rejects re-publishing an
142
- existing version). The private one publishes from a checked-out `main` with the
142
+ existing version). The private one publishes from a checked-out `release` with the
143
143
  committed `@shakers/shakers-cli` manifest, unchanged. For the beta, bump
144
144
  `BETA.version` in `scripts/prepare-beta.js` (manual, one line), then run the
145
145
  single command `npm run publish:beta`: it stamps `name`/`version`/`publishConfig`,
@@ -380,15 +380,39 @@ Desktop) can drive the same features as tools. It exposes, among others:
380
380
 
381
381
  - **AI usage**: `ai_usage`, `ai_usage_result`.
382
382
  - **Auth**: `login`, `logout`, `social_signin_start`, `social_signin_poll`.
383
- - **Register / onboarding**: `register`, `register_status`,
384
- `onboarding_interview_start`, `onboarding_interview_turn`,
385
- `onboarding_interview_complete`, `repeat_onboarding_interview`
383
+ - **Sign-up** (from the talent's own AI, ticket 39540; copy from UX review
384
+ 39574): every talent-facing text is fixed, comes from the tools in the
385
+ talent's language (es/en/it/pt, fixed by their first message) and the AI shows
386
+ it word for word. `signup_start` (welcome and permission to look for the CV),
387
+ `signup_email` (the email they read most), `signup_draft` (the profile draft
388
+ and the data notice, confirmed before the account exists),
389
+ `signup_create_account` (starts the hub import of LinkedIn + CV + what the AI
390
+ knows and opens the local password/Google window, which claims that profile
391
+ with a code kept only in memory; if the email already has an account, the
392
+ window asks the talent to sign in instead), `signup_status`,
393
+ `update_existing_profile` (shows what would change in an existing profile and
394
+ re-imports filling only empty fields), `save_profile_details`,
395
+ `import_profile`, `open_web` (opens the web signed in through a one-time
396
+ link), `open_linkedin_profile` (only when the talent does not know where to
397
+ find their LinkedIn URL), plus `suggest_register_context` and `read_cv`.
398
+ `ai_usage` is the step after the account exists, right after its two
399
+ disclaimers.
400
+ - **Onboarding interview**: `onboarding_interview_start`,
401
+ `onboarding_interview_turn`, `onboarding_interview_complete` (if LiveKit
402
+ cannot load, the talent is sent to the web), `repeat_onboarding_interview`
386
403
  (OVERWRITE-repeat — the agent must warn the talent it is irreversible before
387
- calling), `onboarding_finish`.
404
+ calling), `onboarding_finish` (only after a repeat; the sign-up never calls it).
388
405
  - **Roles**: `list_available_roles`, `add_role`, `list_my_roles`, `set_main_role`
389
406
  — `set_main_role` is the onboarding **final step** (after
390
407
  `onboarding_interview_complete`, or if the interview was skipped, offer
391
408
  `list_my_roles` and set the picked one).
409
+ - **Closed questions** of the sign-up (AI-usage consent, which email, interview
410
+ here or later, how to answer it, main role) are answered by picking an option.
411
+ When the client declares the MCP `elicitation` capability (spec 2025-06-18),
412
+ the tool asks in the client's own dialog (`elicitation/create`) and gets the
413
+ pick directly; otherwise it returns `question` and `options` and the AI offers
414
+ them as buttons or a numbered list. The legal texts are always shown in the
415
+ chat before any consent dialog.
392
416
  - **Profile / skills / agents**: `list_addable`, `add_skill`, `add_agent`,
393
417
  `add_project`, `get_profile`, `get_rate`, `get_languages`, `list_socials`,
394
418
  `list_experiences`, `list_portfolios`, `get_availability`.
@@ -398,7 +422,8 @@ Desktop) can drive the same features as tools. It exposes, among others:
398
422
  - **Alma**: `ask`.
399
423
 
400
424
  MCP tool names are **stable** (renaming would break existing client configs); the
401
- role tools above were added, not renamed.
425
+ role tools above were added, not renamed. `register`, `register_preview` and
426
+ `register_status` were removed with the sign-up redesign, not renamed.
402
427
 
403
428
  ## Try it
404
429
 
@@ -587,9 +612,10 @@ run without a session (that is how you obtain/drop one); `logout` with no
587
612
  session is a silent no-op.
588
613
 
589
614
  ```bash
590
- shakers login # no method flag: interactive picker (Email / Google) on a real TTY
615
+ shakers login # no method flag: interactive picker (Email / Google / LinkedIn) on a real TTY
591
616
  shakers login --email you@shakers.com # skip the picker, prompt only for the password
592
617
  shakers login --google # skip the picker, go straight to the Google flow
618
+ shakers login --linkedin # skip the picker, go straight to the LinkedIn flow
593
619
  shakers logout # forget the stored session (no-op if none)
594
620
  ```
595
621
 
@@ -608,7 +634,11 @@ shakers logout # forget the stored session (no-op if none)
608
634
  your machine — no client id, no loopback server, no secret; the Hub owns all
609
635
  Google config and does the token exchange server-side. After you authorize, the
610
636
  Hub session is exchanged for the same API JWT the email flow stores.
611
- - **LinkedIn is parked** (ADR-041/046) — not implemented.
637
+ - **LinkedIn** signs in through the same device flow as Google, with
638
+ `provider=linkedin`: the `login --linkedin` / `--provider linkedin` flags and
639
+ the sign-in window served to AI apps (the loopback method-choice screen) both
640
+ route to it. This reverses the earlier parking (ADR-041/046). Whether it
641
+ completes depends on the Hub having a LinkedIn OAuth app provisioned.
612
642
  - The session (`{ accessToken, expiresAt, email }`) is stored **atomically**,
613
643
  permissions `600`, in `~/.config/shakers/auth-session.json`. **Logged in**
614
644
  means "an unexpired token is present" — an unparseable or missing
package/bin/ai-usage.js CHANGED
@@ -6,6 +6,10 @@ const { detectReportLang, getCatalog } = require('../src/i18n');
6
6
  const { parseArgs } = require('../src/cli-args');
7
7
  const { loadConsentState } = require('../src/share');
8
8
  const { handleOneShotCommand } = require('../src/usage-oneshot-commands');
9
+ const { prefetchGitLogs } = require('../src/git-log-prefetch');
10
+ const { GIT_ACTIVITY_LOG_ARGS } = require('../src/git-activity');
11
+ const { WORK_STREAMS_LOG_ARGS } = require('../src/work-streams');
12
+ const { AUTHORSHIP_LOG_ARGS } = require('../src/authorship');
9
13
  const {
10
14
  resolveRepoScope,
11
15
  machineWideToplevels,
@@ -20,29 +24,14 @@ const {
20
24
  renderAndFinalize,
21
25
  } = require('../src/usage-run-steps');
22
26
 
23
- // `run(argv, { ask })`: shared by the dispatcher and register flow; an injected
24
- // `ask` is caller-owned. `bypassThrottle` (options, not argv) stays register-only.
25
- async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypassThrottle = false, inRegistration = false } = {}) {
26
- const opts = parseArgs(argv);
27
-
28
- // Resolve language first so even --help is localized.
29
- const lang = opts.lang || detectReportLang();
30
- const catalog = getCatalog(lang);
31
-
32
- if (opts.help) {
33
- process.stdout.write(catalog.cli.help);
34
- return;
35
- }
36
-
37
- // One-shot commands (consent/endpoint config): act immediately, do NOT scan.
38
- if (await handleOneShotCommand({ opts, catalog, injectedAsk })) return;
39
-
27
+ // Steps 1-6 of a run: scan, scope, aggregate, classify; nothing is rendered or sent.
28
+ async function computeUsage({ opts, catalog, lang, injectedAsk = null, inRegistration = false }) {
40
29
  // 1. Local scan + classify (always computed and shown, ADR-011/013).
41
30
  const { report, root, rootDir } = performScan({ opts, catalog });
42
31
 
43
32
  // 2. Identity gate + local agent-usage signal. Roadmap only for anonymous runs
44
33
  // (gate.showRoadmap = !loggedIn) AND never during register/onboarding.
45
- const { gate: identityGate } = prepareIdentityAndUsage({ report, catalog });
34
+ const { gate: identityGate } = prepareIdentityAndUsage({ report, catalog, quiet: opts.json === true });
46
35
  const gate = (inRegistration && identityGate.showRoadmap)
47
36
  ? { ...identityGate, showRoadmap: false }
48
37
  : identityGate;
@@ -56,6 +45,7 @@ async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypa
56
45
  const agentRoots = scope.toplevels;
57
46
 
58
47
  // 5. Aggregate scoped signals + rebuild scan/org-chart over the selection, re-classify.
48
+ await prefetchGitLogs(agentRoots, [GIT_ACTIVITY_LOG_ARGS, WORK_STREAMS_LOG_ARGS, AUTHORSHIP_LOG_ARGS]);
59
49
  runAggregation({ report, scope, consentEmail, agentRoots, root, catalog });
60
50
  const maturity = classify(report);
61
51
  const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
@@ -68,12 +58,40 @@ async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypa
68
58
  await runModelCalls({
69
59
  report, maturity, aiConsentGranted, opts, gate, hasAgents, agentRoots, rootDir, lang, catalog,
70
60
  });
61
+ return { report, maturity, root, gate };
62
+ }
63
+
64
+ // The MCP's scan: same steps, returned instead of printed, since stdout is its JSON-RPC channel.
65
+ async function scanUsage(argv) {
66
+ const opts = parseArgs(argv);
67
+ const lang = opts.lang || detectReportLang();
68
+ const { report, maturity } = await computeUsage({ opts, catalog: getCatalog(lang), lang });
69
+ return { report, maturity };
70
+ }
71
+
72
+ // Shared by the dispatcher and register flow: an injected `ask` is caller-owned, `bypassThrottle` is register-only.
73
+ async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypassThrottle = false, inRegistration = false } = {}) {
74
+ const opts = parseArgs(argv);
75
+
76
+ // Resolve language first so even --help is localized.
77
+ const lang = opts.lang || detectReportLang();
78
+ const catalog = getCatalog(lang);
79
+
80
+ if (opts.help) {
81
+ process.stdout.write(catalog.cli.help);
82
+ return;
83
+ }
84
+
85
+ // One-shot commands (consent/endpoint config): act immediately, do NOT scan.
86
+ if (await handleOneShotCommand({ opts, catalog, injectedAsk })) return;
87
+
88
+ const { report, maturity, root, gate } = await computeUsage({ opts, catalog, lang, injectedAsk, inRegistration });
71
89
 
72
90
  // 7. Render (or emit --json), persist, optional --build-next-level, auto-share.
73
91
  await renderAndFinalize({ report, maturity, lang, opts, root, gate, catalog, bypassThrottle });
74
92
  }
75
93
 
76
- module.exports = { run, resolveRepoScope, machineWideToplevels, promptScope };
94
+ module.exports = { run, scanUsage, resolveRepoScope, machineWideToplevels, promptScope };
77
95
 
78
96
  // Auto-run only when executed directly, not when require()'d by the dispatcher.
79
97
  if (require.main === module) {
package/bin/login.js CHANGED
@@ -78,7 +78,7 @@ async function run(
78
78
  if (opts.google) return runSocial('google', c, { openBrowser, sleep, lang });
79
79
  if (opts.linkedin) return runSocial('linkedin', c, { openBrowser, sleep, lang });
80
80
 
81
- // No method flag: offer the picker (ADR-041/046 — Email and Google only, LinkedIn stays parked).
81
+ // No method flag: offer the picker (ADR-041/046 — Email, Google and LinkedIn; LinkedIn pending Hub OAuth provisioning).
82
82
  if (!hasMethodFlag(opts)) {
83
83
  const stdinIsTTY = ttyOverride !== undefined ? ttyOverride : !!process.stdin.isTTY;
84
84
  if (stdinIsTTY) {
package/bin/mcp.js CHANGED
@@ -23,8 +23,11 @@ const { makeOnboardingTools } = require('../src/mcp-onboarding-tools');
23
23
  const { makeRoleTools } = require('../src/mcp-role-tools');
24
24
  const { makeDeviceLoginTools } = require('../src/mcp-device-login-tools');
25
25
  const { makeRegisterTools } = require('../src/mcp-register-tools');
26
+ const { makeSignupTools } = require('../src/mcp-signup-tools');
26
27
  const { detectFlowLang } = require('../src/i18n');
27
28
  const { makeReportTools } = require('../src/mcp-report-tools');
29
+ const { makePrompts } = require('../src/mcp-prompts');
30
+ const { buildServerInstructions } = require('../src/mcp-instructions');
28
31
  const { ensureFreshSession } = require('../src/session-refresh');
29
32
 
30
33
  let VERSION = '';
@@ -42,7 +45,10 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
42
45
  + ` MCP client, not run by hand.\n`
43
46
  + ` Tools: ai_usage, ai_usage_result, login, logout, social_signin_start, social_signin_poll,\n`
44
47
  + ` list_addable, add_skill, add_agent,\n`
45
- + ` add_project, suggest_register_context, read_cv, register_preview, register, register_status, onboarding_interview_start,\n`
48
+ + ` add_project, signup_start, signup_email, signup_draft, signup_create_account,\n`
49
+ + ` signup_status, update_existing_profile, import_profile, save_profile_details, open_web,\n`
50
+ + ` open_linkedin_profile,\n`
51
+ + ` suggest_register_context, read_cv, onboarding_interview_start,\n`
46
52
  + ` onboarding_interview_turn, onboarding_interview_complete,\n`
47
53
  + ` repeat_onboarding_interview, onboarding_finish,\n`
48
54
  + ` list_available_roles, add_role, list_my_roles, set_main_role,\n`
@@ -53,26 +59,23 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
53
59
  + ` list_certifications, get_profile, get_rate, get_languages,\n`
54
60
  + ` list_socials, list_experiences, list_portfolios, ask.\n\n`;
55
61
 
56
- // The register and onboarding flows span several tools. Clients that load tool
57
- // schemas on demand only see the tool the model picked first, so the flow lives
58
- // here, where the client keeps it in context.
59
- const SERVER_INSTRUCTIONS = [
60
- 'Shakers talent tools. When the talent asks to register or sign up on Shakers, go straight to a filled draft: you fill the registration from what you know and from their CV, and they only confirm. Never open with a questionnaire or a list of what you do not know.',
61
- 'Register flow — load and use these tools in this order:',
62
- '1. First reply: ONE short question — may you search for and read their CV on this machine to fill everything in?',
63
- '2. With their yes: suggest_register_context (with talentName), then read_cv on the candidates (talentConsent:true, talentName) until one has mentionsTalent:true. Drop any CV with mentionsTalent:false (someone else\'s) without describing it. Take LinkedIn, GitHub, experience, languages and location from their CV.',
64
- 'No CV, no permission, or none of the CVs is theirs: do NOT fall back to questions. Build the same draft from everything else you know about them: your memory of past conversations, a search of those conversations if you have that tool, this conversation and any context or memory files you can see. The CV adds facts; it is never a precondition.',
65
- '3. Infer EVERY field: employment from what you know (a company email or employer means employed there), pricing as a concrete estimate from role, seniority and location, motivation as your best match. Call register_preview.',
66
- '4. Show ONE summary saying where each value comes from; only truly unknown required fields as a short question at the end, optional unknowns in one line. The sign-up goes to suggest_register_context\'s `environment`: never call a test environment production.',
67
- '5. After their confirmation, call register.',
68
- 'Onboarding interview (onboarding_interview_start / onboarding_interview_turn): follow the ANSWER MODE described in onboarding_interview_start.',
69
- ].join('\n');
62
+ // The LOCAL (filesystem/machine) tools — the only ones the .mcpb bundle exposes; the
63
+ // server-backed tools belong to the remote connector.
64
+ const LOCAL_ONLY_TOOL_NAMES = new Set(['ai_usage', 'ai_usage_result', 'read_cv', 'suggest_register_context']);
70
65
 
71
- function buildServer() {
66
+ // `localOnly` builds the scan-only server for the Claude .mcpb bundle, reusing the SAME
67
+ // tool registry (just filtered) — no duplicate registration.
68
+ function buildServer({ localOnly = false } = {}) {
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()];
71
+ const tools = localOnly ? allTools.filter((t) => LOCAL_ONLY_TOOL_NAMES.has(t.name)) : allTools;
72
+ // Slash-command prompts, kept to the tools actually registered in this mode.
73
+ const prompts = makePrompts(new Set(tools.map((t) => t.name)), lang);
72
74
  return createMcpServer({
73
- tools: [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools(), ...makeDeviceLoginTools(), ...makeRegisterTools({ lang: detectFlowLang() }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAskTools(), ...makeAlmaTools()],
74
- serverInfo: { name: 'Shakers', version: VERSION || '0.0.0' },
75
- instructions: SERVER_INSTRUCTIONS,
75
+ tools,
76
+ prompts,
77
+ serverInfo: { name: localOnly ? 'Shakers (local scan)' : 'Shakers', version: VERSION || '0.0.0' },
78
+ instructions: localOnly ? null : (client) => buildServerInstructions(client),
76
79
  // Long-lived server: refresh the email session's hub JWT from the stored
77
80
  // cookie before each tool call so a stale (~15-min) JWT never reaches a tool.
78
81
  beforeCall: () => ensureFreshSession(process.env),
@@ -92,7 +95,8 @@ async function run(argv = process.argv.slice(2)) {
92
95
 
93
96
  require('../src/config').sanitizeStaleBakedEndpoints(process.env);
94
97
 
95
- const server = buildServer();
98
+ // `--local` = the scan-only server shipped in the Claude .mcpb bundle.
99
+ const server = buildServer({ localOnly: argv.includes('--local') });
96
100
  const rl = server.start({
97
101
  input: process.stdin,
98
102
  output: process.stdout,
@@ -107,7 +111,7 @@ async function run(argv = process.argv.slice(2)) {
107
111
  });
108
112
  }
109
113
 
110
- module.exports = { run, buildServer };
114
+ module.exports = { run, buildServer, buildServerInstructions };
111
115
 
112
116
  if (require.main === module) {
113
117
  run();
package/bin/mcpb.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Claude Desktop's built-in Node imports this entry from its own host, so it starts without a require.main check.
5
+ require('./mcp').run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.8.0-beta.5",
3
+ "version": "0.10.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": {
@@ -16,13 +16,14 @@
16
16
  "start": "node bin/shakers.js",
17
17
  "shakers": "node bin/shakers.js",
18
18
  "test": "node --test test/*.test.js",
19
+ "build:mcpb": "sh scripts/build-mcpb.sh",
19
20
  "publish:beta": "node scripts/prepare-beta.js && npm publish --tag latest && git checkout -- package.json"
20
21
  },
21
22
  "engines": {
22
23
  "node": ">=18"
23
24
  },
24
25
  "dependencies": {
25
- "@livekit/rtc-node": "^0.13.0"
26
+ "@livekit/rtc-node": "0.13.35"
26
27
  },
27
28
  "keywords": [
28
29
  "shakers",
@@ -210,5 +210,6 @@ module.exports = {
210
210
  isPlatformTalent,
211
211
  PLATFORM_TALENT_USER_TYPES,
212
212
  decodeJwtPayload,
213
+ emailFromHubToken,
213
214
  firstNameFromSession,
214
215
  };
package/src/authorship.js CHANGED
@@ -5,9 +5,13 @@ const path = require('path');
5
5
 
6
6
  // Git AUTHORSHIP + provenance for the certify phase (skill-code-certification, ADR-017).
7
7
 
8
+ const { takePrefetchedGitLog } = require('./git-log-prefetch');
9
+
8
10
  const GIT_TIMEOUT_MS = 15000;
9
11
  const SHORT_SHA_LEN = 10;
10
12
 
13
+ const AUTHORSHIP_LOG_ARGS = ['log', '--no-merges', '--pretty=format:\x01%ae\x1f%at', '--name-only'];
14
+
11
15
  function runGit(root, args) {
12
16
  try {
13
17
  return execFileSync('git', ['-C', root, ...args], {
@@ -72,12 +76,8 @@ function getRootPrefix(root) {
72
76
  function buildPathIndex(root) {
73
77
  const authorsByPath = new Map();
74
78
  const timeByPath = new Map();
75
- const log = runGit(root, [
76
- 'log',
77
- '--no-merges',
78
- '--pretty=format:\x01%ae\x1f%at',
79
- '--name-only',
80
- ]);
79
+ const prefetched = takePrefetchedGitLog(root, AUTHORSHIP_LOG_ARGS);
80
+ const log = prefetched !== undefined ? prefetched : runGit(root, AUTHORSHIP_LOG_ARGS);
81
81
  if (log === null) return { authorsByPath, timeByPath };
82
82
 
83
83
  let currentEmail = null;
@@ -196,6 +196,7 @@ function attributeSample(sample, verifiedEmail, authorship, authorizedSet = null
196
196
 
197
197
  module.exports = {
198
198
  collectAuthorship,
199
+ AUTHORSHIP_LOG_ARGS,
199
200
  attributeSample,
200
201
  // exported for unit tests
201
202
  normalizeRemote,
@@ -113,8 +113,8 @@ function postMultipartWithTimeout(url, fields, timeoutMs, method = 'POST', extra
113
113
  chunks.push(value.data, Buffer.from('\r\n'));
114
114
  continue;
115
115
  }
116
- const clean = String(value).replace(UNSAFE_HEADER_VALUE_RE, '');
117
- chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${clean}\r\n`));
116
+ // The boundary delimits a part, so a value keeps its line breaks (a multi-line userQuery).
117
+ chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${String(value)}\r\n`));
118
118
  }
119
119
  chunks.push(Buffer.from(`--${boundary}--\r\n`));
120
120
  const data = Buffer.concat(chunks);
@@ -4,7 +4,6 @@
4
4
 
5
5
  const { postJsonWithTimeout } = require('./backend-request');
6
6
  const { reasonForError } = require('./onboarding-client');
7
- const { toUpperLang } = require('./lang-codes');
8
7
 
9
8
  const DEFAULT_TIMEOUT_MS = 20000;
10
9
 
@@ -154,62 +153,9 @@ async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessTo
154
153
  return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
155
154
  }
156
155
 
157
- async function requestCreateCertificationInterview(
158
- { dimensionKey, languageCode, accessToken } = {},
159
- { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
160
- ) {
161
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
162
- if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
163
- const body = { dimensionKey };
164
- if (languageCode) body.languageCode = toUpperLang(languageCode);
165
- let res;
166
- try {
167
- res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, bearerHeaders(accessToken));
168
- } catch (e) {
169
- return { ok: false, reason: (e && e.kind) || 'network-error' };
170
- }
171
- if (res.status < 200 || res.status >= 300) {
172
- const reason = reasonForError(res.status, res.raw);
173
- if (reason !== 'dimension-on-cooldown') return { ok: false, reason };
174
- // certs puts the date in the message only: "... available on 2026-10-28T14:50:38.265Z".
175
- const available = String(res.raw || '').match(/available on (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/);
176
- return { ok: false, reason, availableOn: available ? available[1] : null };
177
- }
178
- const data = payloadOf(res.raw);
179
- const interviewId = pick(data || {}, ['interviewId', 'id']);
180
- if (!interviewId) return { ok: false, reason: 'bad-response' };
181
- return { ok: true, interviewId, language: pick(data || {}, ['language', 'languageCode']) || null };
182
- }
183
-
184
- // Poll the verdict; a 2xx/404/425/202 with no band yet resolves `ready:false` (retriable).
185
- async function requestCertificationReport(
186
- { interviewId, accessToken } = {},
187
- { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
188
- ) {
189
- if (!base) return { ok: false, reason: 'no-endpoint' };
190
- if (!interviewId) return { ok: false, reason: 'no-interview' };
191
- const url = `${base.replace(/\/+$/, '')}/${encodeURIComponent(interviewId)}/report`;
192
- let res;
193
- try {
194
- res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
195
- } catch (e) {
196
- return { ok: false, reason: (e && e.kind) || 'network-error' };
197
- }
198
- if (res.status === 404 || res.status === 425 || res.status === 202) return { ok: true, ready: false, report: null };
199
- if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
200
- const data = payloadOf(res.raw);
201
- if (data === undefined || data === null || typeof data !== 'object') return { ok: true, ready: false, report: null };
202
- const band = pick(data, ['band', 'level', 'combinedLevel']);
203
- // A report with too little evidence for a level has band null; it is still finished.
204
- if (!band && !pick(data, ['evaluationId'])) return { ok: true, ready: false, report: null };
205
- return { ok: true, ready: true, report: data };
206
- }
207
-
208
156
  module.exports = {
209
157
  requestMyCertifications,
210
158
  requestTemplatesByDimension,
211
- requestCreateCertificationInterview,
212
- requestCertificationReport,
213
159
  composeDimensionKey,
214
160
  composeOfferableDimensions,
215
161
  discoverOfferableDimensions,
@@ -1,23 +1,15 @@
1
1
  'use strict';
2
2
 
3
- // `certify` dimension flow: discover -> pick -> create -> LiveKit interview -> poll verdict.
3
+ // `certify` dimension flow: discover -> pick -> hand off to the web interview.
4
4
 
5
5
  const { runInteractiveMultiSelect } = require('./interactive-select');
6
6
 
7
7
  function makeCertifyDimensionDeps(overrides = {}) {
8
8
  const config = require('./config');
9
9
  const client = require('./certify-dimension-client');
10
- const onboardingFlow = require('./onboarding-flow');
11
10
  return {
12
- getCertificationInterviewsEndpoint: config.getCertificationInterviewsEndpoint,
13
- // `{certsBase}/interviews` — shared base for LiveKit start/complete + the report poll.
14
- getInterviewsBase: config.getOnboardingInterviewsEndpoint,
11
+ getTalentProfileUrl: config.getTalentProfileUrl,
15
12
  discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
16
- requestCreateCertificationInterview: client.requestCreateCertificationInterview,
17
- requestCertificationReport: client.requestCertificationReport,
18
- conductLivekitInterview: onboardingFlow.conductLivekitInterview,
19
- onboardingDeps: onboardingFlow.makeDeps(),
20
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
21
13
  ...overrides,
22
14
  };
23
15
  }
@@ -61,41 +53,19 @@ function resolveDimensionArg(arg, items) {
61
53
  return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
62
54
  }
63
55
 
64
- // The evaluation lands one to two minutes after the interview ends, four when the queue is busy.
65
- async function pollReport(deps, { interviewId, accessToken }, { base, attempts = 60, intervalMs = 5000 } = {}) {
66
- for (let i = 0; i < attempts; i++) {
67
- const res = await deps.requestCertificationReport({ interviewId, accessToken }, { base });
68
- if (!res.ok) return { ok: false, reason: res.reason };
69
- if (res.ready) return { ok: true, report: res.report };
70
- if (i < attempts - 1) await deps.sleep(intervalMs);
56
+ // Deep link into the talent's Shakers web profile, filtered to the chosen dimension.
57
+ // One dimension can publish more than one assessment, so the filtered catalog is the
58
+ // robust target rather than a single templateId.
59
+ function buildWebLink(profileUrl, dimension) {
60
+ if (!profileUrl || !dimension || !dimension.clusterId || !dimension.slug) return null;
61
+ let origin;
62
+ try {
63
+ origin = new URL(profileUrl).origin;
64
+ } catch {
65
+ return null;
71
66
  }
72
- return { ok: false, reason: 'timeout' };
73
- }
74
-
75
- function formatDay(iso, lang) {
76
- const d = iso ? new Date(iso) : null;
77
- if (!d || Number.isNaN(d.getTime())) return null;
78
- return d.toLocaleDateString(lang === 'en' ? 'en-GB' : 'es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
79
- }
80
-
81
- function renderVerdict(report, cd) {
82
- const lines = [];
83
- const band = report.band || report.level || report.combinedLevel;
84
- const dimension = report.dimensionName || report.dimension || report.dimensionKey;
85
- if (dimension) lines.push(cd.verdictDimension(dimension));
86
- if (band) lines.push(cd.verdictBand(band));
87
- if (typeof report.certified === 'boolean') lines.push(report.certified ? cd.verdictCertified : cd.verdictNotCertified);
88
- const summary = report.summary || report.rationale || report.feedback;
89
- if (typeof summary === 'string' && summary.trim()) lines.push(summary.trim());
90
- const areas = Array.isArray(report.areas) ? report.areas : null;
91
- if (areas && areas.length) {
92
- for (const a of areas) {
93
- const name = a && (a.name || a.area || a.tag);
94
- const note = a && (a.note || a.comment);
95
- if (name) lines.push(`- ${name}${note ? `: ${note}` : ''}`);
96
- }
97
- }
98
- return lines.join('\n ');
67
+ const key = `${encodeURIComponent(dimension.clusterId)}/${encodeURIComponent(dimension.slug)}`;
68
+ return `${origin}/certifications?dimension=${key}`;
99
69
  }
100
70
 
101
71
  async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
@@ -146,41 +116,11 @@ async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog
146
116
  }
147
117
  }
148
118
 
149
- const createEndpoint = deps.getCertificationInterviewsEndpoint();
150
- const created = await io.withProgress(cd.creating, () =>
151
- deps.requestCreateCertificationInterview(
152
- { dimensionKey: chosen.dimensionKey, languageCode: lang, accessToken },
153
- { endpoint: createEndpoint },
154
- ));
155
- if (!created.ok) {
156
- io.error(created.reason === 'dimension-on-cooldown'
157
- ? cd.onCooldown(formatDay(created.availableOn, lang))
158
- : cd.createFailed(created.reason));
159
- return { ok: false, reason: created.reason, step: 'create' };
160
- }
161
-
162
- const interview = await deps.conductLivekitInterview(io, deps.onboardingDeps, {
163
- interviewId: created.interviewId,
164
- language: lang,
165
- plain: true,
166
- });
167
- if (!interview.ok) {
168
- io.warn(interview.reason === 'needs-web' ? cd.needsWeb : cd.interviewUnavailable(interview.reason));
169
- return { ok: false, reason: interview.reason, step: 'interview' };
170
- }
171
-
172
- const reportBase = deps.getInterviewsBase();
173
- const polled = await io.withProgress(cd.reportPolling, () =>
174
- pollReport(deps, { interviewId: created.interviewId, accessToken }, { base: reportBase }));
175
- if (!polled.ok) {
176
- io.warn(polled.reason === 'timeout' ? cd.reportTimeout : cd.reportFailed(polled.reason));
177
- return { ok: true, completed: 'interview', verdict: null };
178
- }
179
-
180
- io.section(cd.verdictTitle);
181
- io.notify(renderVerdict(polled.report, cd));
182
- io.success(cd.done);
183
- return { ok: true, completed: 'interview', verdict: polled.report };
119
+ const webLink = buildWebLink(deps.getTalentProfileUrl(), chosen);
120
+ io.notify(cd.webHandoff);
121
+ if (webLink) io.success(cd.webHandoffLink(webLink));
122
+ else io.notify(cd.webHandoffNoLink);
123
+ return { ok: true, handoff: true, dimensionKey: chosen.dimensionKey, webLink };
184
124
  }
185
125
 
186
126
  module.exports = {
@@ -188,6 +128,5 @@ module.exports = {
188
128
  runCertifyDimension,
189
129
  chooseDimension,
190
130
  resolveDimensionArg,
191
- pollReport,
192
- renderVerdict,
131
+ buildWebLink,
193
132
  };
package/src/config.js CHANGED
@@ -318,13 +318,6 @@ function getTemplatesByDimensionEndpoint(env = process.env) {
318
318
  return base ? `${base}/templates/by-dimension` : null;
319
319
  }
320
320
 
321
- function getCertificationInterviewsEndpoint(env = process.env) {
322
- const explicit = readEnv(env, 'CERTIFICATION_INTERVIEWS_ENDPOINT');
323
- if (explicit && explicit.trim()) return explicit.trim();
324
- const base = getCertsBase(env);
325
- return base ? `${base}/users/me/certification-interviews` : null;
326
- }
327
-
328
321
  // Find Positions list (hub `works/positions/find`, talent Bearer). Serves the
329
322
  // All/Recommended/Saved tabs via `criteria.savedByMe`; ordered by match desc.
330
323
  function getFindPositionsEndpoint(env = process.env) {
@@ -535,6 +528,31 @@ function getImportProfileEndpoint(env = process.env) {
535
528
  return deriveFromHub(env, 'works/me/import-profile');
536
529
  }
537
530
 
531
+ // MCP sign-up (ticket 39540): public UNREGISTERED + claim code, its import status, and the one-time web login.
532
+ function getMcpSignupEndpoint(env = process.env) {
533
+ return deriveFromHub(env, 'works-ai/talents/mcp-signup');
534
+ }
535
+
536
+ function getMyImportStatusEndpoint(env = process.env) {
537
+ return deriveFromHub(env, 'works/me/import-profile/status');
538
+ }
539
+
540
+ function getOneTimeTokenEndpoint(env = process.env) {
541
+ return deriveFromHub(env, 'auth/one-time-token/generate');
542
+ }
543
+
544
+ // The ONE place the web route that redeems a one-time token is spelled (the route itself is a later works-frontend ticket).
545
+ function getOneTimeLoginUrl(token, env = process.env) {
546
+ if (typeof token !== 'string' || !token) return null;
547
+ const profile = getTalentProfileUrl(env);
548
+ if (!profile) return null;
549
+ try {
550
+ return `${new URL(profile).origin}/auth/one-time?token=${encodeURIComponent(token)}`;
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+
538
556
  function getProfessionalDetailsEndpoint(env = process.env) {
539
557
  const explicit = readEnv(env, 'PROFESSIONAL_DETAILS_ENDPOINT');
540
558
  if (explicit && explicit.trim()) return explicit.trim();
@@ -731,7 +749,6 @@ module.exports = {
731
749
  getSetMainRoleEndpoint,
732
750
  getAssignedClustersEndpoint,
733
751
  getTemplatesByDimensionEndpoint,
734
- getCertificationInterviewsEndpoint,
735
752
  // Find Positions list (`find-projects` command / `find_projects` MCP tool).
736
753
  getFindPositionsEndpoint,
737
754
  // Marketplace (show-project / save-project / unsave-project / invitations).
@@ -771,6 +788,10 @@ module.exports = {
771
788
  sanitizeStaleBakedEndpoints,
772
789
  getSignUpEndpoint,
773
790
  getImportProfileEndpoint,
791
+ getMcpSignupEndpoint,
792
+ getMyImportStatusEndpoint,
793
+ getOneTimeTokenEndpoint,
794
+ getOneTimeLoginUrl,
774
795
  getProfessionalDetailsEndpoint,
775
796
  getPricingRateEndpoint,
776
797
  getSetAvailabilityEndpoint,