shakers-cli-beta 0.9.0 → 0.10.1

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.
@@ -1,163 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const {
4
- FREELANCE_TYPES, WORK_SITUATIONS, EMPLOYMENT_PARTICIPATIONS, FREELANCE_OPINIONS,
5
- CHANGE_MOTIVATORS, WORK_MODES, MONTHLY_HOURS, LANGUAGE_CODES, LANGUAGE_LEVELS,
6
- } = require('./onboarding-flow');
7
- const { getCatalog } = require('./i18n');
8
3
  const { TranscriptBuffer } = require('./interview-transcript');
4
+ const { askChoice } = require('./mcp-choice');
9
5
 
10
- const MONTHLY_HOURS_QUOTED = MONTHLY_HOURS.map((h) => `'${h}'`).join(', ');
11
- const MONTHLY_HOURS_SLASHED = MONTHLY_HOURS.join('/');
12
-
13
- // "Friendly label (CODE)" list from an i18n label map: the talent sees the label, the model passes the canonical code.
14
- function labelledOptions(codes, labels) {
15
- return codes.map((code) => `${labels[code] || code} (${code})`).join(', ');
16
- }
17
-
18
- function buildSchemas(lang) {
19
- const L = getCatalog(lang).onboarding;
20
- const showLabel = 'Show the talent the label, pass the code';
21
-
22
- const ACCOUNT_FIELDS = {
23
- name: { type: 'string', description: "The talent's first name, as they give it in the chat." },
24
- lastName: { type: 'string', description: "The talent's last name, as they give it in the chat." },
25
- email: { type: 'string', description: "The talent's own email address, used as their account login. They give it in the chat." },
26
- preferredLanguage: { type: 'string', enum: ['ES', 'EN', 'IT', 'PT', 'FR'], description: 'Preferred language as an UPPERCASE ISO code. Optional — defaults to the session language.' },
27
- newsletterConsent: { type: 'boolean', description: 'Whether the talent agrees to receive the newsletter. Optional, defaults to false.' },
28
- freelanceType: { type: 'string', enum: FREELANCE_TYPES, description: `How the talent is set up to work. ${showLabel}: ${labelledOptions(FREELANCE_TYPES, L.freelanceTypeLabels)}. Distinct from workSituation below (that is the CURRENT freelance/employed/etc situation).` },
29
- };
30
-
31
- const WORK_SITUATION_SCHEMA = {
32
- type: 'object',
33
- description: "The talent's current work situation (ADR-036), asked once after account creation — the same question the web's registration flow asks. NOT the same thing as freelanceType above.",
34
- properties: {
35
- situation: { type: 'string', enum: WORK_SITUATIONS, description: `Current work situation. ${showLabel}: ${labelledOptions(WORK_SITUATIONS, L.workSituationLabels)}. 'EMPLOYED' requires participation below.` },
36
- participation: { type: 'string', enum: EMPLOYMENT_PARTICIPATIONS, description: `Only when situation is 'EMPLOYED'. ${showLabel}: ${labelledOptions(EMPLOYMENT_PARTICIPATIONS, L.employmentParticipationLabels)}.` },
37
- opinion: { type: 'string', enum: FREELANCE_OPINIONS, description: `The talent's opinion on freelancing. ${showLabel}: ${labelledOptions(FREELANCE_OPINIONS, L.freelanceOpinionLabels)}. Required unless situation is 'FREELANCE' (implied ALREADY_FREELANCE).` },
38
- changeMotivators: { type: 'string', enum: CHANGE_MOTIVATORS, description: `What motivates the talent about freelancing/Shakers. ${showLabel}: ${labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)}. Required only when the talent is or is considering freelance (situation 'FREELANCE', or opinion 'WAS_FREELANCE_BEFORE'/'OPEN_TO_FREELANCE'); omit when opinion is 'NOT_INTERESTED'.` },
39
- },
40
- required: ['situation'],
41
- };
42
-
43
- const LANGUAGES_SCHEMA = {
44
- type: 'array',
45
- description: `Languages the talent speaks (ADR-036). ALWAYS ask this — it must not be skipped. ${showLabel}. Language options: ${labelledOptions(LANGUAGE_CODES, L.languageNames)}; and for each language a level: ${labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)}. Bulk upsert — send the talent’s FULL language list. Omit only if the talent explicitly declines.`,
46
- items: {
47
- type: 'object',
48
- properties: {
49
- language: { type: 'string', enum: LANGUAGE_CODES, description: `Language code. ${showLabel}: ${labelledOptions(LANGUAGE_CODES, L.languageNames)}.` },
50
- level: { type: 'string', enum: LANGUAGE_LEVELS, description: `Language level. ${showLabel}: ${labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)}.` },
51
- },
52
- required: ['language', 'level'],
53
- },
54
- };
55
-
56
- const AVAILABILITY_SCHEMA = {
57
- type: 'object',
58
- description: `Availability AND location (ADR-028). ALWAYS ask this — it must not be skipped. There is NO separate location field: the talent's LOCATION (country, timezone, region) lives here. Offer the options: available (yes/no); monthlyHours must be one of ${MONTHLY_HOURS_QUOTED}; workModes is a MULTI-VALUE array, any combination of ${WORK_MODES.join('/')} (the talent can pick several); longFullTimeProjects (yes/no — open to long full-time projects). For location, ask the talent their country and timezone (region is optional). All fields optional/nullable, but collect what you can. monthlyHours is a STRICT enum string (not an int). city is not collected here.`,
59
- properties: {
60
- available: { type: 'boolean', description: 'Whether the talent is currently available (yes/no).' },
61
- monthlyHours: { type: 'string', enum: MONTHLY_HOURS, description: `Monthly hours the talent can work, exactly one of: ${MONTHLY_HOURS_QUOTED} (send '> 160' verbatim, with the space).` },
62
- workModes: { type: 'array', items: { type: 'string', enum: WORK_MODES }, description: `Work modes the talent accepts — a combinable array, any subset of ${WORK_MODES.join('/')} (e.g. ['REMOTE','HYBRID']). Only REMOTE means the talent will not see hybrid or on-site projects.` },
63
- country: { type: 'string', description: 'LOCATION — ISO-2 country code, 2 letters (e.g. ES, US, GB).' },
64
- timezone: { type: 'string', description: 'LOCATION — IANA timezone (e.g. Europe/Madrid, America/New_York). Max 100 chars.' },
65
- subdivision: { type: 'string', description: 'LOCATION — optional region/state code (e.g. ES-CT). Max 10 chars.' },
66
- longFullTimeProjects: { type: 'boolean', description: 'Whether the talent is open to long full-time projects (yes/no).' },
67
- },
68
- };
69
-
70
- const REGISTER_SCHEMA = {
71
- type: 'object',
72
- properties: {
73
- ...ACCOUNT_FIELDS,
74
- profile: PROFILE_SCHEMA,
75
- workSituation: WORK_SITUATION_SCHEMA,
76
- pricing: PRICING_SCHEMA,
77
- availability: AVAILABILITY_SCHEMA,
78
- languages: LANGUAGES_SCHEMA,
79
- phone: PHONE_SCHEMA,
80
- },
81
- required: ['name', 'lastName', 'email', 'freelanceType', 'profile', 'workSituation', 'pricing'],
82
- };
83
-
84
- // Same shape as REGISTER_SCHEMA but NOTHING is required: the preview accepts a
85
- // partial, pre-filled payload and reports what is still missing.
86
- const PREVIEW_SCHEMA = {
87
- type: 'object',
88
- description: 'A partial, pre-filled register payload to REVIEW before writing. Every field is optional — fill only what the talent has said or what is verifiable from context, omit the rest. Returns what WOULD be written, what is still missing to ask, and any invalid values. Writes nothing.',
89
- properties: { ...REGISTER_SCHEMA.properties },
90
- };
91
-
92
- return { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA, PREVIEW_SCHEMA };
93
- }
94
-
95
- // The register tool's own long description, per language: the batched
96
- // collection guide with friendly labels inline for every choice field.
97
- function buildRegisterDescription(lang) {
98
- const L = getCatalog(lang).onboarding;
99
- return "The talent registers THEMSELVES on Shakers (self-service; no prior session needed). Your job is to relay the non-secret profile details the talent gives you in the chat as arguments. Credentials never touch the chat or any tool argument: the tool opens a sign-up window on the talent's own machine (127.0.0.1) where they either Continue with Google (no password at all) or type an email and password directly into that local window.\n\nGO STRAIGHT TO A FILLED DRAFT — never open with a questionnaire or a list of what you do not know. (1) Your FIRST reply is one short question: may you search for and read their CV on this machine to fill everything in? (skip it if they already said yes). (2) With their yes: call suggest_register_context with talentName = the name you know, then read_cv the candidates (nameMatch first, then generic ones like cv.pdf) with talentConsent:true and talentName, until one returns mentionsTalent:true; a CV with mentionsTalent:false belongs to someone else: drop it and never show or use anything from it. From their CV take LinkedIn/GitHub (links), experience, education, languages and location; pass paths exactly as given (the short ~/ form is fine). If they say no, there is no CV, 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. (3) Pre-fill EVERY field: identity from git/your memory/the CV; workSituation from what you know (a company email or employer in memory or CV means employed there — propose it); `pricing` ALWAYS as a concrete per-project estimate from their role, seniority, stack and location, labelled as your estimate (ask only if you know nothing about their work); `workSituation.changeMotivators` as your best match; availability, languages and country from context. What you know about the person outranks git: if the git identity is someone else, ignore it. (4) Call register_preview and show ONE summary that says where each value comes from, for the talent to confirm or correct in one reply. Required fields you truly have no basis for go in one short question at the end; optional unknowns (phone, website, newsletter) in a single 'si quieres' line, never as missing items. (5) After their confirmation, call register. The sign-up goes to suggest_register_context's `environment` (local/development/staging/production): name it correctly and never call a test environment production. All of this is self-declared profile data.\n\nFor whatever you still have to ASK (only what you could not infer), ask in ONE grouped message, not one field at a time, and for every CHOICE field PRESENT THE VALID OPTIONS so the talent just picks one. For every choice field SHOW THE TALENT THE FRIENDLY LABEL, never the raw code (e.g. show 'Nativo', not 'NATIVE'), and pass the code as the value. Fields, grouped:\n1. Identity — name, lastName, email (optional: preferredLanguage, newsletterConsent).\n2. Profile — `profile.linkedinUrl` (required source; optional GitHub/website/CV path).\n3. Work — freelanceType (offer: "
100
- + labelledOptions(FREELANCE_TYPES, L.freelanceTypeLabels)
101
- + ") and `workSituation` (offer situation: "
102
- + labelledOptions(WORK_SITUATIONS, L.workSituationLabels)
103
- + "; participation "
104
- + labelledOptions(EMPLOYMENT_PARTICIPATIONS, L.employmentParticipationLabels)
105
- + " only when EMPLOYED; opinion "
106
- + labelledOptions(FREELANCE_OPINIONS, L.freelanceOpinionLabels)
107
- + " unless FREELANCE; changeMotivators "
108
- + labelledOptions(CHANGE_MOTIVATORS, L.changeMotivatorLabels)
109
- + " when the talent is or is considering freelance).\n4. Pricing — per-PROJECT price; currency USD, EUR or GBP.\n5. Availability + languages — `availability` (offer monthlyHours "
110
- + MONTHLY_HOURS_SLASHED
111
- + ", workModes as a combinable array of " + WORK_MODES.join('/') + ", longFullTimeProjects yes/no, and LOCATION: country as ISO-2 e.g. ES, timezone as IANA e.g. Europe/Madrid) and `languages` (offer "
112
- + labelledOptions(LANGUAGE_CODES, L.languageNames)
113
- + ", each with a level "
114
- + labelledOptions(LANGUAGE_LEVELS, L.languageLevelLabels)
115
- + ").\n6. Contact — `phone` (telephoneCode e.g. +34 and telephoneNumber).\n\nALWAYS ask for availability (including location), languages and phone — they are expected, not optional extras; only omit one if the talent explicitly declines. There is NO separate location field: location (country/timezone/region) is part of `availability`.\n\nOnce the window completes the account exists and the session is set; the tool then starts the background profile import, saves the work situation, per-PROJECT pricing (ADR-024), and any availability/languages/phone provided. If the account already exists, the tool returns { accountAlreadyExists: true } — switch to the login tool (method 'google') instead. Returns { importJobId, next, disclaimers, profileUrl }. `next` is 'ai_usage' when the talent has no prior AI-usage evidence (offer step 4, the ai_usage tool) or 'onboarding_interview_start' when they already do (step 4 is skipped). When you run ai_usage as this step, pass the returned `usageRepoScope` ({ mode: 'all' }) — registration always measures the whole machine, never a single repo. Before running ai_usage OR onboarding_interview_start, show the talent the matching `disclaimers` block, its two parts (info accessed, goal+duration) separately. Poll register_status with the importJobId to track the import. Always finish any path by calling onboarding_finish (whether or not the talent takes steps 4 and 5) — that is what marks the onboarding complete and moves the talent's registration level.";
116
- }
117
-
118
- const PHONE_SCHEMA = {
119
- type: 'object',
120
- description: "Phone (ADR-036). ALWAYS ask this — it must not be skipped. Two parts: telephoneCode = the international dialling code (e.g. +34, +1, +44), telephoneNumber = the local number. Omit only if the talent explicitly declines to share it.",
121
- properties: {
122
- telephoneCode: { type: 'string', description: "International dialling code, e.g. '+34', '+1', '+44'. Max 10 chars." },
123
- telephoneNumber: { type: 'string', description: 'Local phone number. Max 20 chars.' },
124
- },
125
- required: ['telephoneCode', 'telephoneNumber'],
126
- };
127
-
128
- const PROFILE_SCHEMA = {
129
- type: 'object',
130
- properties: {
131
- linkedinUrl: { type: 'string', description: "The talent's LinkedIn URL. Required — it is the mandatory profile source." },
132
- cvPath: { type: 'string', description: 'Path to the CV (PDF/DOC/DOCX, max 10 MB) on this machine, absolute or ~/... (optional). Only a CV the talent confirmed is theirs. The FILE is uploaded to Shakers with the profile import.' },
133
- githubUrl: { type: 'string', description: 'GitHub URL (optional).' },
134
- websiteUrl: { type: 'string', description: 'Personal website URL (optional).' },
135
- },
136
- required: ['linkedinUrl'],
137
- };
138
-
139
- const PRICING_SCHEMA = {
140
- type: 'object',
141
- description: 'Per-PROJECT pricing (ADR-024). NOT hourly or annual. Amounts are the whole project price as a plain number, no thousands separators, at most 2 decimals, 0..999999.99. Currency is USD, EUR or GBP. The full-time (standard) price is the primary one and should always be captured, exactly as the CLI asks it; part-time is an additional modality. At least one variant must be selected.',
142
- properties: {
143
- fullTimeSelected: { type: 'boolean', description: 'Whether the talent offers full-time projects. This standard/full-time price is the primary one — capture it by default.' },
144
- fullTimeAmount: { type: 'number', description: 'Full-time (standard) per-project price, plain number without thousands separators (e.g. 60000).' },
145
- fullTimeCurrency: { type: 'string', enum: ['USD', 'EUR', 'GBP'], description: 'Currency for the full-time price.' },
146
- partTimeSelected: { type: 'boolean', description: 'Whether the talent also offers part-time projects (additional modality).' },
147
- partTimeAmount: { type: 'number', description: 'Part-time per-project price, plain number without thousands separators (e.g. 50).' },
148
- partTimeCurrency: { type: 'string', enum: ['USD', 'EUR', 'GBP'], description: 'Currency for the part-time price.' },
149
- },
150
- };
151
-
152
- // Default (English) static exports for callers/tests that read the module-level
153
- // schemas directly. The live tools rebuild these per session language.
154
- const { ACCOUNT_FIELDS, WORK_SITUATION_SCHEMA, LANGUAGES_SCHEMA, AVAILABILITY_SCHEMA, REGISTER_SCHEMA, PREVIEW_SCHEMA } = buildSchemas('en');
155
-
156
- const STATUS_SCHEMA = {
157
- type: 'object',
158
- properties: { jobId: { type: 'string', description: 'The importJobId returned by register — an internal handle for polling; never shown or mentioned to the talent.' } },
159
- required: ['jobId'],
160
- };
6
+ const ANSWER_MODES = ['drafted', 'own', 'full'];
161
7
 
