shakers-cli-beta 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/bin/mcp.js +13 -7
- package/bin/register.js +5 -2
- package/package.json +1 -1
- package/src/authorship-mismatch.js +62 -0
- package/src/backend-request.js +12 -8
- package/src/config.js +27 -6
- package/src/consent-flow.js +10 -10
- package/src/i18n.js +16 -12
- package/src/mcp-ai-usage-result-tool.js +4 -2
- package/src/mcp-ai-usage-tool.js +3 -1
- package/src/mcp-alma-tools.js +7 -3
- package/src/mcp-applications-tools.js +4 -2
- package/src/mcp-auth-form.js +39 -10
- package/src/mcp-availability-tools.js +4 -2
- package/src/mcp-certifications-tools.js +4 -2
- package/src/mcp-certify-dimension-tools.js +4 -2
- package/src/mcp-choice.js +30 -4
- package/src/mcp-device-login-tools.js +6 -2
- package/src/mcp-find-projects-tools.js +4 -2
- package/src/mcp-instructions.js +6 -6
- package/src/mcp-invitations-tools.js +4 -2
- package/src/mcp-lang-tools.js +4 -2
- package/src/mcp-onboarding-tools.js +41 -44
- package/src/mcp-portfolios-tools.js +7 -3
- package/src/mcp-profile-tools.js +4 -2
- package/src/mcp-prompts.js +3 -3
- package/src/mcp-rate-tools.js +4 -2
- package/src/mcp-register-tools.js +53 -20
- package/src/mcp-report-tools.js +7 -3
- package/src/mcp-role-tools.js +36 -13
- package/src/mcp-saved-positions-tools.js +7 -3
- package/src/mcp-server.js +17 -4
- package/src/mcp-show-project-tools.js +4 -2
- package/src/mcp-signup-tools.js +229 -62
- package/src/mcp-socials-tools.js +4 -2
- package/src/onboarding-flow.js +112 -13
- package/src/onboarding-funnel.js +272 -0
- package/src/profile-client.js +11 -0
- package/src/share.js +4 -3
- package/src/signup-copy.js +84 -26
- package/src/terminal-agents.js +8 -0
- package/src/usage-run-steps.js +3 -0
- package/src/mcp-ask-tools.js +0 -50
package/src/mcp-rate-tools.js
CHANGED
|
@@ -14,7 +14,7 @@ function makeRateTools(deps = {}) {
|
|
|
14
14
|
const requireLoginSession = () => {
|
|
15
15
|
const session = loadAuthSession();
|
|
16
16
|
if (sessionStatus(session) !== 'active') {
|
|
17
|
-
throw new Error('no active Shakers session —
|
|
17
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (get_rate is session-gated).');
|
|
18
18
|
}
|
|
19
19
|
return session;
|
|
20
20
|
};
|
|
@@ -29,7 +29,9 @@ function makeRateTools(deps = {}) {
|
|
|
29
29
|
return [
|
|
30
30
|
{
|
|
31
31
|
name: 'get_rate',
|
|
32
|
-
|
|
32
|
+
title: 'Your rates',
|
|
33
|
+
description: 'Shows your hourly rate and your yearly target on Shakers.',
|
|
34
|
+
guidance: "View the talent's rates as the web shows them: partTimePrice is the hourly rate (per hour) and fullTimePrice the annual target (per year); a variant not selected is not offered. Read-only. Requires an active session. Show only the human-facing amounts/currency; any internal ids in the result are handles for tools only — never show or mention them to the talent.",
|
|
33
35
|
inputSchema: GET_RATE_SCHEMA,
|
|
34
36
|
handler: getRate,
|
|
35
37
|
},
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { TranscriptBuffer } = require('./interview-transcript');
|
|
4
|
-
const
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { askChoice, askConsent } = require('./mcp-choice');
|
|
6
|
+
const { expandHome, tildify } = require('./env-paths');
|
|
5
7
|
|
|
6
8
|
const ANSWER_MODES = ['drafted', 'own', 'full'];
|
|
7
9
|
|
|
@@ -62,7 +64,7 @@ function withTimeout(promise, ms) {
|
|
|
62
64
|
return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
//
|
|
67
|
+
// Stays local (stderr, the MCP client's log); only the sign-up funnel is sent.
|
|
66
68
|
function defaultReportLivekitFailure(kind, error) {
|
|
67
69
|
const cause = error && error.cause && error.cause.message ? error.cause.message : (error && error.message) || '';
|
|
68
70
|
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`);
|
|
@@ -85,6 +87,8 @@ function makeRegisterTools(deps = {}) {
|
|
|
85
87
|
const textLang = () => require('./signup-language').signupLanguage(lang);
|
|
86
88
|
const copy = () => require('./signup-copy').signupCopy(textLang());
|
|
87
89
|
const checkCompleted = deps.checkOnboardingCompleted || require('./onboarding-availability').checkOnboardingCompleted;
|
|
90
|
+
const funnel = deps.funnel || require('./onboarding-funnel').NOOP_FUNNEL;
|
|
91
|
+
const track = (record) => { record(); funnel.flush(); };
|
|
88
92
|
|
|
89
93
|
// The LiveKit room is a live WebRTC connection that must survive between the
|
|
90
94
|
// separate start/turn/complete JSON-RPC calls of this long-lived stdio server.
|
|
@@ -115,6 +119,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
function interviewLater() {
|
|
122
|
+
track(() => funnel.completed('pre_interview', { interview_choice: 'skip' }));
|
|
118
123
|
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.' };
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -149,7 +154,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
149
154
|
reason: 'onboarding-already-completed',
|
|
150
155
|
alreadyCompleted: true,
|
|
151
156
|
message:
|
|
152
|
-
'
|
|
157
|
+
'The talent already completed the onboarding interview: skip it and say nothing about it to the talent, just go on with the next step. Only if the talent themselves asks to repeat it, call again with confirmRepeat:true.',
|
|
153
158
|
};
|
|
154
159
|
}
|
|
155
160
|
if (!CHAT_INTERVIEW_LANGUAGES.has(textLang())) {
|
|
@@ -157,6 +162,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
157
162
|
}
|
|
158
163
|
const acknowledged = args.disclaimerAcknowledged === true;
|
|
159
164
|
if (!acknowledged) {
|
|
165
|
+
track(() => funnel.viewed('pre_interview'));
|
|
160
166
|
const where = await askWhere(args, ctx);
|
|
161
167
|
if (where.here === false) return interviewLater();
|
|
162
168
|
if (where.chat) {
|
|
@@ -181,8 +187,10 @@ function makeRegisterTools(deps = {}) {
|
|
|
181
187
|
message: `${answer.chat.choiceMessage} Then call onboarding_interview_start again with where:"here", disclaimerAcknowledged:true and answerMode: drafted for the first option, own for the second, full for the third.`,
|
|
182
188
|
};
|
|
183
189
|
}
|
|
190
|
+
track(() => funnel.completed('pre_interview', { interview_choice: 'start_now' }));
|
|
184
191
|
const room = {};
|
|
185
192
|
const opened = await withTimeout(openRoom(args, room), interviewTimeoutMs);
|
|
193
|
+
if (opened === TIMED_OUT || !opened.ok) track(() => funnel.errored('interview', opened === TIMED_OUT ? 'timeout' : opened.reason));
|
|
186
194
|
if (opened === TIMED_OUT) {
|
|
187
195
|
room.abandoned = true;
|
|
188
196
|
if (room.client) room.client.disconnect().catch(() => {});
|
|
@@ -190,6 +198,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
190
198
|
}
|
|
191
199
|
if (!opened.ok) return opened;
|
|
192
200
|
interviewSessions.set(opened.interviewId, { client: opened.client, buffer: opened.buffer, connectAt: Date.now(), ended: opened.opening.ended === true });
|
|
201
|
+
track(() => funnel.viewed('interview', { interview_mode: 'text' }));
|
|
193
202
|
return { ok: true, interviewId: opened.interviewId, answerMode: answer.mode, greeting: opened.opening.text, ended: opened.opening.ended === true };
|
|
194
203
|
}
|
|
195
204
|
|
|
@@ -275,6 +284,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
275
284
|
const saved = await teardownInterview(args.interviewId, { persist: true });
|
|
276
285
|
if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed' };
|
|
277
286
|
lastCompletedInterview = { interviewId: args.interviewId, completedAt: Date.now() };
|
|
287
|
+
track(() => funnel.completed('interview', { interview_mode: 'text' }));
|
|
278
288
|
return { ok: true, state: 'taken', message: catalog.interviewComplete };
|
|
279
289
|
}
|
|
280
290
|
|
|
@@ -311,8 +321,12 @@ function makeRegisterTools(deps = {}) {
|
|
|
311
321
|
};
|
|
312
322
|
}
|
|
313
323
|
|
|
314
|
-
// Content-free context the model may PROPOSE to the talent (never auto-write).
|
|
315
|
-
async function suggestContext(args = {}) {
|
|
324
|
+
// Content-free context the model may PROPOSE to the talent (never auto-write), searched only after the talent says yes.
|
|
325
|
+
async function suggestContext(args = {}, ctx = {}) {
|
|
326
|
+
const c = copy().consent;
|
|
327
|
+
const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.yes, no: c.no, answer: args.answer });
|
|
328
|
+
if (asked.reply) return asked.reply;
|
|
329
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: nothing was searched. Do not look for the CV on this computer; ask them to attach it in the chat.' };
|
|
316
330
|
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
317
331
|
const suggestions = read({ talentName: args.talentName });
|
|
318
332
|
const found = suggestions.cvCandidates && suggestions.cvCandidates.length > 0;
|
|
@@ -321,16 +335,21 @@ function makeRegisterTools(deps = {}) {
|
|
|
321
335
|
suggestions,
|
|
322
336
|
note: 'Propose these to the talent to confirm; never write them without confirmation.',
|
|
323
337
|
message: found
|
|
324
|
-
? '
|
|
338
|
+
? 'Now call read_cv on the first candidate (the most likely), with talentName if you know it: read_cv asks the talent itself before reading it, so ask nothing in your own words. nameMatch:null only means their name is unknown, never that the CV is not theirs. Move to the next candidate only if read_cv says mentionsTalent:false; say you did not find it only when none is left.'
|
|
325
339
|
: 'No CV was found on this machine: ask the talent to attach it.',
|
|
326
340
|
};
|
|
327
341
|
}
|
|
328
342
|
|
|
329
|
-
// Text of the CV the talent
|
|
330
|
-
async function readCv(args = {}) {
|
|
331
|
-
if (args.
|
|
343
|
+
// Text of the CV file the talent allowed this tool to read, in its own question.
|
|
344
|
+
async function readCv(args = {}, ctx = {}) {
|
|
345
|
+
if (typeof args.path !== 'string' || !args.path.trim()) return { ok: false, reason: 'cv-not-found', message: 'Pass the path of the CV file.' };
|
|
346
|
+
const file = path.resolve(expandHome(args.path.trim()));
|
|
347
|
+
const c = copy().consent;
|
|
348
|
+
const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.yes, no: c.no, answer: args.answer });
|
|
349
|
+
if (asked.reply) return asked.reply;
|
|
350
|
+
if (!asked.granted) return { ok: false, reason: 'consent-declined', message: 'The talent said no: the file was not read. Do not read it; ask them to attach their CV in the chat or tell you what it says.' };
|
|
332
351
|
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
333
|
-
const result = read(
|
|
352
|
+
const result = read(file, { talentName: args.talentName });
|
|
334
353
|
if (!result.ok) return { ...result, message: 'Could not read the CV text here. Ask the talent to attach the CV to the chat, or to tell you what it says.' };
|
|
335
354
|
// Someone else's CV (a candidate they downloaded): never hand its contents over.
|
|
336
355
|
if (result.mentionsTalent === false) return { ok: true, mentionsTalent: false, note: "This CV does not name the talent: it belongs to someone else. Do not use, quote or describe it; try the next candidate." };
|
|
@@ -340,27 +359,33 @@ function makeRegisterTools(deps = {}) {
|
|
|
340
359
|
return [
|
|
341
360
|
{
|
|
342
361
|
name: 'suggest_register_context',
|
|
343
|
-
|
|
344
|
-
|
|
362
|
+
title: 'Look for your CV on this computer',
|
|
363
|
+
description: 'Asks you first, then lists the names of PDF and Word files in your Desktop, Documents, Downloads and iCloud Drive folders that look like a CV and reads your time zone and the name, email and GitHub user saved by your coding tools. It does not open those files or send anything.',
|
|
364
|
+
guidance: "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 (null when their name is unknown) 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. The tool asks the talent first (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is searched until they answer yes. After a `say`, call it again with answer set to the option they pick. Never answer for them.",
|
|
365
|
+
inputSchema: { type: 'object', properties: { talentName: { type: 'string', description: "The talent's full name as you know it (memory, chat); improves CV matching." }, answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' } } },
|
|
345
366
|
handler: suggestContext,
|
|
346
367
|
},
|
|
347
368
|
{
|
|
348
369
|
name: 'read_cv',
|
|
349
|
-
|
|
370
|
+
title: 'Read your CV',
|
|
371
|
+
description: 'Asks you first, then reads the text of the CV file you allow to prepare your profile draft. This step sends nothing to Shakers.',
|
|
372
|
+
guidance: "Read the TEXT of a CV file (PDF, DOC, DOCX) on this machine, with talentName set. The tool asks the talent first, naming the file (a dialog when the client has one, otherwise it returns `say` to print word for word as your whole reply); nothing is read until they answer yes. After a `say`, call it again with the same path and answer set to the option they pick. Never answer for them. 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.",
|
|
350
373
|
inputSchema: {
|
|
351
374
|
type: 'object',
|
|
352
375
|
properties: {
|
|
353
376
|
path: { type: 'string', description: 'Path to the CV file, exactly as suggest_register_context gave it (the short ~/ form is fine) or absolute.' },
|
|
354
|
-
|
|
377
|
+
answer: { type: 'string', description: 'The option the talent picked when you printed the question this tool returned. Never set it yourself.' },
|
|
355
378
|
talentName: { type: 'string', description: "The talent's full name as you know it; used to tell their CV from other people's." },
|
|
356
379
|
},
|
|
357
|
-
required: ['path'
|
|
380
|
+
required: ['path'],
|
|
358
381
|
},
|
|
359
382
|
handler: readCv,
|
|
360
383
|
},
|
|
361
384
|
{
|
|
362
385
|
name: 'onboarding_interview_start',
|
|
363
|
-
|
|
386
|
+
title: 'Start your onboarding interview',
|
|
387
|
+
description: 'Starts the short Shakers interview here in the chat, after asking you where and how you want to answer it. Your answers go to Shakers to get to know your profile.',
|
|
388
|
+
guidance: "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 `say` to print word for word; default here), then how they want to answer, with the two interview disclaimers in that same question. reason 'interview-later' means they chose the web: skip the interview. reason 'interview-on-web' (a sign-up language the chat interview does not speak) is the same: print relayVerbatim and skip it. 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. When it returns `say`, print it word for word as your whole reply (it holds the disclaimers and the question) and, once they answer, call with where:'here', disclaimerAcknowledged:true and answerMode. 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', print relayVerbatim word for word and call open_web so they do the interview on the web.",
|
|
364
389
|
inputSchema: INTERVIEW_START_SCHEMA,
|
|
365
390
|
handler: interviewStart,
|
|
366
391
|
available: async () => {
|
|
@@ -373,25 +398,33 @@ function makeRegisterTools(deps = {}) {
|
|
|
373
398
|
},
|
|
374
399
|
{
|
|
375
400
|
name: 'onboarding_interview_turn',
|
|
376
|
-
|
|
401
|
+
title: 'Answer an interview question',
|
|
402
|
+
description: 'Sends the answer you gave or approved for the current interview question to Shakers and brings the next one.',
|
|
403
|
+
guidance: "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.",
|
|
377
404
|
inputSchema: INTERVIEW_TURN_SCHEMA,
|
|
378
405
|
handler: interviewTurn,
|
|
379
406
|
},
|
|
380
407
|
{
|
|
381
408
|
name: 'onboarding_interview_complete',
|
|
382
|
-
|
|
409
|
+
title: 'Finish the interview',
|
|
410
|
+
description: 'Closes the interview and saves it on Shakers.',
|
|
411
|
+
guidance: "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.",
|
|
383
412
|
inputSchema: INTERVIEW_COMPLETE_SCHEMA,
|
|
384
413
|
handler: interviewComplete,
|
|
385
414
|
},
|
|
386
415
|
{
|
|
387
416
|
name: 'repeat_onboarding_interview',
|
|
388
|
-
|
|
417
|
+
title: 'Repeat your onboarding interview',
|
|
418
|
+
description: 'Deletes your previous onboarding interview on Shakers so you can take it again. This cannot be undone.',
|
|
419
|
+
guidance: "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'.",
|
|
389
420
|
inputSchema: { type: 'object', properties: {} },
|
|
390
421
|
handler: repeatOnboarding,
|
|
391
422
|
},
|
|
392
423
|
{
|
|
393
424
|
name: 'onboarding_finish',
|
|
394
|
-
|
|
425
|
+
title: 'Complete your onboarding',
|
|
426
|
+
description: 'Marks your onboarding as completed on Shakers after you repeat the interview.',
|
|
427
|
+
guidance: "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 }.",
|
|
395
428
|
inputSchema: FINISH_SCHEMA,
|
|
396
429
|
handler: onboardingFinish,
|
|
397
430
|
},
|
package/src/mcp-report-tools.js
CHANGED
|
@@ -33,7 +33,7 @@ function makeReportTools(deps = {}) {
|
|
|
33
33
|
const requireSession = () => {
|
|
34
34
|
const session = loadAuthSession();
|
|
35
35
|
if (sessionStatus(session) !== 'active') {
|
|
36
|
-
throw new Error('no active Shakers session —
|
|
36
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
|
|
37
37
|
}
|
|
38
38
|
return session;
|
|
39
39
|
};
|
|
@@ -85,13 +85,17 @@ function makeReportTools(deps = {}) {
|
|
|
85
85
|
return [
|
|
86
86
|
{
|
|
87
87
|
name: 'report',
|
|
88
|
-
|
|
88
|
+
title: 'Show your AI report',
|
|
89
|
+
description: 'Shows your AI report, either the one saved on Shakers or one built from your last AI analysis on this computer, which it also saves there as a file.',
|
|
90
|
+
guidance: "Two modes. stored:true returns the talent's ENTIRE stored server report (inventory + activity + AI-fluency level + prompting/interaction level) as plain text, read from certs WITHOUT re-running any scan — says so cleanly when none is stored (this stored view does NOT include the Setup×Usage matrix or the vision/how-I-work narratives). Otherwise builds the local report for this repo from local state: it returns the full human-readable report in `text` (Markdown mirroring the CLI terminal — Setup level and rank rationale, tools, MCP servers, agents, activity, plus the Setup×Usage matrix and vision/how-I-work when available) AND the HTML file path (no browser). Render `text` verbatim to the talent; keep the fenced blocks fenced so the tables stay aligned. Requires an active session. Any internal ids/codes are handles for tools only — never show or mention them to the talent.",
|
|
89
91
|
inputSchema: REPORT_SCHEMA,
|
|
90
92
|
handler: report,
|
|
91
93
|
},
|
|
92
94
|
{
|
|
93
95
|
name: 'share',
|
|
94
|
-
|
|
96
|
+
title: 'Create your share card',
|
|
97
|
+
description: 'Creates on this computer a card with your AI report to share on LinkedIn. It sends nothing.',
|
|
98
|
+
guidance: "Generate the Shakers-branded LinkedIn share card for this repo and return its file path. Does not open a browser. Requires an active session.",
|
|
95
99
|
inputSchema: SHARE_SCHEMA,
|
|
96
100
|
handler: share,
|
|
97
101
|
},
|
package/src/mcp-role-tools.js
CHANGED
|
@@ -109,12 +109,13 @@ function makeRoleTools(deps = {}) {
|
|
|
109
109
|
countOpenPositions = (opts) => require('./find-projects-client').countOpenPositions({}, opts),
|
|
110
110
|
sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
111
111
|
now = () => Date.now(),
|
|
112
|
+
funnel = require('./onboarding-funnel').NOOP_FUNNEL,
|
|
112
113
|
} = deps;
|
|
113
114
|
|
|
114
115
|
const requireSession = () => {
|
|
115
116
|
const session = loadAuthSession();
|
|
116
117
|
if (sessionStatus(session) !== 'active') {
|
|
117
|
-
throw new Error('no active Shakers session —
|
|
118
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first.');
|
|
118
119
|
}
|
|
119
120
|
return session;
|
|
120
121
|
};
|
|
@@ -282,10 +283,18 @@ function makeRoleTools(deps = {}) {
|
|
|
282
283
|
// This call's own list: another call may replace `offered` while the dialog is open.
|
|
283
284
|
const options = shown.map((m) => m.role);
|
|
284
285
|
offered = options;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
286
|
+
// Same block as the recommended roles: intro, the best match marked, its reason, its open projects, then what the main profile is.
|
|
287
|
+
const fits = shown.filter((m) => m.byRole.length || m.byStack.length);
|
|
288
|
+
const session = loadAuthSession();
|
|
289
|
+
const counts = fits.length ? await openCounts(session && session.hubAccessToken, fits.map((m) => m.role)) : [];
|
|
290
|
+
const blocks = fits.map((m, i) => [
|
|
291
|
+
i === 0 ? c.roleRecommended(m.role.name) : c.roleName(m.role.name),
|
|
292
|
+
m.byRole.length ? c.catalogueReason.role(m.byRole.join(', ')) : null,
|
|
293
|
+
m.byStack.length ? c.catalogueReason.stack(m.byStack.join(', ')) : null,
|
|
294
|
+
typeof counts[i] === 'number' && counts[i] > 0 ? c.openProjects(counts[i]) : null,
|
|
295
|
+
].filter(Boolean).join('\n'));
|
|
296
|
+
const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
|
|
297
|
+
const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
|
|
289
298
|
if (asked.answer === c.mainRoleNotListed) return askOwnRole();
|
|
290
299
|
const role = asked.answer && options.find((r) => r.name === asked.answer);
|
|
291
300
|
if (role) {
|
|
@@ -293,7 +302,7 @@ function makeRoleTools(deps = {}) {
|
|
|
293
302
|
return { role, add: true };
|
|
294
303
|
}
|
|
295
304
|
offered = options;
|
|
296
|
-
return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, [...options.map((r) => r.name), c.mainRoleNotListed]) }, options, `Pass catalogue:true too: it adds the role first. If they pick the last option, call set_main_role with catalogue:true and answer = that option.`) };
|
|
305
|
+
return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, [...options.map((r) => r.name), c.mainRoleNotListed], texts) }, options, `Pass catalogue:true too: it adds the role first. If they pick the last option, call set_main_role with catalogue:true and answer = that option.`) };
|
|
297
306
|
}
|
|
298
307
|
|
|
299
308
|
async function setMainRole(args = {}, ctx = {}) {
|
|
@@ -309,37 +318,51 @@ function makeRoleTools(deps = {}) {
|
|
|
309
318
|
const added = await addRole({ clusterId: picked.role.clusterId });
|
|
310
319
|
if (!added.ok) return added;
|
|
311
320
|
}
|
|
312
|
-
const set = await
|
|
321
|
+
const set = await applyMainRole(session, picked.role.clusterId, { suggestion: !picked.add });
|
|
313
322
|
return set.ok ? { ...set, name: picked.role.name } : set;
|
|
314
323
|
}
|
|
315
|
-
|
|
324
|
+
return applyMainRole(session, args.clusterId, { suggestion: args.catalogue === true ? false : undefined });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function applyMainRole(session, clusterId, { suggestion } = {}) {
|
|
328
|
+
const res = await requestSetMainRole({ clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
|
|
316
329
|
if (res.ok === false && res.reason === 'http-400') return { ok: false, reason: res.reason, message: 'The talent does not hold that role yet: call add_role with it, then set_main_role again.' };
|
|
317
330
|
if (!res.ok) return { ok: false, reason: res.reason };
|
|
318
|
-
|
|
331
|
+
funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
|
|
332
|
+
funnel.flush();
|
|
333
|
+
return { ok: true, clusterId };
|
|
319
334
|
}
|
|
320
335
|
|
|
321
336
|
return [
|
|
322
337
|
{
|
|
323
338
|
name: 'list_available_roles',
|
|
324
|
-
|
|
339
|
+
title: 'Roles you can add',
|
|
340
|
+
description: 'Shows the Shakers roles you can add to your profile.',
|
|
341
|
+
guidance: "List the roles the talent can add to their profile (the cluster catalog minus roles they already hold). Each row has clusterId (internal handle), name, category and an optional proposalKind (why it is suggested). Present roles to the talent by NAME; never show clusterId. Requires an active session.",
|
|
325
342
|
inputSchema: EMPTY_SCHEMA,
|
|
326
343
|
handler: listAvailableRoles,
|
|
327
344
|
},
|
|
328
345
|
{
|
|
329
346
|
name: 'add_role',
|
|
330
|
-
|
|
347
|
+
title: 'Add a role',
|
|
348
|
+
description: 'Adds the role you pick to your Shakers profile.',
|
|
349
|
+
guidance: "Add a role to the talent's profile (a GROWTH role) by clusterId from list_available_roles. Requires an active session. Returns { clusterId, alreadyAssigned? }.",
|
|
331
350
|
inputSchema: CLUSTER_SCHEMA,
|
|
332
351
|
handler: addRole,
|
|
333
352
|
},
|
|
334
353
|
{
|
|
335
354
|
name: 'list_my_roles',
|
|
336
|
-
|
|
355
|
+
title: 'Your roles',
|
|
356
|
+
description: 'Shows your roles on Shakers and the ones suggested by your interview and your CV.',
|
|
357
|
+
guidance: "List the roles the talent already holds, plus mainClusterId (their current main role) and `recommended`: the roles the onboarding interview and the CV import suggest, interview first. Use it before set_main_role. In the sign-up pass waitSeconds (20): it waits for the interview evaluation to recommend roles and says when to call again (`pending`) or to fall back to list_available_roles. Follow `message`. clusterId is an internal handle — refer to roles by name to the talent. Requires an active session.",
|
|
337
358
|
inputSchema: LIST_MY_ROLES_SCHEMA,
|
|
338
359
|
handler: listMyRoles,
|
|
339
360
|
},
|
|
340
361
|
{
|
|
341
362
|
name: 'set_main_role',
|
|
342
|
-
|
|
363
|
+
title: 'Choose your main role',
|
|
364
|
+
description: 'Asks you which role is your main one and saves it on your Shakers profile.',
|
|
365
|
+
guidance: "Set the talent's MAIN role. In the sign-up it is the last step before open_web: after onboarding_interview_complete (or when the talent skipped the interview) and once list_my_roles is no longer pending, call it without clusterId: it asks the talent which of the recommended roles is their main one, with the catalogue as the last option (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns `say` to print word for word and the roles, to call it again with the clusterId they pick. Then it sets that role (adding a catalogue role first). Also usable standalone with clusterId to change the main role later; catalogue:true adds a catalogue role first. Requires an active session.",
|
|
343
366
|
inputSchema: SET_MAIN_ROLE_SCHEMA,
|
|
344
367
|
handler: setMainRole,
|
|
345
368
|
},
|
|
@@ -19,7 +19,7 @@ function makeSavedPositionsTools(deps = {}) {
|
|
|
19
19
|
const requireLoginSession = () => {
|
|
20
20
|
const session = loadAuthSession();
|
|
21
21
|
if (sessionStatus(session) !== 'active') {
|
|
22
|
-
throw new Error('no active Shakers session —
|
|
22
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (this tool is session-gated).');
|
|
23
23
|
}
|
|
24
24
|
return session;
|
|
25
25
|
};
|
|
@@ -36,13 +36,17 @@ function makeSavedPositionsTools(deps = {}) {
|
|
|
36
36
|
return [
|
|
37
37
|
{
|
|
38
38
|
name: 'save_project',
|
|
39
|
-
|
|
39
|
+
title: 'Save a project',
|
|
40
|
+
description: 'Saves a project to your list on Shakers.',
|
|
41
|
+
guidance: 'Bookmark a project/Position for the talent (Find Positions "Saved"). Idempotent — saving twice is a no-op. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
|
|
40
42
|
inputSchema: ID_SCHEMA,
|
|
41
43
|
handler: toggle(savePosition),
|
|
42
44
|
},
|
|
43
45
|
{
|
|
44
46
|
name: 'unsave_project',
|
|
45
|
-
|
|
47
|
+
title: 'Remove a saved project',
|
|
48
|
+
description: 'Removes a project from your saved list on Shakers.',
|
|
49
|
+
guidance: 'Remove a project/Position bookmark for the talent. Idempotent — un-saving something not saved is not an error. Requires an active session and the position id. The position id is an INTERNAL handle for calling tools — never show or mention it to the talent.',
|
|
46
50
|
inputSchema: ID_SCHEMA,
|
|
47
51
|
handler: toggle(unsavePosition),
|
|
48
52
|
},
|
package/src/mcp-server.js
CHANGED
|
@@ -34,7 +34,7 @@ function errorResponse(id, code, message, data) {
|
|
|
34
34
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
|
|
37
|
+
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
|
|
38
38
|
const registry = new Map();
|
|
39
39
|
for (const t of tools) {
|
|
40
40
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -86,10 +86,14 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
//
|
|
89
|
+
// The spec (2025-06-18) advertises elicitation support as a presence flag: `capabilities.elicitation: {}`.
|
|
90
|
+
// So any advertised elicitation can ask a closed question — except an explicit url-only object
|
|
91
|
+
// (`{ url }` without `{ form }`), which only opens links and falls back to the chat.
|
|
90
92
|
function canElicitForm() {
|
|
91
93
|
const e = clientCapabilities.elicitation;
|
|
92
|
-
|
|
94
|
+
if (!e) return false;
|
|
95
|
+
if (typeof e === 'object' && e.url !== undefined && e.form === undefined) return false;
|
|
96
|
+
return true;
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
// MCP elicitation/create: the client's { action, content }, or null when it has no dialog or the exchange failed.
|
|
@@ -103,11 +107,14 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
// `description` and `title` are what the talent reads in the client; `guidance` is how the model must use the tool, so it rides in the schema.
|
|
106
111
|
function shapeTool(t) {
|
|
112
|
+
const schema = t.inputSchema || { type: 'object' };
|
|
107
113
|
return {
|
|
108
114
|
name: t.name,
|
|
115
|
+
...(t.title ? { title: t.title } : {}),
|
|
109
116
|
description: t.description || '',
|
|
110
|
-
inputSchema: t.
|
|
117
|
+
inputSchema: t.guidance ? { ...schema, description: [t.guidance, schema.description].filter(Boolean).join(' ') } : schema,
|
|
111
118
|
};
|
|
112
119
|
}
|
|
113
120
|
|
|
@@ -201,6 +208,12 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
201
208
|
case 'initialize': {
|
|
202
209
|
const requested = msg.params && msg.params.protocolVersion;
|
|
203
210
|
clientCapabilities = (msg.params && msg.params.capabilities) || {};
|
|
211
|
+
if (typeof onInitialize === 'function') {
|
|
212
|
+
try { onInitialize({ clientInfo: (msg.params && msg.params.clientInfo) || {} }); } catch { /* never breaks the handshake */ }
|
|
213
|
+
}
|
|
214
|
+
// One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
|
|
215
|
+
// declared elicitation shape and the resulting decision are visible in the MCP log.
|
|
216
|
+
process.stderr.write(` [mcp] initialize elicitation=${JSON.stringify(clientCapabilities.elicitation)} -> elicit=${canElicitForm()}\n`);
|
|
204
217
|
const text = typeof instructions === 'function' ? instructions({ elicitation: canElicitForm() }) : instructions;
|
|
205
218
|
return resultResponse(id, {
|
|
206
219
|
protocolVersion: requested || advertisedVersion,
|
|
@@ -20,7 +20,7 @@ function makeShowProjectTools(deps = {}) {
|
|
|
20
20
|
const requireLoginSession = () => {
|
|
21
21
|
const session = loadAuthSession();
|
|
22
22
|
if (sessionStatus(session) !== 'active') {
|
|
23
|
-
throw new Error('no active Shakers session —
|
|
23
|
+
throw new Error('no active Shakers session — sign in with social_signin_start (or run `shakers login`) first (show_project is session-gated).');
|
|
24
24
|
}
|
|
25
25
|
return session;
|
|
26
26
|
};
|
|
@@ -37,7 +37,9 @@ function makeShowProjectTools(deps = {}) {
|
|
|
37
37
|
return [
|
|
38
38
|
{
|
|
39
39
|
name: 'show_project',
|
|
40
|
-
|
|
40
|
+
title: 'See a project',
|
|
41
|
+
description: 'Shows the full details of one Shakers project.',
|
|
42
|
+
guidance: "Get the full detail of one Position/Project available to the talent (Shakers Find Positions detail): title, parent project, company, budget, work mode, required skills/languages, description, goals, FAQs, and the talent's saved/applied/canApply state. Requires an active session and the position id. The `id` (and any positionId/projectId in the result) is an INTERNAL handle for calling tools — NEVER show or mention it to the talent; refer to the project by its title/company.",
|
|
41
43
|
inputSchema: SHOW_PROJECT_SCHEMA,
|
|
42
44
|
handler: showProject,
|
|
43
45
|
},
|