shakers-cli-beta 0.11.0 → 0.12.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.
- package/bin/certify.js +1 -1
- package/bin/login.js +44 -111
- package/package.json +1 -1
- package/src/agents-client.js +1 -1
- package/src/ai-profile-client.js +1 -1
- package/src/applications-client.js +1 -1
- package/src/availability-client.js +1 -1
- package/src/certifications-client.js +1 -1
- package/src/certify-dimension-client.js +0 -79
- package/src/certify-dimension-flow.js +24 -105
- package/src/config.js +31 -22
- package/src/find-projects-client.js +1 -1
- package/src/i18n.js +24 -78
- package/src/lang-client.js +1 -1
- package/src/mcp-device-login-tools.js +3 -3
- package/src/mcp-instructions.js +2 -2
- package/src/mcp-onboarding-tools.js +10 -16
- package/src/mcp-role-tools.js +13 -5
- package/src/mcp-signup-client.js +1 -1
- package/src/mcp-signup-tools.js +76 -18
- package/src/profile-client.js +1 -4
- package/src/rate-client.js +1 -2
- package/src/roles-client.js +0 -4
- package/src/saved-positions-client.js +1 -1
- package/src/show-project-client.js +1 -1
- package/src/signup-copy.js +34 -12
package/src/mcp-signup-tools.js
CHANGED
|
@@ -8,7 +8,7 @@ const { signupCopy, legalCopy, renderDraft, amount } = require('./signup-copy');
|
|
|
8
8
|
const { fixSignupLanguage, signupLanguage } = require('./signup-language');
|
|
9
9
|
const { readLocalCv } = require('./cv-file');
|
|
10
10
|
const { emailFromHubToken } = require('./auth-session-store');
|
|
11
|
-
const { askAfterTexts, queueNotice, takeNotices } = require('./mcp-choice');
|
|
11
|
+
const { askAfterTexts, optionFor, queueNotice, takeNotices } = require('./mcp-choice');
|
|
12
12
|
|
|
13
13
|
// MCP sign-up (ticket 39540): hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
|
|
14
14
|
|
|
@@ -36,6 +36,13 @@ const SECTION_HELP = {
|
|
|
36
36
|
'bad-opinion': 'workSituation.opinion is required unless situation is FREELANCE: pick your best match from its enum.',
|
|
37
37
|
'bad-motivation': 'workSituation.changeMotivators is required when situation is FREELANCE or opinion is WAS_FREELANCE_BEFORE or OPEN_TO_FREELANCE: pick your best match from its enum.',
|
|
38
38
|
};
|
|
39
|
+
// What the closing words may say about the profile import, by the state read at that moment.
|
|
40
|
+
const IMPORT_CLOSING = {
|
|
41
|
+
done: 'The profile import finished: if you mention it, say it is done, never that it is pending.',
|
|
42
|
+
running: 'The profile import is still running: if you mention it, say it finishes on its own in a few minutes.',
|
|
43
|
+
failed: 'Say nothing more about the profile import than the fixed lines already shown.',
|
|
44
|
+
none: 'Say nothing about the state of the profile import.',
|
|
45
|
+
};
|
|
39
46
|
const VERBATIM = 'Print relayVerbatim word for word now, as its own block, before any other tool call, with nothing of your own added to it.';
|
|
40
47
|
|
|
41
48
|
function isPersonalEmail(email) {
|
|
@@ -134,7 +141,7 @@ const WELCOME_SCHEMA = {
|
|
|
134
141
|
language: { type: 'string', description: "ISO code of the language the talent wrote their first message in (es, en, it, pt...). It fixes the language of every sign-up text from here on." },
|
|
135
142
|
firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
|
|
136
143
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
137
|
-
cvPath: { type: 'string', description: 'When the talent\'s
|
|
144
|
+
cvPath: { type: 'string', description: 'When the talent\'s first message or answer already gives their CV as a path on this machine: that exact file is read, with no CV question, no search and no request for the file.' },
|
|
138
145
|
},
|
|
139
146
|
required: ['language'],
|
|
140
147
|
};
|
|
@@ -148,6 +155,9 @@ const EMAIL_SCHEMA = {
|
|
|
148
155
|
},
|
|
149
156
|
};
|
|
150
157
|
|
|
158
|
+
// In the order the data notice names them.
|
|
159
|
+
const DRAFT_SOURCES = ['linkedin', 'cv'];
|
|
160
|
+
|
|
151
161
|
const DRAFT_SCHEMA = {
|
|
152
162
|
type: 'object',
|
|
153
163
|
description: 'Your draft of the profile, from the CV, LinkedIn and what you know. Every field is shown as you write it, in the talent\'s language.',
|
|
@@ -163,9 +173,10 @@ const DRAFT_SCHEMA = {
|
|
|
163
173
|
hourlyRate: { type: 'number', description: `Euros per hour (part-time projects), at most ${MAX_HOURLY_RATE}: the talent's figure, else your estimate from role, seniority, stack and location.` },
|
|
164
174
|
annualRate: { type: 'number', description: `Euros per year (full-time projects), at least ${MIN_ANNUAL_RATE}: the talent's figure, else your estimate.` },
|
|
165
175
|
ratesFromTalent: { type: 'boolean', description: 'true only when the talent gave you both rates; omit it when you estimated them (they are shown as your estimate).' },
|
|
176
|
+
sources: { type: 'array', items: { type: 'string', enum: DRAFT_SOURCES }, description: "What the draft comes from: 'cv' when you read their CV, 'linkedin' only when you have their LinkedIn URL. The data notice names exactly these." },
|
|
166
177
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
167
178
|
},
|
|
168
|
-
required: ['name', 'role', 'hourlyRate', 'annualRate'],
|
|
179
|
+
required: ['name', 'role', 'hourlyRate', 'annualRate', 'sources'],
|
|
169
180
|
};
|
|
170
181
|
|
|
171
182
|
const CREATE_SCHEMA = {
|
|
@@ -275,6 +286,8 @@ function makeSignupTools(deps = {}) {
|
|
|
275
286
|
draft: null,
|
|
276
287
|
draftConfirmed: false,
|
|
277
288
|
keepProfile: false,
|
|
289
|
+
// Fields the update question already told the talent they keep.
|
|
290
|
+
toldKept: new Set(),
|
|
278
291
|
sources: null,
|
|
279
292
|
account: { state: 'not-started' },
|
|
280
293
|
reported: { account: null, import: null },
|
|
@@ -393,11 +406,26 @@ function makeSignupTools(deps = {}) {
|
|
|
393
406
|
state.draftConfirmed = false;
|
|
394
407
|
state.email = null;
|
|
395
408
|
state.failedSources.clear();
|
|
409
|
+
state.toldKept = new Set();
|
|
396
410
|
state.cvPath = null;
|
|
397
411
|
takeNotices();
|
|
398
412
|
}
|
|
399
413
|
if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
|
|
400
414
|
const c = copy();
|
|
415
|
+
const linkedin = `LinkedIn: use the URL in the CV (read_cv returns links.linkedin). Only if it has none, ask \`linkedinAsk\` word for word; only if they say they do not know where to find it, say \`linkedinOpening\` and call open_linkedin_profile. Then call signup_email.`;
|
|
416
|
+
// A CV path in the first message already answers the CV question: welcome only, then that CV.
|
|
417
|
+
if (!nonEmpty(args.answer) && state.cvPath) {
|
|
418
|
+
return {
|
|
419
|
+
ok: true,
|
|
420
|
+
step: 'cv-given',
|
|
421
|
+
cvPath: state.cvPath,
|
|
422
|
+
relayVerbatim: c.welcome(nonEmpty(args.firstName)),
|
|
423
|
+
linkedinAsk: c.linkedinAsk,
|
|
424
|
+
linkedinOpening: c.linkedinOpening,
|
|
425
|
+
language: lang(),
|
|
426
|
+
message: `${VERBATIM} Then read_cv cvPath with talentConsent:true, asking nothing about the CV. ${linkedin}`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
401
429
|
const asked = await askAfterTexts(ctx, {
|
|
402
430
|
tool: 'signup_start',
|
|
403
431
|
texts: [c.welcome(nonEmpty(args.firstName))],
|
|
@@ -406,7 +434,6 @@ function makeSignupTools(deps = {}) {
|
|
|
406
434
|
answer: args.answer,
|
|
407
435
|
});
|
|
408
436
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
409
|
-
const linkedin = `LinkedIn: use the URL in the CV (read_cv returns links.linkedin). Only if it has none, ask \`linkedinAsk\` word for word; only if they say they do not know where to find it, say \`linkedinOpening\` and call open_linkedin_profile. Then call signup_email.`;
|
|
410
437
|
// A CV path the talent gave wins over both answers: searching could read another candidate.
|
|
411
438
|
const cvPath = state.cvPath;
|
|
412
439
|
if (cvPath) {
|
|
@@ -461,6 +488,13 @@ function makeSignupTools(deps = {}) {
|
|
|
461
488
|
return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} Then call signup_email with email set to what they write and typed:true.` };
|
|
462
489
|
}
|
|
463
490
|
|
|
491
|
+
// A session this machine kept for the same email means the account exists, so the window will ask to sign in.
|
|
492
|
+
function knownAccount() {
|
|
493
|
+
const stored = resolved.loadAuthSession();
|
|
494
|
+
const email = stored && typeof stored.email === 'string' ? stored.email.toLowerCase() : null;
|
|
495
|
+
return !!email && !!state.email && email === state.email.toLowerCase();
|
|
496
|
+
}
|
|
497
|
+
|
|
464
498
|
// Step 3: the draft, the data notice and how the window works; confirming it is what opens the window.
|
|
465
499
|
async function signupDraft(args = {}, ctx = {}) {
|
|
466
500
|
const c = copy();
|
|
@@ -475,9 +509,14 @@ function makeSignupTools(deps = {}) {
|
|
|
475
509
|
return { ok: false, reason: 'draft-rates-required', message: 'The draft needs both rates: if the talent gave them, send them with ratesFromTalent:true; otherwise estimate hourlyRate (€/h) and annualRate (€/year) from role, seniority, stack and location, and send them without it. Then call signup_draft again.' };
|
|
476
510
|
}
|
|
477
511
|
if (args.ratesFromTalent === true) draft.ratesFromTalent = true;
|
|
512
|
+
const sources = Array.isArray(args.sources) ? DRAFT_SOURCES.filter((s) => args.sources.includes(s)) : [];
|
|
513
|
+
// A confirmation of the printed block stands even if the model drops sources on the answer call.
|
|
514
|
+
if (!sources.length && !optionFor(args.answer, [c.draftOk, c.draftChange])) {
|
|
515
|
+
return { ok: false, reason: 'draft-sources-required', message: "The draft needs sources: 'cv' if you read their CV, 'linkedin' only if you have their LinkedIn URL. Then call signup_draft again." };
|
|
516
|
+
}
|
|
478
517
|
const asked = await askAfterTexts(ctx, {
|
|
479
518
|
tool: 'signup_draft',
|
|
480
|
-
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice, c.windowNotice],
|
|
519
|
+
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
|
|
481
520
|
question: c.draftQuestion,
|
|
482
521
|
options: [c.draftOk, c.draftChange],
|
|
483
522
|
answer: args.answer,
|
|
@@ -684,17 +723,31 @@ function makeSignupTools(deps = {}) {
|
|
|
684
723
|
};
|
|
685
724
|
}
|
|
686
725
|
|
|
687
|
-
// Fill-empty only:
|
|
726
|
+
// Fill-empty only: per draft field, what it fills or the value the profile keeps, plus the import that only adds.
|
|
688
727
|
async function existingDiff() {
|
|
689
728
|
const c = copy();
|
|
690
729
|
const draft = state.draft || {};
|
|
691
730
|
const current = await currentProfile();
|
|
692
731
|
const lines = [];
|
|
732
|
+
const kept = new Set();
|
|
693
733
|
const has = (v) => v !== undefined && v !== null && v !== '';
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
734
|
+
// An unreadable section (undefined) counts as set, as fillEmpty never writes it.
|
|
735
|
+
const field = (key, now, filled, label, shown) => {
|
|
736
|
+
if (!has(draft[key])) return;
|
|
737
|
+
if (now === null) { lines.push(filled); return; }
|
|
738
|
+
kept.add(key);
|
|
739
|
+
lines.push(c.existingStays(label, now === undefined ? null : shown(now), shown(draft[key])));
|
|
740
|
+
};
|
|
741
|
+
const pricing = current.pricing || { hourly: undefined, annual: undefined };
|
|
742
|
+
const hours = current.availability ? (has(current.availability.monthlyHours) ? current.availability.monthlyHours : null) : undefined;
|
|
743
|
+
field('hourlyRate', pricing.hourly, c.existingRate(c.existingEmpty, money(draft.hourlyRate, c.perHour)), c.hourlyRateLabel, (v) => money(v, c.perHour));
|
|
744
|
+
field('annualRate', pricing.annual, c.existingAnnual(c.existingEmpty, money(draft.annualRate, c.perYear)), c.annualRateLabel, (v) => money(v, c.perYear));
|
|
745
|
+
field('monthlyHours', hours, c.existingHours(c.existingEmpty, c.hours(draft.monthlyHours)), c.keptLabels.availability, (v) => c.hours(v));
|
|
746
|
+
if (has(draft.languages) && current.languageCount !== 0) { kept.add('languages'); lines.push(c.existingStays(c.keptLabels.languages)); }
|
|
747
|
+
if (kept.size) lines.push(c.existingStaysNote);
|
|
748
|
+
state.toldKept = kept;
|
|
749
|
+
const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
|
|
750
|
+
if (given.length) lines.push(c.existingLists(given));
|
|
698
751
|
return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
|
|
699
752
|
}
|
|
700
753
|
|
|
@@ -731,25 +784,25 @@ function makeSignupTools(deps = {}) {
|
|
|
731
784
|
return { details: out, kept };
|
|
732
785
|
}
|
|
733
786
|
|
|
734
|
-
// What the existing profile kept, for the model per field and for the talent as one fixed line.
|
|
787
|
+
// What the existing profile kept, for the model per field and for the talent as one fixed line without what the update question already told.
|
|
735
788
|
function keptReport(kept) {
|
|
736
789
|
const c = copy();
|
|
737
790
|
const fields = [];
|
|
738
791
|
const items = [];
|
|
739
|
-
const
|
|
792
|
+
const told = (key) => state.toldKept.has(key);
|
|
740
793
|
if (kept.workSituation) { fields.push('workSituation: not changed (the profile keeps what it had)'); items.push(c.keptLabels.workSituation); }
|
|
741
794
|
for (const [key, label, unit] of [['hourlyRate', c.hourlyRateLabel, c.perHour], ['annualRate', c.annualRateLabel, c.perYear]]) {
|
|
742
795
|
const k = kept.pricing && kept.pricing[key];
|
|
743
796
|
if (!k) continue;
|
|
744
797
|
fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had, not ${k.requested})` : `${key}: kept existing value ${k.kept} (not changed, not ${k.requested})`);
|
|
745
|
-
items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
|
|
798
|
+
if (!told(key)) items.push(k.kept === null ? label : `${label}: ${money(k.kept, unit)}`);
|
|
746
799
|
}
|
|
747
800
|
for (const [key, k] of Object.entries(kept.availability || {})) {
|
|
748
801
|
fields.push(k.kept === null ? `${key}: not changed (the profile keeps what it had)` : `${key}: kept existing value ${Array.isArray(k.kept) ? k.kept.join('/') : k.kept} (not changed, not ${Array.isArray(k.requested) ? k.requested.join('/') : k.requested})`);
|
|
749
802
|
}
|
|
750
803
|
const hours = kept.availability && kept.availability.monthlyHours;
|
|
751
|
-
if (hours) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
|
|
752
|
-
if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); items.push(c.keptLabels.languages); }
|
|
804
|
+
if (hours && !told('monthlyHours')) items.push(hours.kept === null ? c.keptLabels.availability : `${c.keptLabels.availability}: ${c.hours(hours.kept)}`);
|
|
805
|
+
if (kept.languages) { fields.push('languages: not changed (the profile already had languages)'); if (!told('languages')) items.push(c.keptLabels.languages); }
|
|
753
806
|
if (kept.phone) { fields.push('phone: not changed (the profile keeps what it had)'); items.push(c.keptLabels.phone); }
|
|
754
807
|
return { fields, line: items.length ? c.existingUnchanged(items) : null };
|
|
755
808
|
}
|
|
@@ -849,14 +902,18 @@ function makeSignupTools(deps = {}) {
|
|
|
849
902
|
const unseen = takeNotices();
|
|
850
903
|
const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
|
|
851
904
|
const relayFirst = unseen.length ? `${VERBATIM} ` : '';
|
|
905
|
+
// Read right before the closing words, so they never state an import state that is already stale.
|
|
906
|
+
const imp = await readImport();
|
|
907
|
+
const importLine = IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
|
|
852
908
|
if (opened) {
|
|
853
909
|
return {
|
|
854
910
|
ok: true,
|
|
855
911
|
opened: true,
|
|
856
912
|
loggedIn,
|
|
913
|
+
import: { state: imp.state },
|
|
857
914
|
...relay,
|
|
858
915
|
message: relayFirst + (loggedIn ? 'Shakers opened in the talent\'s browser, signed in.' : 'Shakers opened in the talent\'s browser at the sign-in page; they sign in there.')
|
|
859
|
-
+
|
|
916
|
+
+ ` If they say it did not open, call open_web with open:false and give them its link. ${importLine}`,
|
|
860
917
|
};
|
|
861
918
|
}
|
|
862
919
|
return {
|
|
@@ -864,8 +921,9 @@ function makeSignupTools(deps = {}) {
|
|
|
864
921
|
opened: false,
|
|
865
922
|
loggedIn,
|
|
866
923
|
link: url,
|
|
924
|
+
import: { state: imp.state },
|
|
867
925
|
...relay,
|
|
868
|
-
message: relayFirst
|
|
926
|
+
message: `${relayFirst}Give the talent \`link\` to click, with no comment on the address.${loggedIn ? ' It works once, for 2 minutes.' : ''} ${importLine}`,
|
|
869
927
|
};
|
|
870
928
|
}
|
|
871
929
|
|
|
@@ -912,7 +970,7 @@ function makeSignupTools(deps = {}) {
|
|
|
912
970
|
},
|
|
913
971
|
{
|
|
914
972
|
name: 'update_existing_profile',
|
|
915
|
-
description: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
|
|
973
|
+
description: 'When signup_status says the talent already had an account: shows what the confirmed draft would fill in the empty fields of their profile and the value each field they already had keeps, and asks whether to update it; on yes it imports their LinkedIn/CV filling only what is empty.',
|
|
916
974
|
inputSchema: UPDATE_EXISTING_SCHEMA,
|
|
917
975
|
handler: updateExistingProfile,
|
|
918
976
|
},
|
package/src/profile-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// AI-profile client
|
|
3
|
+
// AI-profile client. The "My work
|
|
4
4
|
// with AI" read model: setup/usage tier+level, 3x3 cell, vision, howIWork.
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
@@ -87,14 +87,12 @@ async function getMapped(endpoint, hubAccessToken, timeoutMs, map) {
|
|
|
87
87
|
return { ok: true, ...map(data) };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// `works/me/profile` -> headline (basic profile text).
|
|
91
90
|
async function fetchMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
92
91
|
const getEndpoint = deps.getMeProfileEndpoint || require('./config').getMeProfileEndpoint;
|
|
93
92
|
const req = deps.getMapped || getMapped;
|
|
94
93
|
return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => ({ headline: str(d && d.headline) }));
|
|
95
94
|
}
|
|
96
95
|
|
|
97
|
-
// `works/talents/me/profile` -> completion %, freelance type, onboarding status.
|
|
98
96
|
async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
99
97
|
const getEndpoint = deps.getTalentMeProfileEndpoint || require('./config').getTalentMeProfileEndpoint;
|
|
100
98
|
const req = deps.getMapped || getMapped;
|
|
@@ -105,7 +103,6 @@ async function fetchTalentMeProfile(deps = {}, { hubAccessToken, timeoutMs = DEF
|
|
|
105
103
|
}));
|
|
106
104
|
}
|
|
107
105
|
|
|
108
|
-
// `works/talents/me/work-details/languages` -> count + codes.
|
|
109
106
|
async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
110
107
|
const getEndpoint = deps.getLanguagesEndpoint || require('./config').getLanguagesEndpoint;
|
|
111
108
|
const req = deps.getMapped || getMapped;
|
package/src/rate-client.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Pricing-rate client
|
|
4
|
-
// Bearer). READ-ONLY here: setting the rate is a financial PUT, left to register.
|
|
3
|
+
// Pricing-rate client. READ-ONLY here: setting the rate is a financial PUT, left to register.
|
|
5
4
|
|
|
6
5
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
7
6
|
const { reasonForError } = require('./onboarding-client');
|
package/src/roles-client.js
CHANGED
|
@@ -43,7 +43,6 @@ function normalizeRole(r) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// GET works/certifications/me/available-roles -> { roles:[{clusterId,name,category,proposalKind}] }.
|
|
47
46
|
async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
48
47
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
49
48
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -60,7 +59,6 @@ async function requestAvailableRoles({ hubAccessToken } = {}, { endpoint, timeou
|
|
|
60
59
|
return { ok: true, roles: rows.map(normalizeRole).filter(Boolean) };
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
// GET works/talents/me/work-details/assigned-clusters -> the talent's GROWTH/RECOMMENDED roles.
|
|
64
62
|
async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
65
63
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
66
64
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -77,7 +75,6 @@ async function requestAssignedClusters({ hubAccessToken } = {}, { endpoint, time
|
|
|
77
75
|
return { ok: true, clusters: rows.map(normalizeRole).filter(Boolean) };
|
|
78
76
|
}
|
|
79
77
|
|
|
80
|
-
// POST works/talents/me/work-details/assigned-clusters { clusterId } -> adds a GROWTH role.
|
|
81
78
|
async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
82
79
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
83
80
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -92,7 +89,6 @@ async function requestAddGrowthRole({ clusterId, hubAccessToken } = {}, { endpoi
|
|
|
92
89
|
return { ok: true, clusterId };
|
|
93
90
|
}
|
|
94
91
|
|
|
95
|
-
// PATCH works/certifications/me/main-role { clusterId } -> sets the preferred main role.
|
|
96
92
|
async function requestSetMainRole({ clusterId, hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
97
93
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
98
94
|
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Saved-positions client
|
|
3
|
+
// Saved-positions client. Both
|
|
4
4
|
// operations are idempotent server-side, so no read-before-write is needed.
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
package/src/signup-copy.js
CHANGED
|
@@ -7,28 +7,28 @@ const SIGNUP_LANGUAGES = ['es', 'en', 'it', 'pt'];
|
|
|
7
7
|
// Legal texts. PENDING legal/GDPR validation: not final copy. The CLI onboarding reads the es/en ones too.
|
|
8
8
|
const LEGAL = {
|
|
9
9
|
es: {
|
|
10
|
-
signupNotice:
|
|
10
|
+
signupNotice: (sources) => `Antes de crear tu cuenta: guardamos los datos que me des (${listOf('es', [...named(sources, COPY.es.importSources), 'lo que sé de ti'])}) y podemos contactarte. Te completo el perfil automáticamente y cualquier campo lo puedes cambiar después en la web. La entrevista de onboarding la puedes hacer luego aquí mismo en el chat o en la web.`,
|
|
11
11
|
usageInfoAccessed: 'Se accede a: la estructura de tus herramientas de IA y a extractos anonimizados de tu actividad en este equipo. Tu código y tus prompts en crudo NUNCA salen de tu máquina.',
|
|
12
12
|
usageGoalDuration: 'Objetivo: medir cómo trabajas con IA para tu perfil. Duración: ~1-2 minutos.',
|
|
13
13
|
interviewInfoAccessed: 'Se accede a: tus respuestas en esta entrevista y tu perfil importado. La conduce el servidor de Shakers.',
|
|
14
14
|
interviewGoalDuration: 'Objetivo: conocer tu rol, tu dedicación y cómo usas la IA. Duración: ~5 minutos.',
|
|
15
15
|
},
|
|
16
16
|
en: {
|
|
17
|
-
signupNotice:
|
|
17
|
+
signupNotice: (sources) => `Before we create your account: we store the data you give me (${listOf('en', [...named(sources, COPY.en.importSources), 'what I know about you'])}) and may contact you. I fill in your profile automatically and you can change any field later on the web. You can do the onboarding interview afterwards right here in the chat or on the web.`,
|
|
18
18
|
usageInfoAccessed: 'Accessed: the structure of your AI tools and redacted excerpts of your activity on this machine. Your raw code and prompts NEVER leave your machine.',
|
|
19
19
|
usageGoalDuration: 'Goal: measure how you work with AI for your profile. Duration: ~1-2 minutes.',
|
|
20
20
|
interviewInfoAccessed: 'Accessed: your answers in this interview and your imported profile. It is conducted by the Shakers server.',
|
|
21
21
|
interviewGoalDuration: 'Goal: learn your role, your dedication and how you use AI. Duration: ~5 minutes.',
|
|
22
22
|
},
|
|
23
23
|
it: {
|
|
24
|
-
signupNotice:
|
|
24
|
+
signupNotice: (sources) => `Prima di creare il tuo account: conserviamo i dati che mi dai (${listOf('it', [...named(sources, COPY.it.importSources), 'quello che so di te'])}) e possiamo contattarti. Completo il tuo profilo in automatico e puoi cambiare qualsiasi campo in seguito sul sito. Il colloquio di onboarding puoi farlo dopo sul sito.`,
|
|
25
25
|
usageInfoAccessed: 'Accesso a: la struttura dei tuoi strumenti di IA ed estratti anonimizzati della tua attività su questo computer. Il tuo codice e i tuoi prompt originali NON escono MAI dalla tua macchina.',
|
|
26
26
|
usageGoalDuration: "Obiettivo: misurare come lavori con l'IA per il tuo profilo. Durata: ~1-2 minuti.",
|
|
27
27
|
interviewInfoAccessed: 'Accesso a: le tue risposte in questo colloquio e il tuo profilo importato. Lo conduce il server di Shakers.',
|
|
28
28
|
interviewGoalDuration: "Obiettivo: conoscere il tuo ruolo, la tua disponibilità e come usi l'IA. Durata: ~5 minuti.",
|
|
29
29
|
},
|
|
30
30
|
pt: {
|
|
31
|
-
signupNotice:
|
|
31
|
+
signupNotice: (sources) => `Antes de criar a sua conta: guardamos os dados que me der (${listOf('pt', [...named(sources, COPY.pt.importSources), 'o que sei sobre si'])}) e podemos contactá-lo. Preencho o seu perfil automaticamente e pode alterar qualquer campo depois na web. A entrevista de onboarding pode fazê-la depois na web.`,
|
|
32
32
|
usageInfoAccessed: 'Acede-se a: a estrutura das suas ferramentas de IA e excertos anonimizados da sua atividade neste computador. O seu código e os seus prompts em bruto NUNCA saem da sua máquina.',
|
|
33
33
|
usageGoalDuration: 'Objetivo: medir como trabalha com IA para o seu perfil. Duração: ~1-2 minutos.',
|
|
34
34
|
interviewInfoAccessed: 'Acede-se a: as suas respostas nesta entrevista e o seu perfil importado. É conduzida pelo servidor da Shakers.',
|
|
@@ -38,6 +38,16 @@ const LEGAL = {
|
|
|
38
38
|
|
|
39
39
|
const LOCALES = { es: 'es-ES', en: 'en-GB', it: 'it-IT', pt: 'pt-PT' };
|
|
40
40
|
|
|
41
|
+
// "a, b and c" in the talent's language.
|
|
42
|
+
function listOf(lang, items) {
|
|
43
|
+
return new Intl.ListFormat(LOCALES[lang], { type: 'conjunction' }).format(items);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The names of only the sources the talent actually gave (cv, linkedin), from a per-language table.
|
|
47
|
+
function named(sources, names) {
|
|
48
|
+
return (sources || []).map((s) => names[s]).filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
function joinParts(parts) {
|
|
42
52
|
return parts.filter((p) => p !== null && p !== undefined && String(p).trim() !== '').join(' · ');
|
|
43
53
|
}
|
|
@@ -69,14 +79,17 @@ const COPY = {
|
|
|
69
79
|
draftQuestion: '¿Está bien o cambio algo?',
|
|
70
80
|
draftOk: 'Todo correcto',
|
|
71
81
|
draftChange: 'Quiero cambiar algo',
|
|
72
|
-
windowNotice: 'Cuando confirmes,
|
|
82
|
+
windowNotice: 'Cuando me confirmes, solo faltará crear tu cuenta para guardar todo esto y que puedas entrar en Shakers cuando quieras.\n\nTe abro una ventana en el navegador, elige cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat. En esa misma pantalla aceptas los términos y la política de privacidad.\n\n👉 Mira tu navegador. Cuando termines, vuelve aquí y seguimos.',
|
|
83
|
+
windowNoticeSignIn: 'Cuando me confirmes, solo faltará entrar en la cuenta que ya tienes en Shakers con este email para guardar todo esto.\n\nTe abro una ventana en el navegador, elige cómo entrar:\n· Con Google\n· Con email y contraseña\n\nLa contraseña la escribes allí directamente, nunca pasa por este chat.\n\n👉 Mira tu navegador. Cuando termines, vuelve aquí y seguimos.',
|
|
73
84
|
windowLink: (link) => `Si no se te ha abierto la ventana, entra desde aquí: ${link}`,
|
|
74
85
|
existingTitle: 'He visto que ya tienes cuenta. ¿Actualizo tu perfil con esto?',
|
|
75
86
|
existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
|
|
76
87
|
existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
|
|
77
88
|
existingHours: (from, to) => `Disponibilidad: ${from} → ${to}`,
|
|
78
89
|
existingEmpty: 'sin indicar',
|
|
79
|
-
|
|
90
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: se queda en ${kept}, no ${requested}` : `${label}: sin cambios`),
|
|
91
|
+
existingStaysNote: 'Lo que ya tenías relleno no lo cambio desde aquí: si quieres otros valores, cámbialos en la web.',
|
|
92
|
+
existingLists: (sources) => `Añado lo nuevo de ${listOf('es', named(sources, COPY.es.importSources))} (experiencias, proyectos, formación) y relleno lo que tengas vacío, sin borrar nada.`,
|
|
80
93
|
existingYes: 'Sí, actualízalo',
|
|
81
94
|
existingNo: 'No, déjalo como está',
|
|
82
95
|
existingKept: 'Perfecto, dejo tu perfil como está.',
|
|
@@ -139,14 +152,17 @@ const COPY = {
|
|
|
139
152
|
draftQuestion: 'Does it look right, or shall I change something?',
|
|
140
153
|
draftOk: 'All correct',
|
|
141
154
|
draftChange: 'I want to change something',
|
|
142
|
-
windowNotice: "
|
|
155
|
+
windowNotice: "Once you confirm, all that's left is to create your account to save all this and so you can get into Shakers whenever you like.\n\nI'll open a window in your browser; choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat. On that same screen you accept the terms and the privacy policy.\n\n👉 Check your browser. When you're done, come back here and we'll carry on.",
|
|
156
|
+
windowNoticeSignIn: "Once you confirm, all that's left is to sign in to the account you already have on Shakers with this email to save all this.\n\nI'll open a window in your browser; choose how to sign in:\n· With Google\n· With email and password\n\nYou type the password there directly; it never goes through this chat.\n\n👉 Check your browser. When you're done, come back here and we'll carry on.",
|
|
143
157
|
windowLink: (link) => `If the window did not open, use this link: ${link}`,
|
|
144
158
|
existingTitle: 'I see you already have an account. Shall I update your profile with this?',
|
|
145
159
|
existingRate: (from, to) => `Hourly rate: ${from} → ${to}`,
|
|
146
160
|
existingAnnual: (from, to) => `Annual rate: ${from} → ${to}`,
|
|
147
161
|
existingHours: (from, to) => `Availability: ${from} → ${to}`,
|
|
148
162
|
existingEmpty: 'not set',
|
|
149
|
-
|
|
163
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: stays at ${kept}, not ${requested}` : `${label}: no change`),
|
|
164
|
+
existingStaysNote: 'I do not change from here what you had already filled in: if you want other values, change them on the web.',
|
|
165
|
+
existingLists: (sources) => `I add what is new from ${listOf('en', named(sources, COPY.en.importSources))} (experience, projects, education) and fill in what is empty, without deleting anything.`,
|
|
150
166
|
existingYes: 'Yes, update it',
|
|
151
167
|
existingNo: 'No, leave it as it is',
|
|
152
168
|
existingKept: 'Sure, I am leaving your profile as it is.',
|
|
@@ -209,14 +225,17 @@ const COPY = {
|
|
|
209
225
|
draftQuestion: 'Va bene o cambio qualcosa?',
|
|
210
226
|
draftOk: 'Tutto corretto',
|
|
211
227
|
draftChange: 'Voglio cambiare qualcosa',
|
|
212
|
-
windowNotice: "Quando confermi,
|
|
228
|
+
windowNotice: "Quando mi confermi, mancherà solo creare il tuo account per salvare tutto questo e poter entrare in Shakers quando vuoi.\n\nTi apro una finestra nel browser, scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat. Nella stessa schermata accetti i termini e l'informativa sulla privacy.\n\n👉 Guarda il tuo browser. Quando hai finito, torna qui e continuiamo.",
|
|
229
|
+
windowNoticeSignIn: "Quando mi confermi, mancherà solo entrare nell'account che hai già su Shakers con questa email per salvare tutto questo.\n\nTi apro una finestra nel browser, scegli come entrare:\n· Con Google\n· Con email e password\n\nLa password la scrivi direttamente lì, non passa mai da questa chat.\n\n👉 Guarda il tuo browser. Quando hai finito, torna qui e continuiamo.",
|
|
213
230
|
windowLink: (link) => `Se la finestra non si è aperta, entra da qui: ${link}`,
|
|
214
231
|
existingTitle: 'Ho visto che hai già un account. Aggiorno il tuo profilo con questi dati?',
|
|
215
232
|
existingRate: (from, to) => `Tariffa oraria: ${from} → ${to}`,
|
|
216
233
|
existingAnnual: (from, to) => `Tariffa annuale: ${from} → ${to}`,
|
|
217
234
|
existingHours: (from, to) => `Disponibilità: ${from} → ${to}`,
|
|
218
235
|
existingEmpty: 'non indicata',
|
|
219
|
-
|
|
236
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: resta ${kept}, non ${requested}` : `${label}: nessuna modifica`),
|
|
237
|
+
existingStaysNote: 'Quello che avevi già compilato non lo cambio da qui: se vuoi altri valori, cambiali sul sito.',
|
|
238
|
+
existingLists: (sources) => `Aggiungo le novità ${listOf('it', named(sources, { cv: 'del tuo CV', linkedin: 'del tuo LinkedIn' }))} (esperienze, progetti, formazione) e compilo quello che hai vuoto, senza cancellare nulla.`,
|
|
220
239
|
existingYes: 'Sì, aggiornalo',
|
|
221
240
|
existingNo: 'No, lascialo così',
|
|
222
241
|
existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
|
|
@@ -279,14 +298,17 @@ const COPY = {
|
|
|
279
298
|
draftQuestion: 'Está bem ou mudo alguma coisa?',
|
|
280
299
|
draftOk: 'Está tudo certo',
|
|
281
300
|
draftChange: 'Quero mudar algo',
|
|
282
|
-
windowNotice: 'Quando confirmar,
|
|
301
|
+
windowNotice: 'Quando me confirmar, só faltará criar a sua conta para guardar tudo isto e poder entrar na Shakers quando quiser.\n\nAbro-lhe uma janela no navegador, escolha como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat. Nesse mesmo ecrã aceita os termos e a política de privacidade.\n\n👉 Veja o seu navegador. Quando terminar, volte aqui e continuamos.',
|
|
302
|
+
windowNoticeSignIn: 'Quando me confirmar, só faltará entrar na conta que já tem na Shakers com este email para guardar tudo isto.\n\nAbro-lhe uma janela no navegador, escolha como entrar:\n· Com Google\n· Com email e palavra-passe\n\nEscreve a palavra-passe diretamente lá; nunca passa por este chat.\n\n👉 Veja o seu navegador. Quando terminar, volte aqui e continuamos.',
|
|
283
303
|
windowLink: (link) => `Se a janela não abriu, entre por aqui: ${link}`,
|
|
284
304
|
existingTitle: 'Vi que já tem conta. Atualizo o seu perfil com isto?',
|
|
285
305
|
existingRate: (from, to) => `Tarifa por hora: ${from} → ${to}`,
|
|
286
306
|
existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
|
|
287
307
|
existingHours: (from, to) => `Disponibilidade: ${from} → ${to}`,
|
|
288
308
|
existingEmpty: 'sem indicar',
|
|
289
|
-
|
|
309
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: fica em ${kept}, não ${requested}` : `${label}: sem alterações`),
|
|
310
|
+
existingStaysNote: 'O que já tinha preenchido não o altero daqui: se quiser outros valores, altere-os na web.',
|
|
311
|
+
existingLists: (sources) => `Acrescento o que há de novo ${listOf('pt', named(sources, { cv: 'no seu CV', linkedin: 'no seu LinkedIn' }))} (experiências, projetos, formação) e preencho o que tiver vazio, sem apagar nada.`,
|
|
290
312
|
existingYes: 'Sim, atualize',
|
|
291
313
|
existingNo: 'Não, deixe como está',
|
|
292
314
|
existingKept: 'Perfeito, deixo o seu perfil como está.',
|