162
8
  const INTERVIEW_START_SCHEMA = {
163
9
  type: 'object',
@@ -166,7 +12,17 @@ const INTERVIEW_START_SCHEMA = {
166
12
  language: { type: 'string', enum: ['es', 'en'] },
167
13
  disclaimerAcknowledged: {
168
14
  type: 'boolean',
169
- description: 'Must be true. Set it only after showing the talent BOTH interview disclaimers separately (info accessed AND goal+duration) from the register response, and getting their agreement.',
15
+ description: 'Must be true. Set it only after showing the talent both interview disclaimers word for word, each as its own block (from signup_status or from this tool called without it), and getting their agreement.',
16
+ },
17
+ where: {
18
+ type: 'string',
19
+ enum: ['here', 'later'],
20
+ description: "The talent's pick to 'here now or later on the web?'. Omit it to have the tool ask: in a dialog when the client has one, otherwise it returns the question with its options.",
21
+ },
22
+ answerMode: {
23
+ type: 'string',
24
+ enum: ANSWER_MODES,
25
+ description: "The talent's pick to the answer-mode question: drafted = you propose each answer and they approve or edit it, own = they answer themselves, full = you answer every question yourself. Required with disclaimerAcknowledged:true; omit it to have the tool ask, in a dialog or as options it returns.",
170
26
  },
171
27
  confirmRepeat: {
172
28
  type: 'boolean',
@@ -180,7 +36,7 @@ const INTERVIEW_TURN_SCHEMA = {
180
36
  type: 'object',
181
37
  properties: {
182
38
  interviewId: { type: 'string', description: 'Internal handle for this interview — for calling the interview tools ONLY; never shown or mentioned to the talent.' },
183
- message: { type: 'string', description: "The talent's answer to the current question: in conduit mode their REAL, VERBATIM words; in AI-drafted mode (only if the talent opted in) the answer they APPROVED after seeing your draft, with their edits. Never a value they have not seen and confirmed. If they have not answered or approved yet, do NOT call this." },
39
+ message: { type: 'string', description: "The talent's answer to the current question: in 'own' mode their REAL, VERBATIM words; in 'drafted' mode the answer they APPROVED or edited after seeing your proposal; in 'full' mode (or once they explicitly asked you to answer the rest yourself) your own answer built only from what they confirmed. Outside 'full' mode never a value they have not seen and confirmed: if they have not answered or approved yet, do NOT call this." },
184
40
  },
185
41
  required: ['interviewId', 'message'],
186
42
  };
@@ -193,26 +49,37 @@ const INTERVIEW_COMPLETE_SCHEMA = {
193
49
 
194
50
  const FINISH_SCHEMA = { type: 'object', properties: {} };
195
51
 
52
+ const LIVEKIT_LOAD_FAILURES = new Set(['livekit-not-installed', 'livekit-no-text-streams']);
53
+
54
+ // No telemetry ships in this package: stderr reaches the MCP client's server log, with what is needed to size the gap.
55
+ function defaultReportLivekitFailure(kind, error) {
56
+ const cause = error && error.cause && error.cause.message ? error.cause.message : (error && error.message) || '';
57
+ process.stderr.write(` [mcp] livekit-unavailable kind=${kind} os=${process.platform} arch=${process.arch} node=${process.version} cause=${JSON.stringify(cause.slice(0, 300))}\n`);
58
+ }
59
+
60
+ // The last interview this process completed: the main-role step waits for its evaluation.
61
+ let lastCompletedInterview = null;
62
+
63
+ function completedOnboardingInterview() {
64
+ return lastCompletedInterview;
65
+ }
66
+
196
67
  function makeRegisterTools(deps = {}) {
197
68
  const flow = require('./onboarding-flow');
198
69
  const resolved = flow.makeDeps(deps.flowDeps || {});
199
70
  const lang = deps.lang || 'en';
200
- const schemas = buildSchemas(lang);
201
- const registerDescription = buildRegisterDescription(lang);
202
71
  // Same confirmation copy the CLI prints ("Price saved.", …), reused verbatim.
203
72
  const catalog = require('./i18n').getCatalog(lang).onboarding;
204
- const runLoopbackAuth = deps.runLoopbackAuth || require('./mcp-auth-form').runLoopbackAuth;
73
+ // The talent's sign-up language, for what this tool asks them in the chat.
74
+ const textLang = () => require('./signup-language').signupLanguage(lang);
75
+ const copy = () => require('./signup-copy').signupCopy(textLang());
205
76
  const checkCompleted = deps.checkOnboardingCompleted || require('./onboarding-availability').checkOnboardingCompleted;
206
77
 
207
78
  // The LiveKit room is a live WebRTC connection that must survive between the
208
79
  // separate start/turn/complete JSON-RPC calls of this long-lived stdio server.
209
80
  const interviewSessions = new Map();
210
81
 
211
- function connectHint(kind) {
212
- if (kind === 'livekit-not-installed') return catalog.livekitMissing;
213
- if (kind === 'livekit-no-text-streams') return catalog.livekitOld;
214
- return catalog.livekitConnectError;
215
- }
82
+ const reportLivekitFailure = deps.reportLivekitFailure || defaultReportLivekitFailure;
216
83
 
217
84
  async function teardownInterview(interviewId, { persist = false } = {}) {
218
85
  const sess = interviewSessions.get(interviewId);
@@ -231,114 +98,29 @@ function makeRegisterTools(deps = {}) {
231
98
  return saved;
232
99
  }
233
100
 
234
- async function register(args = {}) {
235
- const registerFields = {
236
- name: args.name,
237
- lastName: args.lastName,
238
- email: args.email,
239
- preferredLanguage: args.preferredLanguage,
240
- newsletterConsent: args.newsletterConsent === true,
241
- freelanceType: args.freelanceType,
242
- };
243
- const check = flow.validateRegisterFields(registerFields);
244
- if (!check.ok) return { ok: false, step: 'signup', reason: check.reason };
245
-
246
- const created = await runLoopbackAuth({ mode: 'register', deps: resolved, lang, registerFields });
247
- if (created.viaSocial) {
248
- // Social registration is the device flow (start/poll tools), not this single blocking call.
249
- const provider = created.provider || 'google';
250
- return {
251
- ok: false,
252
- step: 'signup',
253
- reason: 'use-device-flow',
254
- next: 'social_signin_start',
255
- mode: 'register',
256
- provider,
257
- message: `To register with ${provider}, call social_signin_start (provider "${provider}", mode "register", freelanceType "${registerFields.freelanceType || ''}") to get a URL and code to show the talent, then social_signin_poll. Once it returns authorized, run the profile steps (import, work situation, pricing).`,
258
- };
259
- }
260
- if (!created.ok) {
261
- return { ok: false, step: 'signup', reason: created.reason };
262
- }
263
-
264
- const profile = args.profile || {};
265
- const importRes = await flow.startProfileImport(resolved, {
266
- linkedinUrl: profile.linkedinUrl,
267
- cvPath: profile.cvPath,
268
- githubUrl: profile.githubUrl,
269
- websiteUrl: profile.websiteUrl,
270
- language: lang,
271
- });
272
- const importOutcome = importRes.ok
273
- ? { ok: true, jobId: importRes.jobId, state: importRes.state }
274
- : { ok: false, reason: importRes.reason, warning: 'Profile import is unavailable right now; registration continues and the import can be retried later.' };
275
-
276
- const workSituationRes = await flow.saveWorkSituation(resolved, args.workSituation || {});
277
- if (!workSituationRes.ok) return { ok: false, step: 'work-situation', reason: workSituationRes.reason };
278
-
279
- const priceRes = await flow.savePricing(resolved, args.pricing || {});
280
- if (!priceRes.ok) return { ok: false, step: 'pricing', reason: priceRes.reason };
281
-
282
- let availabilityOutcome = { skipped: true };
283
- if (args.availability && typeof args.availability === 'object') {
284
- const availRes = await flow.saveAvailability(resolved, args.availability);
285
- availabilityOutcome = availRes.ok
286
- ? { ok: true }
287
- : { ok: false, reason: availRes.reason, warning: 'Availability could not be saved; registration continues and it can be set later.' };
288
- }
289
-
290
- let languagesOutcome = { skipped: true };
291
- if (Array.isArray(args.languages) && args.languages.length > 0) {
292
- const langRes = await flow.saveLanguages(resolved, args.languages);
293
- languagesOutcome = langRes.ok
294
- ? { ok: true }
295
- : { ok: false, reason: langRes.reason, unresolved: langRes.unresolved, warning: 'Languages could not be saved; registration continues and they can be added later.' };
296
- }
297
-
298
- let phoneOutcome = { skipped: true };
299
- if (args.phone && typeof args.phone === 'object') {
300
- const phoneRes = await flow.savePhone(resolved, args.phone);
301
- phoneOutcome = phoneRes.ok
302
- ? { ok: true }
303
- : { ok: false, reason: phoneRes.reason, warning: 'Phone could not be saved; registration continues and it can be set later.' };
304
- }
305
-
306
- const prior = await flow.detectPriorUsage(resolved);
307
- const hasEvidence = prior.ok ? prior.hasEvidence : null;
308
-
309
- // Human-readable confirmations, identical to the CLI step notices.
310
- const confirmations = [catalog.accountReady, catalog.professionalSaved, catalog.pricingSaved];
311
- if (importOutcome.ok) confirmations.push(catalog.importDone);
312
- if (availabilityOutcome.ok) confirmations.push(catalog.availabilitySaved);
313
- if (languagesOutcome.ok) confirmations.push(catalog.languagesSaved);
314
- if (phoneOutcome.ok) confirmations.push(catalog.phoneSaved);
101
+ function interviewLater() {
102
+ return { ok: false, reason: 'interview-later', message: 'The talent does the interview later on the web: skip it here and go on with the main role, then open_web.' };
103
+ }
315
104
 
316
- return {
317
- ok: true,
318
- message: catalog.accountReady,
319
- confirmations,
320
- import: importOutcome,
321
- importJobId: importOutcome.jobId || null,
322
- availability: availabilityOutcome,
323
- languages: languagesOutcome,
324
- phone: phoneOutcome,
325
- priorUsage: { hasEvidence },
326
- next: hasEvidence === true ? 'onboarding_interview_start' : 'ai_usage',
327
- usageRepoScope: { mode: 'all' },
328
- disclaimers: {
329
- usage: flow.usageDisclaimers(lang),
330
- interview: flow.interviewDisclaimers(lang),
331
- },
332
- profileUrl: flow.profileUrl(resolved),
333
- };
105
+ async function askWhere(args, ctx) {
106
+ if (args.where === 'here') return { here: true };
107
+ if (args.where === 'later') return { here: false };
108
+ const c = copy();
109
+ const asked = await askChoice(ctx, { question: c.interviewWhereQuestion, options: [c.interviewHere, c.interviewLater] });
110
+ if (asked.answer) return { here: asked.answer === c.interviewHere };
111
+ return { chat: asked.chat, dismissed: asked.dismissed };
334
112
  }
335
113
 
336
- async function registerStatus(args = {}) {
337
- const res = await flow.pollProfileImport(resolved, { jobId: args.jobId });
338
- return res.ok ? { ok: true, state: res.state } : { ok: false, reason: res.reason };
114
+ async function askAnswerMode(args, ctx) {
115
+ if (ANSWER_MODES.includes(args.answerMode)) return { mode: args.answerMode };
116
+ const c = copy();
117
+ const labels = { drafted: c.answerModeDrafted, own: c.answerModeOwn, full: c.answerModeFull };
118
+ const asked = await askChoice(ctx, { question: c.answerModeQuestion, options: ANSWER_MODES.map((m) => labels[m]) });
119
+ if (asked.answer) return { mode: ANSWER_MODES.find((m) => labels[m] === asked.answer) };
120
+ return { chat: asked.chat, dismissed: asked.dismissed };
339
121
  }
340
122
 
341
- async function interviewStart(args = {}) {
123
+ async function interviewStart(args = {}, ctx = {}) {
342
124
  let alreadyCompleted = false;
343
125
  try {
344
126
  alreadyCompleted = await checkCompleted({});
@@ -355,18 +137,39 @@ function makeRegisterTools(deps = {}) {
355
137
  };
356
138
  }
357
139
  if (args.disclaimerAcknowledged !== true) {
140
+ const where = await askWhere(args, ctx);
141
+ if (where.here === false) return interviewLater();
142
+ const disclaimers = flow.interviewDisclaimers(textLang());
143
+ const show = 'show the talent each relayVerbatim text word for word, each as its own block, with no titles, labels or bold of your own, then call again with disclaimerAcknowledged:true.';
358
144
  return {
359
145
  ok: false,
360
146
  reason: 'disclaimers-not-acknowledged',
361
- disclaimers: flow.interviewDisclaimers(lang),
362
- message: 'Show the talent BOTH interview disclaimers separately (info accessed AND goal+duration), then call again with disclaimerAcknowledged:true.',
147
+ relayVerbatim: [disclaimers.infoAccessed, disclaimers.goalDuration],
148
+ ...(where.chat ? { where: where.chat } : {}),
149
+ ...(where.dismissed ? { dismissed: where.dismissed } : {}),
150
+ message: where.chat
151
+ ? `First ask \`where.question\` with its \`where.options\` (your native choice buttons if you have them, otherwise a short numbered list; accept the number or the text). If they pick the web, call again with where:"later". If they pick here, ${show}`
152
+ : `The talent does it here: ${show}`,
153
+ };
154
+ }
155
+ const answer = await askAnswerMode(args, ctx);
156
+ if (answer.chat) {
157
+ return {
158
+ ok: false,
159
+ reason: 'answer-mode-required',
160
+ ...(answer.dismissed ? { dismissed: answer.dismissed } : {}),
161
+ ...answer.chat,
162
+ message: `${answer.chat.choiceMessage} Then call again with disclaimerAcknowledged:true and answerMode: drafted for the first option, own for the second, full for the third.`,
363
163
  };
364
164
  }
365
165
  const start = await flow.createAndStartLivekitOnboarding(resolved, {
366
166
  candidateId: args.candidateId,
367
167
  language: args.language || lang,
368
168
  });
369
- if (!start.ok) return { ok: false, step: start.step || 'start', reason: start.reason };
169
+ if (!start.ok) {
170
+ const out = { ok: false, step: start.step || 'start', reason: start.reason };
171
+ return start.reason === 'interview-already-started' ? { ...out, next: 'open_web' } : out;
172
+ }
370
173
 
371
174
  const closing = start.closingMessage;
372
175
  const isClosing = closing ? (t) => typeof t === 'string' && t.includes(closing) : null;
@@ -378,7 +181,9 @@ function makeRegisterTools(deps = {}) {
378
181
  } catch (e) {
379
182
  try { await client.disconnect(); } catch { /* nothing joined */ }
380
183
  const kind = (e && e.kind) || 'connect-failed';
381
- return { ok: false, step: 'connect', reason: kind, message: connectHint(kind) };
184
+ if (LIVEKIT_LOAD_FAILURES.has(kind)) reportLivekitFailure(kind, e);
185
+ // Whatever kept the room from opening here, the same interview runs on the web.
186
+ return { ok: false, step: 'connect', reason: kind, relayVerbatim: copy().livekitUnavailable, message: 'Show relayVerbatim word for word, then open_web.', next: 'open_web' };
382
187
  }
383
188
 
384
189
  let opening;
@@ -390,7 +195,7 @@ function makeRegisterTools(deps = {}) {
390
195
  }
391
196
  buffer.recordAgent(opening.text);
392
197
  interviewSessions.set(start.interviewId, { client, buffer, connectAt: Date.now(), ended: opening.ended === true });
393
- return { ok: true, interviewId: start.interviewId, greeting: opening.text, ended: opening.ended === true };
198
+ return { ok: true, interviewId: start.interviewId, answerMode: answer.mode, greeting: opening.text, ended: opening.ended === true };
394
199
  }
395
200
 
396
201
  async function interviewTurn(args = {}) {
@@ -420,6 +225,7 @@ function makeRegisterTools(deps = {}) {
420
225
  }
421
226
  const saved = await teardownInterview(args.interviewId, { persist: true });
422
227
  if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed' };
228
+ lastCompletedInterview = { interviewId: args.interviewId, completedAt: Date.now() };
423
229
  return { ok: true, state: 'taken', message: catalog.interviewComplete };
424
230
  }
425
231
 
@@ -428,7 +234,7 @@ function makeRegisterTools(deps = {}) {
428
234
  const reset = await flow.restartOnboarding(resolved);
429
235
  if (!reset.ok) {
430
236
  if (reset.reason === 'onboarding-not-found') {
431
- return { ok: false, reason: 'onboarding-not-found', message: 'The talent has no onboarding interview yet — run the normal onboarding first (register + onboarding_interview_start), nothing to repeat.' };
237
+ return { ok: false, reason: 'onboarding-not-found', message: 'The talent has no onboarding interview yet: nothing to repeat. Offer onboarding_interview_start instead.' };
432
238
  }
433
239
  return { ok: false, reason: reset.reason };
434
240
  }
@@ -456,11 +262,6 @@ function makeRegisterTools(deps = {}) {
456
262
  };
457
263
  }
458
264
 
459
- // PURE preview: reuses the write-path validators, touches no network, no session.
460
- async function registerPreview(args = {}) {
461
- return flow.buildRegisterPreview(args || {});
462
- }
463
-
464
265
  // Content-free context the model may PROPOSE to the talent (never auto-write).
465
266
  async function suggestContext(args = {}) {
466
267
  const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
@@ -479,27 +280,15 @@ function makeRegisterTools(deps = {}) {
479
280
  }
480
281
 
481
282
  return [
482
- {
483
- name: 'register',
484
- description: registerDescription,
485
- inputSchema: schemas.REGISTER_SCHEMA,
486
- handler: register,
487
- },
488
- {
489
- name: 'register_preview',
490
- description: "REVIEW a (partial) register payload BEFORE writing — the preferred way to register with the least typing. Fill every field you can reasonably infer, then call this. It writes NOTHING; it returns { wouldWrite: the normalized data per hub endpoint, missingFields: what is still needed (required:true) or just suggested (required:false) to ASK the talent, warnings: any value that is invalid, mustConfirm }. Show the talent a short human summary of wouldWrite and the missing items, let them confirm or correct, then call `register` with the final payload. Pre-fill every field you can reasonably infer (memory, chat, context), including pricing and changeMotivators; omit only what you have no basis for. The talent confirms the whole summary once; fields in `mustConfirm` just need to be visible in that summary, not asked separately. All register data is self-declared profile data.",
491
- inputSchema: schemas.PREVIEW_SCHEMA,
492
- handler: registerPreview,
493
- },
494
283
  {
495
284
  name: 'suggest_register_context',
496
- description: "Local SUGGESTIONS to fill the registration (no network, no file contents): `environment` the sign-up goes to (local/development/staging/production — name it correctly, never call a test environment production), timezone, git name/email, the GitHub login of the gh CLI (githubUser/githubUrl), and cvCandidates — PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's full name, each with nameMatch and generic flags (people who hire keep other people's CVs, so a candidate is only a lead: confirm it with read_cv). Pass talentName = the talent's name as you know it to sharpen the match. Propose values, never write without the talent's confirmation.",
285
+ description: "SUGGESTIONS for the sign-up read on this machine (no network, no file contents): timezone, git name/email, the gh CLI GitHub login, and cvCandidates: PDF/DOC/DOCX files in Desktop, Documents, Downloads and iCloud Drive whose NAME looks like a CV or carries the talent's name, each with nameMatch and generic flags. A candidate is only a lead (people who hire keep other people's CVs): confirm it with read_cv. Pass talentName to sharpen the match. Use it when the talent said you may look for their CV on this machine.",
497
286
  inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." } } },
498
287
  handler: suggestContext,
499
288
  },
500
289
  {
501
290
  name: 'read_cv',
502
- description: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine to fill the register and the onboarding from it. Call it once the talent has allowed you to search and read their CV (talentConsent:true), with talentName set. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; for a CV that does not name them it returns only mentionsTalent:false — it is someone else's: drop it, never describe it, try the next candidate. Local only: nothing is uploaded by this tool (the CV is sent to Shakers only as profile.cvPath in a confirmed register). On failure ask the talent to attach the CV to the chat.",
291
+ description: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine, with the talent's permission (talentConsent:true) and talentName set. Returns { mentionsTalent, links: { linkedin, github, emails, phones }, text } for the talent's own CV; a CV that does not name them returns only mentionsTalent:false: it is someone else's, drop it, never describe it, try the next candidate. Nothing is uploaded by this tool: pass the confirmed path to signup_start (or import_profile) as cvPath, use the text for userQuery and the email, and links.linkedin as their LinkedIn URL.",
503
292
  inputSchema: {
504
293
  type: 'object',
505
294
  properties: {
@@ -511,15 +300,9 @@ function makeRegisterTools(deps = {}) {
511
300
  },
512
301
  handler: readCv,
513
302
  },
514
- {
515
- name: 'register_status',
516
- description: 'Check the state of the background profile import started by register. Pass the importJobId (an internal handle — never shown to the talent). Returns { state } (e.g. running | done | failed).',
517
- inputSchema: STATUS_SCHEMA,
518
- handler: registerStatus,
519
- },
520
303
  {
521
304
  name: 'onboarding_interview_start',
522
- description: "Start the onboarding interview (step 5, optional). Runs over LiveKit: the server joins a live room and the agent conducts it, transparently resolving the branch (role/dedication only when AI-usage evidence exists, otherwise the AI-usage blocks too) — the client does NOT decide the branch. You MUST first show the talent BOTH interview disclaimers separately (from register's disclaimers.interview) and only then call with disclaimerAcknowledged:true. Returns { interviewId, greeting, ended }: relay the greeting to the talent VERBATIM, then drive the interview with onboarding_interview_turn. ANSWER MODE — before calling this, ASK the talent once: do they want you to draft their answers from what you already know about them (your memory of past conversations, the chat, the register data), so they only confirm, correct or add their opinion? (1) If they say YES (AI-drafted mode): FIRST gather everything you can know about them — your memory, a search of your past conversations with them if you have such a tool, their CV (read_cv, with their permission, if not read yet), their AI-usage report and the register data — and show ONE short sheet 'esto es lo que sé de ti' (roles and projects with the concrete facts, stack, languages, what they want next). Let them correct it once; those corrections override your memory for every later answer. Then for every interview question show the question VERBATIM and your proposed answer in the first person: one or two short sentences in their plain voice, built only from facts on the confirmed sheet — no embellishment, no adjectives, metrics or claims they did not give, no repeating a fact already said unless asked. If the sheet does not cover the question, do not guess a fact: ask them briefly. Ask them to confirm, edit or add their view. Send ONLY the version they approve — their edits and opinions included — never a draft they have not seen. For questions about wishes, preferences or opinions, give your best guess but make clear it is a guess. (2) If they say NO: you are a pure CONDUIT — show every question VERBATIM and pass only the talent's REAL, VERBATIM words back; do not opine, guide or answer for them. In both modes never reword, summarize or translate the interview's questions. `interviewId` is an internal handle for the interview tools only — never show or mention it to the talent. If it returns reason 'livekit-not-installed', relay the `message` (it tells the talent how to install the optional @livekit/rtc-node package). If the talent declines the interview, do not call this — instead run the final main-role step (list_my_roles + set_main_role) and offer them their profile URL from register. If the talent has ALREADY completed onboarding this refuses with an already-completed notice unless confirmRepeat:true; note a completed onboarding interview cannot actually be restarted (it returns reason 'interview-already-started') — in that case just run set_main_role.",
305
+ description: "Start the onboarding interview, here in the chat. Offer it only when signup_status says onboardingInterview.completed is not true. Call it first without disclaimerAcknowledged: it asks 'here now or later on the web?' (in a dialog when the client has one, otherwise it returns `where` with the question and its options to ask; default here) and returns the two interview disclaimers. reason 'interview-later' means they chose the web: skip the interview. If it returns reason 'interview-already-started' (begun elsewhere and not finished), call open_web so they continue on the web. It runs over LiveKit and the server agent conducts it; the client does not decide its branch. You MUST show the talent both interview disclaimers word for word, each as its own block, never paraphrased or relabelled, and only then call with disclaimerAcknowledged:true and answerMode (without answerMode it asks the answer-mode question in a dialog, or returns it with its options: ask it and call again). Returns { interviewId, answerMode, greeting, ended }: relay the greeting VERBATIM, then drive it with onboarding_interview_turn. ANSWER MODES. For drafted and full, first gather everything you know (memory, a search of past conversations if you have that tool, their CV, the ai_usage report, the sign-up data) and show ONE short sheet 'esto es lo que se de ti' (roles and projects with concrete facts, stack, languages, what they want next); let them correct it once; those corrections override your memory. Your answers are first person, one or two short sentences in their plain voice, built only from facts on the confirmed sheet: no embellishment, no adjectives, metrics or claims they did not give, no repeating a fact already said unless asked; for wishes, preferences or opinions give your best guess and say it is a guess. (1) drafted: show each question VERBATIM with your proposed answer and send it only after the talent approves or edits it; if the sheet does not cover a question, ask them briefly instead of guessing. Only if the talent explicitly asks you to answer everything yourself (for example 'hazla entera' or 'contéstalas tú todas'), answer the remaining questions without asking for each approval, as in full. (2) full (they chose it, or asked for it as above): answer every question yourself without per-answer approval, still showing each question and your answer, and when the interview ends tell them in one line that you answered those questions yourself. (3) own: you are a pure CONDUIT: show every question VERBATIM and pass back only their REAL, VERBATIM words. In every mode never reword, summarize or translate the questions. `interviewId` is internal: never show it. If it fails with next:'open_web', relay `message` and call open_web so they do the interview on the web.",
523
306
  inputSchema: INTERVIEW_START_SCHEMA,
524
307
  handler: interviewStart,
525
308
  available: async () => {
@@ -532,25 +315,25 @@ function makeRegisterTools(deps = {}) {
532
315
  },
533
316
  {
534
317
  name: 'onboarding_interview_turn',
535
- description: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the ANSWER MODE the talent chose at onboarding_interview_start. AI-drafted mode: `message` is the answer the talent APPROVED after seeing your draft (with their edits/opinion); never send a draft they have not confirmed. Conduit mode: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. In both modes, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
318
+ description: "Send the talent's answer to the current onboarding-interview question and get the next one. Follow the answerMode the talent chose at onboarding_interview_start. drafted: `message` is the answer the talent APPROVED or edited after seeing your proposal; never send one they have not confirmed, unless they explicitly asked you to answer the rest yourself ('hazla entera', 'contéstalas tú todas'): from then on, as in full, send your own answers without per-answer approval. full: send your own answer to each question, built only from what they confirmed, and when it ends tell them you answered those questions yourself. own: `message` is ONLY the talent's REAL, VERBATIM answer — never invent, infer or complete it. Outside full, if the talent has not answered or approved yet, do NOT call this, wait for them. Relay the returned `response` (the next question) to the talent VERBATIM — do not reword, summarize or translate it. Keep calling with their next `message` until `ended` is true, then call onboarding_interview_complete. Do NOT show or mention `interviewId` to the talent (internal handle). If it returns reason 'unknown-interview' (the server was restarted and the live room is gone), start again with onboarding_interview_start.",
536
319
  inputSchema: INTERVIEW_TURN_SCHEMA,
537
320
  handler: interviewTurn,
538
321
  },
539
322
  {
540
323
  name: 'onboarding_interview_complete',
541
- description: "Close the onboarding interview once the turns report ended:true: the buffered transcript is saved and the live room is disconnected. `interviewId` is an internal handle for the interview tools only — never show or mention it to the talent. Returns { state:'taken' }. If it returns reason 'unknown-interview' the room was already gone (server restart) — nothing to complete. After this, if this is first-time REGISTRATION, run the final registration step — offer the talent their main role with list_my_roles + set_main_role — then call onboarding_finish. If you got here from repeat_onboarding_interview (a re-run, not registration), SKIP the main-role step and go straight to onboarding_finish. To change the main role outside registration, the talent uses change-role.",
324
+ description: "Close the onboarding interview once the turns report ended:true: the buffered transcript is saved and the live room is disconnected. `interviewId` is internal: never show it. Returns { state:'taken' }. If it returns reason 'unknown-interview' the room was already gone (server restart): nothing to complete. In the sign-up flow, go on to the main role (list_my_roles with waitSeconds, then set_main_role) and finish with open_web; do NOT call onboarding_finish there.",
542
325
  inputSchema: INTERVIEW_COMPLETE_SCHEMA,
543
326
  handler: interviewComplete,
544
327
  },
545
328
  {
546
329
  name: 'repeat_onboarding_interview',
547
- description: "OVERWRITE-repeat the onboarding interview: resets the talent's completed onboarding interview IN PLACE — the previous evaluation is DISCARDED and CANNOT be recovered. You MUST clearly warn the talent that this overwrites their previous onboarding and is irreversible, and get their explicit go-ahead, BEFORE calling this. On success run the interview again (onboarding_interview_start → onboarding_interview_turn until ended → onboarding_interview_complete), then onboarding_finish. This is a RE-RUN, not registration: do NOT run the main-role step (list_my_roles/set_main_role) afterwards — to change the main role outside registration the talent uses change-role. Returns { interviewId, language } or reason 'onboarding-not-found' (the talent has no onboarding yet — do the normal onboarding first), 'http-401'/'http-403'.",
330
+ description: "OVERWRITE-repeat the onboarding interview: resets the talent's completed onboarding interview IN PLACE; the previous evaluation is DISCARDED and CANNOT be recovered. Clearly warn the talent that this is irreversible and get their explicit go-ahead BEFORE calling this. On success run the interview again (onboarding_interview_start, onboarding_interview_turn until ended, onboarding_interview_complete), then onboarding_finish. Returns { interviewId, language } or reason 'onboarding-not-found' (no onboarding yet), 'http-401'/'http-403'.",
548
331
  inputSchema: { type: 'object', properties: {} },
549
332
  handler: repeatOnboarding,
550
333
  },
551
334
  {
552
335
  name: 'onboarding_finish',
553
- description: "Mark the onboarding as complete — the TERMINAL step, ALWAYS call it once the mandatory steps (register) are done, whether the talent took OR skipped the AI-usage evaluation and the interview. This flips their onboarding status to COMPLETED and lets Shakers promote their registration level (the metrics the team tracks). Idempotent: safe to call once at the end of any path. Returns { finalized, onboardingStatus, registrationLevel, completedProfilePercentage, profileUrl }: use completedProfilePercentage to tell the talent how complete their profile is, and profileUrl to point them to it.",
336
+ description: "Mark the onboarding as COMPLETED on Shakers. NOT part of the sign-up flow (signup_start): there, never call it. Use it only to close a repeat_onboarding_interview run. Idempotent. Returns { finalized, onboardingStatus, registrationLevel, completedProfilePercentage, profileUrl }.",
554
337
  inputSchema: FINISH_SCHEMA,
555
338
  handler: onboardingFinish,
556
339
  },
@@ -559,18 +342,9 @@ function makeRegisterTools(deps = {}) {
559
342
 
560
343
  module.exports = {
561
344
  makeRegisterTools,
562
- ACCOUNT_FIELDS,
563
- REGISTER_SCHEMA,
564
- PREVIEW_SCHEMA,
565
- STATUS_SCHEMA,
345
+ completedOnboardingInterview,
566
346
  INTERVIEW_START_SCHEMA,
567
347
  INTERVIEW_TURN_SCHEMA,
568
348
  INTERVIEW_COMPLETE_SCHEMA,
569
349
  FINISH_SCHEMA,
570
- PROFILE_SCHEMA,
571
- WORK_SITUATION_SCHEMA,
572
- PRICING_SCHEMA,
573
- AVAILABILITY_SCHEMA,
574
- LANGUAGES_SCHEMA,
575
- PHONE_SCHEMA,
576
350
  };