shakers-cli 0.16.0 → 0.18.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 +21 -6
- package/bin/ai-usage.js +31 -12
- package/bin/command-audit-worker.js +19 -0
- package/bin/login.js +4 -8
- package/bin/mcp.js +32 -21
- package/bin/register.js +12 -30
- package/bin/shakers-cli.js +15 -10
- package/package.json +1 -1
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/agents-client.js +1 -0
- package/src/ai-usage-review.js +103 -0
- package/src/auth-client.js +7 -107
- package/src/auth-session-store.js +12 -11
- package/src/backend-request.js +3 -24
- package/src/cli-skill.js +77 -0
- package/src/command-audit.js +47 -4
- package/src/config.js +2 -16
- package/src/consent-flow.js +9 -3
- package/src/detect-repos.js +4 -8
- package/src/detectors.js +23 -0
- package/src/device-auth-store.js +2 -2
- package/src/device-login.js +11 -9
- package/src/env-paths.js +9 -1
- package/src/i18n.js +186 -102
- package/src/mcp-ai-usage-result-tool.js +38 -14
- package/src/mcp-ai-usage-tool.js +317 -172
- package/src/mcp-choice.js +171 -34
- package/src/mcp-detector.js +26 -2
- package/src/mcp-device-login-tools.js +40 -8
- package/src/mcp-install.js +292 -53
- package/src/mcp-instructions.js +43 -17
- package/src/mcp-onboarding-tools.js +1 -3
- package/src/mcp-prompts.js +8 -6
- package/src/mcp-register-tools.js +25 -14
- package/src/mcp-role-tools.js +59 -18
- package/src/mcp-server-name.js +7 -0
- package/src/mcp-server.js +35 -7
- package/src/mcp-signup-client.js +3 -6
- package/src/mcp-signup-flow.js +389 -0
- package/src/mcp-signup-tools.js +250 -133
- package/src/onboarding-flow.js +72 -146
- package/src/onboarding-funnel.js +46 -6
- package/src/opencode-sessions.js +92 -0
- package/src/profile-agents.js +26 -0
- package/src/render-report-text.js +1 -1
- package/src/report-gating.js +4 -2
- package/src/report-store.js +7 -2
- package/src/roles-flow.js +8 -5
- package/src/saved-positions-flow.js +3 -3
- package/src/scanner.js +104 -12
- package/src/session-gate.js +1 -4
- package/src/session-normalizers.js +114 -8
- package/src/session-refresh.js +5 -16
- package/src/session-scan.js +167 -22
- package/src/share-consent-state.js +2 -2
- package/src/share-payload.js +4 -1
- package/src/share.js +2 -2
- package/src/signup-client.js +12 -39
- package/src/signup-copy.js +545 -58
- package/src/skill-install.js +102 -0
- package/src/stdin-ask.js +13 -1
- package/src/tier-engine.js +14 -2
- package/src/trust-narration.js +32 -0
- package/src/usage-run-steps.js +68 -6
- package/src/usage-scan.js +11 -8
- package/src/brand-svg.js +0 -9
- package/src/inter-fonts.js +0 -6
- package/src/mcp-auth-form.js +0 -480
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const { TranscriptBuffer } = require('./interview-transcript');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { askChoice, askConsent } = require('./mcp-choice');
|
|
5
|
+
const { askChoice, askConsent, forChat, handOver, settle, withNextSay } = require('./mcp-choice');
|
|
6
6
|
const { expandHome, tildify } = require('./env-paths');
|
|
7
7
|
|
|
8
8
|
const ANSWER_MODES = ['drafted', 'own', 'full'];
|
|
9
|
+
const WHERE_QUESTION = 'interview:where';
|
|
9
10
|
|
|
10
11
|
const INTERVIEW_START_SCHEMA = {
|
|
11
12
|
type: 'object',
|
|
@@ -123,10 +124,14 @@ function makeRegisterTools(deps = {}) {
|
|
|
123
124
|
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.' };
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// The talent's answer to the interview disclaimers counts only after they went to the chat with the answer-mode question.
|
|
128
|
+
let disclaimersShown = false;
|
|
129
|
+
|
|
126
130
|
async function askWhere(args, ctx) {
|
|
131
|
+
const c = copy();
|
|
132
|
+
if (args.where === 'here' || args.where === 'later') settle(WHERE_QUESTION, args.where === 'here' ? c.interviewHere : c.interviewLater);
|
|
127
133
|
if (args.where === 'here') return { here: true };
|
|
128
134
|
if (args.where === 'later') return { here: false };
|
|
129
|
-
const c = copy();
|
|
130
135
|
const asked = await askChoice(ctx, { question: c.interviewWhereQuestion, options: [c.interviewHere, c.interviewLater] });
|
|
131
136
|
if (asked.answer) return { here: asked.answer === c.interviewHere };
|
|
132
137
|
return { chat: asked.chat, dismissed: asked.dismissed };
|
|
@@ -160,30 +165,35 @@ function makeRegisterTools(deps = {}) {
|
|
|
160
165
|
if (!CHAT_INTERVIEW_LANGUAGES.has(textLang())) {
|
|
161
166
|
return { ok: false, reason: 'interview-on-web', relayVerbatim: copy().interviewOnWeb, message: 'Print relayVerbatim word for word now. The interview is not offered here in this language: skip it, go on with the main role, then open_web.' };
|
|
162
167
|
}
|
|
163
|
-
const acknowledged = args.disclaimerAcknowledged === true;
|
|
168
|
+
const acknowledged = args.disclaimerAcknowledged === true && disclaimersShown;
|
|
169
|
+
// One acknowledgement per interview: the next one shows its disclaimers again.
|
|
170
|
+
if (acknowledged) disclaimersShown = false;
|
|
164
171
|
if (!acknowledged) {
|
|
165
172
|
track(() => funnel.viewed('pre_interview'));
|
|
166
173
|
const where = await askWhere(args, ctx);
|
|
167
174
|
if (where.here === false) return interviewLater();
|
|
168
175
|
if (where.chat) {
|
|
169
|
-
|
|
176
|
+
// "Here" leads to the answer-mode question, whose disclaimers go to the model now.
|
|
177
|
+
const disclaimers = handOver(WHERE_QUESTION, { step: 'interview', ifAnswer: [copy().interviewHere], texts: Object.values(flow.interviewDisclaimers(textLang())) });
|
|
178
|
+
return withNextSay({
|
|
170
179
|
ok: false,
|
|
171
180
|
reason: 'where-required',
|
|
172
181
|
...(where.dismissed ? { dismissed: where.dismissed } : {}),
|
|
173
|
-
...where.chat,
|
|
182
|
+
...forChat(where.chat),
|
|
174
183
|
message: `${where.chat.choiceMessage} Then call onboarding_interview_start again with where:"here" for the first option or where:"later" for the second.`,
|
|
175
|
-
};
|
|
184
|
+
}, disclaimers);
|
|
176
185
|
}
|
|
177
186
|
}
|
|
178
187
|
// The two disclaimers travel with the answer-mode question, so they are read before the talent answers it.
|
|
179
188
|
const disclaimers = acknowledged ? [] : Object.values(flow.interviewDisclaimers(textLang()));
|
|
180
189
|
const answer = await askAnswerMode(acknowledged ? args : {}, ctx, disclaimers);
|
|
181
190
|
if (answer.chat) {
|
|
191
|
+
if (disclaimers.length) disclaimersShown = true;
|
|
182
192
|
return {
|
|
183
193
|
ok: false,
|
|
184
194
|
reason: 'answer-mode-required',
|
|
185
195
|
...(answer.dismissed ? { dismissed: answer.dismissed } : {}),
|
|
186
|
-
...answer.chat,
|
|
196
|
+
...forChat(answer.chat),
|
|
187
197
|
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.`,
|
|
188
198
|
};
|
|
189
199
|
}
|
|
@@ -283,7 +293,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
283
293
|
return { ok: false, reason: 'unknown-interview', message: 'No live interview for that interviewId (the server may have restarted). Nothing to complete; start again if the talent still wants the interview.' };
|
|
284
294
|
}
|
|
285
295
|
const saved = await teardownInterview(args.interviewId, { persist: true });
|
|
286
|
-
if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed' };
|
|
296
|
+
if (!saved.ok) return { ok: false, reason: saved.reason || 'complete-failed', message: 'The interview could not be saved: it stays pending and the talent can take it later on the web. Do not retry it; go on with the main role (list_my_roles, then set_main_role).' };
|
|
287
297
|
lastCompletedInterview = { interviewId: args.interviewId, completedAt: Date.now() };
|
|
288
298
|
track(() => funnel.completed('interview', { interview_mode: 'text' }));
|
|
289
299
|
return { ok: true, state: 'taken', message: catalog.interviewComplete };
|
|
@@ -325,7 +335,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
325
335
|
// Content-free context the model may PROPOSE to the talent (never auto-write), searched only after the talent says yes.
|
|
326
336
|
async function suggestContext(args = {}, ctx = {}) {
|
|
327
337
|
const c = copy().consent;
|
|
328
|
-
const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.
|
|
338
|
+
const asked = await askConsent(ctx, { tool: 'suggest_register_context', key: 'suggest_register_context', question: c.cvSearch, yes: c.cvSearchYes, no: c.no, answer: args.answer });
|
|
329
339
|
if (asked.reply) return asked.reply;
|
|
330
340
|
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.' };
|
|
331
341
|
const read = deps.readRegisterContextSuggestions || require('./register-context').readRegisterContextSuggestions;
|
|
@@ -346,11 +356,12 @@ function makeRegisterTools(deps = {}) {
|
|
|
346
356
|
if (typeof args.path !== 'string' || !args.path.trim()) return { ok: false, reason: 'cv-not-found', message: 'Pass the path of the CV file.' };
|
|
347
357
|
const file = path.resolve(expandHome(args.path.trim()));
|
|
348
358
|
const c = copy().consent;
|
|
349
|
-
const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.
|
|
359
|
+
const asked = await askConsent(ctx, { tool: 'read_cv', key: `read_cv:${file}`, question: c.cvRead(tildify(file)), yes: c.cvReadYes(path.basename(file)), no: c.no, answer: args.answer });
|
|
350
360
|
if (asked.reply) return asked.reply;
|
|
351
361
|
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.' };
|
|
352
362
|
const read = deps.readCvText || require('./cv-text').readCvText;
|
|
353
363
|
const result = read(file, { talentName: args.talentName });
|
|
364
|
+
if (!result.ok && result.reason === 'not-found') return { ...result, message: 'That file is not on this computer. If the talent attached their CV to this chat, use the text you see there: in the sign-up pass it as cvText to signup_start, with the answer they gave. Never run commands, code or other tools to read an attached file.' };
|
|
354
365
|
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.' };
|
|
355
366
|
// Someone else's CV (a candidate they downloaded): never hand its contents over.
|
|
356
367
|
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." };
|
|
@@ -362,7 +373,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
362
373
|
name: 'suggest_register_context',
|
|
363
374
|
title: 'Look for your CV on this computer',
|
|
364
375
|
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.',
|
|
365
|
-
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
|
|
376
|
+
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 the question to ask as its message says); nothing is searched until they answer yes. After asking it in the chat, call it again with answer set to the option they pick. Never answer for them.",
|
|
366
377
|
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.' } } },
|
|
367
378
|
handler: suggestContext,
|
|
368
379
|
},
|
|
@@ -370,7 +381,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
370
381
|
name: 'read_cv',
|
|
371
382
|
title: 'Read your CV',
|
|
372
383
|
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.',
|
|
373
|
-
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
|
|
384
|
+
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 the question to ask as its message says); nothing is read until they answer yes. After asking it in the chat, 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.",
|
|
374
385
|
inputSchema: {
|
|
375
386
|
type: 'object',
|
|
376
387
|
properties: {
|
|
@@ -386,7 +397,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
386
397
|
name: 'onboarding_interview_start',
|
|
387
398
|
title: 'Start your onboarding interview',
|
|
388
399
|
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.',
|
|
389
|
-
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
|
|
400
|
+
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 the question to ask as its message says; 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 a question, ask it as its message says (its `say` holds the disclaimers) 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.",
|
|
390
401
|
inputSchema: INTERVIEW_START_SCHEMA,
|
|
391
402
|
handler: interviewStart,
|
|
392
403
|
available: async () => {
|
|
@@ -409,7 +420,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
409
420
|
name: 'onboarding_interview_complete',
|
|
410
421
|
title: 'Finish the interview',
|
|
411
422
|
description: 'Closes the interview and saves it on Shakers.',
|
|
412
|
-
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 (
|
|
423
|
+
guidance: "Close the onboarding interview once the turns report ended:true, or earlier when the talent asks to end it: 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 (signup step \"roles\", then signup step \"set_main_role\") and finish with signup step \"open_web\"; do NOT call onboarding_finish there.",
|
|
413
424
|
inputSchema: INTERVIEW_COMPLETE_SCHEMA,
|
|
414
425
|
handler: interviewComplete,
|
|
415
426
|
},
|
package/src/mcp-role-tools.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// MCP role tools: list, add and recommend roles, and set the main role (the sign-up's last step before open_web).
|
|
4
4
|
|
|
5
|
-
const { askChoice, askAfterTexts, chatChoice, optionFor,
|
|
5
|
+
const { askChoice, askAfterTexts, chatChoice, forChat, optionFor, OPEN_MESSAGE } = require('./mcp-choice');
|
|
6
6
|
const { signupCopy, renderRole } = require('./signup-copy');
|
|
7
7
|
const { signupLanguage } = require('./signup-language');
|
|
8
8
|
|
|
@@ -17,8 +17,10 @@ const MAX_WAIT_SECONDS = 45;
|
|
|
17
17
|
const PENDING_FINALIZATION = new Set(['COLLECTING', 'PROCESSING']);
|
|
18
18
|
// Alma's signal from the interview first, then the CV import, as the web stacks them.
|
|
19
19
|
const SOURCE_ORDER = { ONBOARDING_INTERVIEW: 0, IMPORT: 1 };
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// The chat's question tool takes two to four options: at most four roles are offered and the talent types any other one;
|
|
21
|
+
// a single role gets the "other role" button as its second option.
|
|
22
|
+
const MAX_ROLE_OPTIONS = 4;
|
|
23
|
+
const withOther = (names, other) => (names.length === 1 ? [...names, other] : names);
|
|
22
24
|
|
|
23
25
|
const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
24
26
|
|
|
@@ -77,7 +79,7 @@ const SET_MAIN_ROLE_SCHEMA = {
|
|
|
77
79
|
description: 'In the sign-up, for each recommended role (by clusterId from list_my_roles): ONE short concrete fact from the CV, the interview or the AI-usage analysis that shows why it fits (e.g. "6 years with Spark and Airflow"), in the talent\'s language. It is shown under the role.',
|
|
78
80
|
},
|
|
79
81
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
80
|
-
clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns)
|
|
82
|
+
clusterId: { type: 'string', description: 'The role the talent already picked, by clusterId from list_my_roles or from the roles this tool returned. Omit it in the sign-up: the tool asks the talent which recommended role is their main one (a dialog, or options it returns); a role they type instead is looked up in the catalogue.' },
|
|
81
83
|
catalogue: { type: 'boolean', description: 'true when the role comes from the catalogue: with clusterId it adds the role before setting it; without clusterId it asks from the catalogue.' },
|
|
82
84
|
},
|
|
83
85
|
};
|
|
@@ -112,7 +114,26 @@ function makeRoleTools(deps = {}) {
|
|
|
112
114
|
sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
113
115
|
now = () => Date.now(),
|
|
114
116
|
funnel = require('./onboarding-funnel').NOOP_FUNNEL,
|
|
117
|
+
inSignup = () => require('./mcp-signup-tools').signupPhase() === 'account',
|
|
118
|
+
completeOnboarding = (session) => require('./onboarding-client').requestCompleteOnboarding(
|
|
119
|
+
{ accessToken: session.accessToken, hubAccessToken: session.hubAccessToken },
|
|
120
|
+
{ endpoint: require('./config').getCompleteOnboardingEndpoint() },
|
|
121
|
+
),
|
|
115
122
|
} = deps;
|
|
123
|
+
let clustersCount;
|
|
124
|
+
let onboardingCompleted = false;
|
|
125
|
+
// The recommended roles were offered in the chat, so an answer that names none of them is a role the talent typed.
|
|
126
|
+
let recommendedAsked = false;
|
|
127
|
+
|
|
128
|
+
async function finishSignup(session) {
|
|
129
|
+
if (onboardingCompleted || !inSignup()) return;
|
|
130
|
+
const res = await completeOnboarding(session).catch(() => null);
|
|
131
|
+
onboardingCompleted = !!(res && res.ok);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function trackSuggestions(roles) {
|
|
135
|
+
funnel.viewed('supercluster', { suggested_superclusters: roles.map((r) => r.clusterId), suggestion_source: 'assigned_clusters', suggestions_count: roles.length });
|
|
136
|
+
}
|
|
116
137
|
|
|
117
138
|
const requireSession = () => {
|
|
118
139
|
const session = loadAuthSession();
|
|
@@ -167,6 +188,7 @@ function makeRoleTools(deps = {}) {
|
|
|
167
188
|
}
|
|
168
189
|
const assigned = await requestAssignedClusters({ hubAccessToken }, { endpoint: getAssignedClustersEndpoint() });
|
|
169
190
|
const clusters = assigned.ok ? assigned.clusters || [] : [];
|
|
191
|
+
if (assigned.ok) clustersCount = clusters.length;
|
|
170
192
|
const recommended = clusters
|
|
171
193
|
.filter((c) => c.type === 'RECOMMENDED')
|
|
172
194
|
.sort((a, b) => (SOURCE_ORDER[a.source] ?? 2) - (SOURCE_ORDER[b.source] ?? 2));
|
|
@@ -193,6 +215,7 @@ function makeRoleTools(deps = {}) {
|
|
|
193
215
|
next = 'set_main_role';
|
|
194
216
|
message = 'Call set_main_role without clusterId: it asks the talent which recommended role is their main one, in a dialog or with options it returns for you to show.';
|
|
195
217
|
} else if (read.mainClusterId) {
|
|
218
|
+
await finishSignup(session);
|
|
196
219
|
next = 'open_web';
|
|
197
220
|
message = 'The talent already has a main role and nothing new was recommended: do not ask it again, go on to open_web. Only if the talent asks to change it, call set_main_role with catalogue:true.';
|
|
198
221
|
} else {
|
|
@@ -207,7 +230,7 @@ function makeRoleTools(deps = {}) {
|
|
|
207
230
|
ok: false,
|
|
208
231
|
reason: 'main-role-choice-required',
|
|
209
232
|
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
210
|
-
...asked.chat,
|
|
233
|
+
...forChat(asked.chat),
|
|
211
234
|
roles: roles.map((r) => ({ clusterId: r.clusterId, name: r.name })),
|
|
212
235
|
message: `${asked.chat.choiceMessage} Then call set_main_role with the clusterId (from roles) of the one they pick. ${extra}`,
|
|
213
236
|
};
|
|
@@ -225,28 +248,42 @@ function makeRoleTools(deps = {}) {
|
|
|
225
248
|
}));
|
|
226
249
|
}
|
|
227
250
|
|
|
228
|
-
// The main-role step: why each recommended
|
|
251
|
+
// The main-role step: why each of the best recommended roles fits and how many projects want it, then the question; any other role the talent types is looked up in the catalogue.
|
|
229
252
|
async function chooseMainRole(session, args, ctx) {
|
|
230
253
|
const read = await readRoles(session.hubAccessToken);
|
|
231
|
-
const recommended = byName(read.recommended);
|
|
254
|
+
const recommended = byName(read.recommended).slice(0, MAX_ROLE_OPTIONS);
|
|
232
255
|
if (!recommended.length && read.mainClusterId) {
|
|
256
|
+
await finishSignup(session);
|
|
233
257
|
return { reply: { ok: true, alreadySet: true, clusterId: read.mainClusterId, message: 'The talent already has a main role and nothing new was recommended: nothing to ask. Go on to open_web; only if they ask to change it, call set_main_role with catalogue:true.' } };
|
|
234
258
|
}
|
|
235
259
|
if (!recommended.length) return chooseFromCatalogue(args, ctx);
|
|
236
260
|
const c = copy();
|
|
237
261
|
const counts = await openCounts(session.hubAccessToken, recommended);
|
|
262
|
+
trackSuggestions(recommended);
|
|
238
263
|
const evidence = args.evidence && typeof args.evidence === 'object' ? args.evidence : {};
|
|
239
|
-
const
|
|
264
|
+
const options = withOther(recommended.map((r) => r.name), c.mainRoleOther);
|
|
265
|
+
// Each option numbered with why it fits; "another role" is its own label.
|
|
266
|
+
const blocks = options.map((name, i) => (recommended[i] ? renderRole(lang(), { name, source: recommended[i].source, evidence: evidence[recommended[i].clusterId], openProjects: counts[i], recommended: i === 0 }) : name));
|
|
267
|
+
const typed = typeof args.answer === 'string' ? args.answer.trim() : '';
|
|
268
|
+
if (recommendedAsked && typed && !optionFor(typed, options)) {
|
|
269
|
+
recommendedAsked = false;
|
|
270
|
+
return chooseFromCatalogue({ answer: typed, skills: args.skills }, ctx);
|
|
271
|
+
}
|
|
240
272
|
const asked = await askAfterTexts(ctx, {
|
|
241
273
|
tool: 'set_main_role',
|
|
242
|
-
texts: [
|
|
274
|
+
texts: [c.rolesIntro],
|
|
275
|
+
listed: { blocks, after: [c.rolesOutro] },
|
|
243
276
|
question: c.mainRoleQuestion,
|
|
244
|
-
options
|
|
277
|
+
options,
|
|
245
278
|
answer: args.answer,
|
|
279
|
+
about: options,
|
|
246
280
|
});
|
|
247
281
|
if (asked.reply) {
|
|
248
|
-
|
|
282
|
+
recommendedAsked = true;
|
|
283
|
+
const other = options.includes(c.mainRoleOther) ? ' If they pick the last option, call set_main_role with catalogue:true and no clusterId.' : '';
|
|
284
|
+
return { reply: { ...asked.reply, roles: recommended.map((r) => ({ clusterId: r.clusterId, name: r.name })), message: `${asked.reply.message} Pass the same evidence again.${other} If they type another role instead of picking one, pass what they typed as answer: the closest catalogue roles are offered.` } };
|
|
249
285
|
}
|
|
286
|
+
recommendedAsked = false;
|
|
250
287
|
if (asked.answer === c.mainRoleOther) return chooseFromCatalogue({}, ctx);
|
|
251
288
|
return { role: recommended.find((r) => r.name === asked.answer) };
|
|
252
289
|
}
|
|
@@ -261,7 +298,7 @@ function makeRoleTools(deps = {}) {
|
|
|
261
298
|
ok: false,
|
|
262
299
|
reason: 'main-role-ask',
|
|
263
300
|
say: copy().mainRoleAsk,
|
|
264
|
-
message: `${
|
|
301
|
+
message: `${OPEN_MESSAGE} Then call set_main_role with catalogue:true and answer = what they say: it offers the closest catalogue roles.`,
|
|
265
302
|
},
|
|
266
303
|
};
|
|
267
304
|
}
|
|
@@ -270,7 +307,7 @@ function makeRoleTools(deps = {}) {
|
|
|
270
307
|
const c = copy();
|
|
271
308
|
const said = typeof args.answer === 'string' ? args.answer.trim() : '';
|
|
272
309
|
if (offered) {
|
|
273
|
-
const picked = optionFor(said,
|
|
310
|
+
const picked = optionFor(said, withOther(offered.map((r) => r.name), c.mainRoleNotListed));
|
|
274
311
|
if (picked === c.mainRoleNotListed) return askOwnRole();
|
|
275
312
|
const role = picked && offered.find((r) => r.name === picked);
|
|
276
313
|
if (role) { offered = null; return { role, add: true }; }
|
|
@@ -296,7 +333,9 @@ function makeRoleTools(deps = {}) {
|
|
|
296
333
|
typeof counts[i] === 'number' && counts[i] > 0 ? c.openProjects(counts[i]) : null,
|
|
297
334
|
].filter(Boolean).join('\n'));
|
|
298
335
|
const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
|
|
299
|
-
|
|
336
|
+
trackSuggestions([]);
|
|
337
|
+
const names = withOther(options.map((r) => r.name), c.mainRoleNotListed);
|
|
338
|
+
const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: names });
|
|
300
339
|
if (asked.answer === c.mainRoleNotListed) return askOwnRole();
|
|
301
340
|
const role = asked.answer && options.find((r) => r.name === asked.answer);
|
|
302
341
|
if (role) {
|
|
@@ -304,7 +343,8 @@ function makeRoleTools(deps = {}) {
|
|
|
304
343
|
return { role, add: true };
|
|
305
344
|
}
|
|
306
345
|
offered = options;
|
|
307
|
-
|
|
346
|
+
const other = names.includes(c.mainRoleNotListed) ? ' If they pick the last option, call set_main_role with catalogue:true and answer = that option.' : '';
|
|
347
|
+
return { reply: roleChoice(asked.chat ? asked : { chat: chatChoice(c.mainRoleQuestion, names, texts) }, options, `Pass catalogue:true too: it adds the role first.${other} If they type another role instead, call set_main_role with catalogue:true and answer = what they typed.`) };
|
|
308
348
|
}
|
|
309
349
|
|
|
310
350
|
async function setMainRole(args = {}, ctx = {}) {
|
|
@@ -330,8 +370,9 @@ function makeRoleTools(deps = {}) {
|
|
|
330
370
|
const res = await requestSetMainRole({ clusterId, hubAccessToken: session.hubAccessToken }, { endpoint: getSetMainRoleEndpoint() });
|
|
331
371
|
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.' };
|
|
332
372
|
if (!res.ok) return { ok: false, reason: res.reason };
|
|
333
|
-
funnel.completed('supercluster', { supercluster: clusterId, was_suggestion_accepted: suggestion });
|
|
373
|
+
funnel.completed('supercluster', { supercluster: clusterId, clusters_count: clustersCount, was_suggestion_accepted: suggestion });
|
|
334
374
|
funnel.flush();
|
|
375
|
+
await finishSignup(session);
|
|
335
376
|
return { ok: true, clusterId };
|
|
336
377
|
}
|
|
337
378
|
|
|
@@ -356,7 +397,7 @@ function makeRoleTools(deps = {}) {
|
|
|
356
397
|
name: 'list_my_roles',
|
|
357
398
|
title: 'Your roles',
|
|
358
399
|
description: 'Shows your roles on Shakers and the ones suggested by your interview and your CV.',
|
|
359
|
-
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 (
|
|
400
|
+
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 (45): 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.",
|
|
360
401
|
inputSchema: LIST_MY_ROLES_SCHEMA,
|
|
361
402
|
handler: listMyRoles,
|
|
362
403
|
},
|
|
@@ -364,7 +405,7 @@ function makeRoleTools(deps = {}) {
|
|
|
364
405
|
name: 'set_main_role',
|
|
365
406
|
title: 'Choose your main role',
|
|
366
407
|
description: 'Asks you which role is your main one and saves it on your Shakers profile.',
|
|
367
|
-
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
|
|
408
|
+
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 (at most four) recommended roles is their main one (or straight from the catalogue when nothing is recommended), in a dialog when the client has one; otherwise it returns the question and the roles, to call it again with the clusterId they pick, or with answer = another role they type, which it looks up in the catalogue. 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.",
|
|
368
409
|
inputSchema: SET_MAIN_ROLE_SCHEMA,
|
|
369
410
|
handler: setMainRole,
|
|
370
411
|
},
|
package/src/mcp-server.js
CHANGED
|
@@ -34,7 +34,28 @@ function errorResponse(id, code, message, data) {
|
|
|
34
34
|
return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Cowork advertises elicitation, drops the request and leaves the call hanging ~180 s (anthropics/claude-ai-mcp#1046);
|
|
38
|
+
// it names itself in clientInfo ('claude-code', version '... (remote_cowork)'). Claude Desktop ('claude-ai') does not
|
|
39
|
+
// advertise it today and is matched so that advertising it later cannot lead to the same hang. Both ask in the chat.
|
|
40
|
+
// Matched on clientInfo only, the one thing the handshake says about the client.
|
|
41
|
+
function dropsElicitation(clientInfo = {}) {
|
|
42
|
+
const name = typeof clientInfo.name === 'string' ? clientInfo.name.toLowerCase() : '';
|
|
43
|
+
const version = typeof clientInfo.version === 'string' ? clientInfo.version.toLowerCase() : '';
|
|
44
|
+
return name === 'claude-ai' || name.includes('cowork') || version.includes('cowork');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The tool's narration opens every result. A reply with `say` (text the talent must read) follows it in the same
|
|
48
|
+
// plain-text block, so the model writes both as one message, and the rest goes as JSON after it: a model writes a
|
|
49
|
+
// readable text before it reasons over a JSON field, and the talent never sees tool results.
|
|
50
|
+
function contentOf(value, narration = null) {
|
|
51
|
+
if (value && typeof value === 'object' && typeof value.say === 'string' && value.say) {
|
|
52
|
+
const { say, ...rest } = value;
|
|
53
|
+
return [{ type: 'text', text: [narration, say].filter(Boolean).join('\n\n') }, { type: 'text', text: JSON.stringify(rest, null, 2) }];
|
|
54
|
+
}
|
|
55
|
+
return [...(narration ? [{ type: 'text', text: narration }] : []), { type: 'text', text: JSON.stringify(value, null, 2) }];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVersion, beforeCall = null, afterCall = null, narrate = null, onInitialize = null, instructions = null, elicitationTimeoutMs = ELICITATION_TIMEOUT_MS } = {}) {
|
|
38
59
|
const registry = new Map();
|
|
39
60
|
for (const t of tools) {
|
|
40
61
|
if (!t || !t.name || typeof t.handler !== 'function') {
|
|
@@ -54,6 +75,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
54
75
|
const advertisedVersion = protocolVersion || DEFAULT_PROTOCOL_VERSION;
|
|
55
76
|
|
|
56
77
|
let clientCapabilities = {};
|
|
78
|
+
let clientInfo = {};
|
|
57
79
|
let send = null;
|
|
58
80
|
let lastRequestId = 0;
|
|
59
81
|
const pending = new Map();
|
|
@@ -91,7 +113,7 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
91
113
|
// (`{ url }` without `{ form }`), which only opens links and falls back to the chat.
|
|
92
114
|
function canElicitForm() {
|
|
93
115
|
const e = clientCapabilities.elicitation;
|
|
94
|
-
if (!e) return false;
|
|
116
|
+
if (!e || dropsElicitation(clientInfo)) return false;
|
|
95
117
|
if (typeof e === 'object' && e.url !== undefined && e.form === undefined) return false;
|
|
96
118
|
return true;
|
|
97
119
|
}
|
|
@@ -174,15 +196,20 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
174
196
|
if (typeof beforeCall === 'function') {
|
|
175
197
|
try { await beforeCall(name); } catch { /* hook must never break a tool call */ }
|
|
176
198
|
}
|
|
199
|
+
const narration = (failed) => {
|
|
200
|
+
const text = typeof narrate === 'function' ? narrate(name, { failed, args }) : null;
|
|
201
|
+
return text ? [{ type: 'text', text }] : [];
|
|
202
|
+
};
|
|
177
203
|
try {
|
|
178
|
-
const
|
|
204
|
+
const asking = narration(false);
|
|
205
|
+
const value = await tool.handler(args, { elicit, elicitation: !!send && canElicitForm(), narration: asking.length ? asking[0].text : null });
|
|
179
206
|
return {
|
|
180
|
-
content:
|
|
207
|
+
content: contentOf(value, asking.length ? asking[0].text : null),
|
|
181
208
|
isError: false,
|
|
182
209
|
};
|
|
183
210
|
} catch (e) {
|
|
184
211
|
return {
|
|
185
|
-
content: [{ type: 'text', text: `${name} tool error: ${e && e.message ? e.message : String(e)}` }],
|
|
212
|
+
content: [...narration(true), { type: 'text', text: `${name} tool error: ${e && e.message ? e.message : String(e)}` }],
|
|
186
213
|
isError: true,
|
|
187
214
|
};
|
|
188
215
|
} finally {
|
|
@@ -213,8 +240,9 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
213
240
|
case 'initialize': {
|
|
214
241
|
const requested = msg.params && msg.params.protocolVersion;
|
|
215
242
|
clientCapabilities = (msg.params && msg.params.capabilities) || {};
|
|
243
|
+
clientInfo = (msg.params && msg.params.clientInfo) || {};
|
|
216
244
|
if (typeof onInitialize === 'function') {
|
|
217
|
-
try { onInitialize({ clientInfo
|
|
245
|
+
try { onInitialize({ clientInfo }); } catch { /* never breaks the handshake */ }
|
|
218
246
|
}
|
|
219
247
|
// One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
|
|
220
248
|
// declared elicitation shape and the resulting decision are visible in the MCP log.
|
|
@@ -292,4 +320,4 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
292
320
|
return { handleMessage, listTools, listAvailableTools, callTool, start };
|
|
293
321
|
}
|
|
294
322
|
|
|
295
|
-
module.exports = { createMcpServer, ERROR, DEFAULT_PROTOCOL_VERSION };
|
|
323
|
+
module.exports = { createMcpServer, dropsElicitation, ERROR, DEFAULT_PROTOCOL_VERSION };
|
package/src/mcp-signup-client.js
CHANGED
|
@@ -96,13 +96,10 @@ async function requestMyImportStatus({ hubAccessToken } = {}, { endpoint, timeou
|
|
|
96
96
|
return { ok: true, ...importStateOf(data) };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
async function requestOneTimeToken({ cookie = null, sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
99
|
+
async function requestOneTimeToken({ sessionToken = null } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
101
100
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
else if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
|
|
105
|
-
else return { ok: false, reason: 'no-session' };
|
|
101
|
+
if (!sessionToken) return { ok: false, reason: 'no-session' };
|
|
102
|
+
const headers = { Authorization: `Bearer ${sessionToken}` };
|
|
106
103
|
let res;
|
|
107
104
|
try {
|
|
108
105
|
res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, headers);
|