shakers-cli-beta 0.12.0 → 0.13.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/package.json +1 -1
- package/src/authorship-mismatch.js +62 -0
- package/src/config.js +10 -1
- package/src/i18n.js +10 -6
- package/src/mcp-auth-form.js +39 -10
- package/src/mcp-choice.js +3 -3
- package/src/mcp-instructions.js +6 -6
- package/src/mcp-prompts.js +1 -1
- package/src/mcp-register-tools.js +1 -1
- package/src/mcp-role-tools.js +13 -5
- package/src/mcp-server.js +9 -2
- package/src/mcp-signup-tools.js +78 -44
- package/src/profile-client.js +11 -0
- package/src/signup-copy.js +29 -25
- package/src/terminal-agents.js +8 -0
- package/src/usage-run-steps.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Display-only authorship-transparency check (talents-ai-score).
|
|
4
|
+
//
|
|
5
|
+
// Warns the talent when their commits in the selected repos are authored under
|
|
6
|
+
// an email that is not their Shakers account email, so their own work would be
|
|
7
|
+
// attributed to nobody. This is a WARNING surface ONLY: it reads each repo's
|
|
8
|
+
// CONFIGURED commit identity (`git config user.email`) and compares counts the
|
|
9
|
+
// attribution path already produced — it NEVER feeds attribution/scoring.
|
|
10
|
+
// git-activity.js owns `authoredCommitCount`; nothing here changes any count.
|
|
11
|
+
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
// The repo's locally configured commit identity. A plain config read, NOT the
|
|
15
|
+
// authorship log parse — it only names who new commits here would be attributed to.
|
|
16
|
+
function readConfiguredEmail(root) {
|
|
17
|
+
try {
|
|
18
|
+
const out = execFileSync('git', ['-C', root, 'config', '--get', 'user.email'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
timeout: 5000,
|
|
21
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
22
|
+
});
|
|
23
|
+
const value = String(out || '').trim();
|
|
24
|
+
return value || null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalize(email) {
|
|
31
|
+
return String(email || '').trim().toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Returns { accountEmail, emails: [configured emails that differ from the account] }
|
|
35
|
+
// when the talent's own commits are plausibly going unattributed, else null.
|
|
36
|
+
//
|
|
37
|
+
// Conservative on purpose (a false warning erodes trust more than a missed one):
|
|
38
|
+
// it fires only when the account email is known, the selection HAS commits, NONE
|
|
39
|
+
// are attributed to the account, and at least one repo is configured under a
|
|
40
|
+
// different email we can name.
|
|
41
|
+
function detectAuthorshipMismatch(toplevels, accountEmail, gitActivity, { readEmail = readConfiguredEmail } = {}) {
|
|
42
|
+
const account = normalize(accountEmail);
|
|
43
|
+
if (!account) return null;
|
|
44
|
+
if (!gitActivity) return null;
|
|
45
|
+
const commits = gitActivity.commitCount || 0;
|
|
46
|
+
const authored = gitActivity.authoredCommitCount || 0;
|
|
47
|
+
if (commits === 0 || authored > 0) return null;
|
|
48
|
+
|
|
49
|
+
const emails = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (const root of Array.isArray(toplevels) ? toplevels : []) {
|
|
52
|
+
const configured = readEmail(root);
|
|
53
|
+
const key = normalize(configured);
|
|
54
|
+
if (!key || key === account || seen.has(key)) continue;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
emails.push(String(configured).trim());
|
|
57
|
+
}
|
|
58
|
+
if (emails.length === 0) return null;
|
|
59
|
+
return { accountEmail: String(accountEmail).trim(), emails };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { detectAuthorshipMismatch, readConfiguredEmail };
|
package/src/config.js
CHANGED
|
@@ -621,10 +621,18 @@ function getTranslationsEndpoint(env = process.env) {
|
|
|
621
621
|
return deriveFromHub(env, 'translations');
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
// The signed-in user as hub sees it (GET only): read back the phone after saving it.
|
|
625
|
+
function getUserMeEndpoint(env = process.env) {
|
|
626
|
+
const explicit = readEnv(env, 'USER_ME_ENDPOINT');
|
|
627
|
+
if (explicit && explicit.trim()) return explicit.trim();
|
|
628
|
+
return deriveFromHub(env, 'works/users/me');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Talent base fields (PATCH name, phone...). works/users/me is GET only.
|
|
624
632
|
function getTalentMeEndpoint(env = process.env) {
|
|
625
633
|
const explicit = readEnv(env, 'TALENT_ME_ENDPOINT');
|
|
626
634
|
if (explicit && explicit.trim()) return explicit.trim();
|
|
627
|
-
return deriveFromHub(env, 'works/
|
|
635
|
+
return deriveFromHub(env, 'works/talent/me');
|
|
628
636
|
}
|
|
629
637
|
|
|
630
638
|
function getOnboardingInterviewsEndpoint(env = process.env) {
|
|
@@ -819,6 +827,7 @@ module.exports = {
|
|
|
819
827
|
getMePortfoliosEndpoint,
|
|
820
828
|
getTranslationsEndpoint,
|
|
821
829
|
getTalentMeEndpoint,
|
|
830
|
+
getUserMeEndpoint,
|
|
822
831
|
getOnboardingInterviewsEndpoint,
|
|
823
832
|
getCompleteOnboardingEndpoint,
|
|
824
833
|
getOnboardingRestartEndpoint,
|
package/src/i18n.js
CHANGED
|
@@ -355,8 +355,10 @@ const catalogs = {
|
|
|
355
355
|
subagents: (n) => `${n} ${n === 1 ? 'run' : 'runs'} de subagente`,
|
|
356
356
|
hours: (h) => `${h} h activas (gap idle > 25 min = pausa)`,
|
|
357
357
|
commits: (authored, total) => `${authored}/${total} commits tuyos`,
|
|
358
|
-
lines: (added, deleted) => `+${added}/-${deleted} líneas`,
|
|
359
|
-
velocity: (v) => `${v} commits/día`,
|
|
358
|
+
lines: (added, deleted) => `+${added}/-${deleted} líneas (del repo)`,
|
|
359
|
+
velocity: (v) => `${v} commits/día (del repo)`,
|
|
360
|
+
authorshipMismatch: (emails, account) =>
|
|
361
|
+
`Tus commits en ${emails.length === 1 ? 'este repo' : 'estos repos'} están a nombre de ${emails.join(', ')}, que no es tu email de Shakers (${account}). Puede que tu trabajo no se te atribuya: configura git con tu email de Shakers (git config user.email "${account}") para que cuente.`,
|
|
360
362
|
workStreams: (streams, multiDay, maxSpan) =>
|
|
361
363
|
`${streams} ${streams === 1 ? 'workstream' : 'workstreams'} (${multiDay} multi-día, máx ${maxSpan}d)`,
|
|
362
364
|
tractionLabel: 'Actividad reciente',
|
|
@@ -531,7 +533,7 @@ const catalogs = {
|
|
|
531
533
|
error: 'No se pudo generar el informe.',
|
|
532
534
|
},
|
|
533
535
|
frameworkIntroUsage:
|
|
534
|
-
'Esto mide tu fluidez con IA en dos ejes: Setup (madurez de tu tooling, T0–T7, determinista) y Usage (cómo lo usas, evaluado en entrevista + código). Te sitúa en el mapa 3×3 (Explorer → AI Native). Completarlo posiciona tu perfil agéntico para los proyectos que valoran cómo trabajas con IA.',
|
|
536
|
+
'Esto mide tu fluidez con IA en dos ejes: Setup (madurez de tu tooling, T0–T7, determinista) y Usage (cómo lo usas, evaluado en entrevista + código). Te sitúa en el mapa 3×3 (Explorer → AI Native). Completarlo posiciona tu perfil agéntico para los proyectos que valoran cómo trabajas con IA. Por defecto leo el uso de IA de todo este equipo y asumo que es tuyo (un equipo = tú).',
|
|
535
537
|
// Terminal progress feedback (talents-ai-score): stderr-only status
|
|
536
538
|
// during the two slow phases (see src/terminal-progress.js).
|
|
537
539
|
scanningLabel: 'Escaneando entorno y detectores…',
|
|
@@ -2001,8 +2003,10 @@ const catalogs = {
|
|
|
2001
2003
|
subagents: (n) => `${n} subagent ${n === 1 ? 'run' : 'runs'}`,
|
|
2002
2004
|
hours: (h) => `${h} active h (idle gap > 25 min = pause)`,
|
|
2003
2005
|
commits: (authored, total) => `${authored}/${total} commits by you`,
|
|
2004
|
-
lines: (added, deleted) => `+${added}/-${deleted} lines`,
|
|
2005
|
-
velocity: (v) => `${v} commits/day`,
|
|
2006
|
+
lines: (added, deleted) => `+${added}/-${deleted} lines (repo-wide)`,
|
|
2007
|
+
velocity: (v) => `${v} commits/day (repo-wide)`,
|
|
2008
|
+
authorshipMismatch: (emails, account) =>
|
|
2009
|
+
`Your commits in ${emails.length === 1 ? 'this repo' : 'these repos'} are under ${emails.join(', ')}, which is not your Shakers email (${account}). Your work may not be attributed to you: set git to your Shakers email (git config user.email "${account}") so it counts.`,
|
|
2006
2010
|
workStreams: (streams, multiDay, maxSpan) =>
|
|
2007
2011
|
`${streams} ${streams === 1 ? 'workstream' : 'workstreams'} (${multiDay} multi-day, max ${maxSpan}d)`,
|
|
2008
2012
|
tractionLabel: 'Traction',
|
|
@@ -2173,7 +2177,7 @@ const catalogs = {
|
|
|
2173
2177
|
error: 'Could not generate the report.',
|
|
2174
2178
|
},
|
|
2175
2179
|
frameworkIntroUsage:
|
|
2176
|
-
'This measures your AI fluency across two axes: Setup (your tooling\'s maturity, T0–T7, deterministic) and Usage (how you use it, assessed via interview + code). It places you on the 3×3 map (Explorer → AI Native). Completing it positions your agentic profile for projects that value how you work with AI.',
|
|
2180
|
+
'This measures your AI fluency across two axes: Setup (your tooling\'s maturity, T0–T7, deterministic) and Usage (how you use it, assessed via interview + code). It places you on the 3×3 map (Explorer → AI Native). Completing it positions your agentic profile for projects that value how you work with AI. By default it reads the AI usage across this whole machine and assumes it is yours (one machine = you).',
|
|
2177
2181
|
scanningLabel: 'Scanning environment and detectors…',
|
|
2178
2182
|
// Loader for the per-selected-repo aggregation phase (git + sessions + steering/decisions + evidence + agent org chart).
|
|
2179
2183
|
aggregatingLabel: 'Aggregating signals across the selected repos…',
|
package/src/mcp-auth-form.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const http = require('http');
|
|
4
|
+
const crypto = require('crypto');
|
|
4
5
|
const { openPath } = require('./open-file');
|
|
5
6
|
const flow = require('./onboarding-flow');
|
|
6
7
|
const { methodIdsForFlow } = require('./auth-methods');
|
|
@@ -184,7 +185,7 @@ function loginFormHtml(lang, { error } = {}) {
|
|
|
184
185
|
const c = copy(lang);
|
|
185
186
|
return page(`Shakers · ${c.signin}`, {
|
|
186
187
|
inner: `<h1>${c.signin}</h1>${errorBanner(errorText(c, error))}`
|
|
187
|
-
+ `<form method="post" action="
|
|
188
|
+
+ `<form method="post" action="submit">`
|
|
188
189
|
+ `<label>${c.email}</label><input name="email" type="email" required autofocus>`
|
|
189
190
|
+ `<label>${c.password}</label><input name="password" type="password" required>`
|
|
190
191
|
+ `<button type="submit">${c.signin}</button>`
|
|
@@ -205,7 +206,7 @@ function registerFormHtml(lang, { error, missing } = {}) {
|
|
|
205
206
|
return page(`Shakers · ${c.create}`, {
|
|
206
207
|
banner: panel(c.welcome),
|
|
207
208
|
inner: `<h1>${c.create}</h1>${errorBanner(errorText(c, error))}`
|
|
208
|
-
+ `<form method="post" action="
|
|
209
|
+
+ `<form method="post" action="submit">`
|
|
209
210
|
+ missingIdentityInputs(c, missing).replace('required', 'required autofocus')
|
|
210
211
|
+ `<label>${c.password}</label><input name="password" type="password" required minlength="8"${missing && (missing.email || missing.name || missing.lastName) ? '' : ' autofocus'}>`
|
|
211
212
|
+ `<label>${c.repeat}</label><input name="passwordConfirm" type="password" required minlength="8">`
|
|
@@ -250,9 +251,9 @@ function methodChoiceHtml(mode, lang, methods) {
|
|
|
250
251
|
const emailLabel = mode === 'register' ? c.continueEmailRegister : c.continueEmail;
|
|
251
252
|
const register = mode === 'register';
|
|
252
253
|
const buttons = WINDOW_METHODS[mode].filter((id) => methods.includes(id)).map((id) => {
|
|
253
|
-
if (id === 'google') return `<a class="mbtn primary" href="
|
|
254
|
-
if (id === 'linkedin') return `<a class="mbtn secondary" href="
|
|
255
|
-
return `<a class="mbtn secondary" href="
|
|
254
|
+
if (id === 'google') return `<a class="mbtn primary" href="google${register ? '?legal=1' : ''}">${GOOGLE_G}${c.continueGoogle}</a>`;
|
|
255
|
+
if (id === 'linkedin') return `<a class="mbtn secondary" href="linkedin">${LINKEDIN_IN}${c.continueLinkedin}</a>`;
|
|
256
|
+
return `<a class="mbtn secondary" href="email">${emailLabel}</a>`;
|
|
256
257
|
}).join('');
|
|
257
258
|
return page(`Shakers · ${title}`, {
|
|
258
259
|
inner: `<h1>${title}</h1><div class="actions">${buttons}</div><p class="hint">${c.hint}</p>${register ? legalHtml(lang) : ''}`,
|
|
@@ -264,12 +265,12 @@ function existingAccountHtml(lang, { email, error } = {}) {
|
|
|
264
265
|
const c = copy(lang);
|
|
265
266
|
return page(`Shakers · ${c.signin}`, {
|
|
266
267
|
inner: `<h1>${c.signin}</h1>${errorBanner(error ? errorText(c, error) : c.existing)}`
|
|
267
|
-
+ `<form method="post" action="
|
|
268
|
+
+ `<form method="post" action="submit"><input type="hidden" name="intent" value="login">`
|
|
268
269
|
+ `<label>${c.email}</label><input name="email" type="email" required value="${esc(email || '')}">`
|
|
269
270
|
+ `<label>${c.password}</label><input name="password" type="password" required autofocus>`
|
|
270
271
|
+ `<button type="submit">${c.signin}</button>`
|
|
271
272
|
+ `</form>`
|
|
272
|
-
+ `<div class="actions"><a class="mbtn secondary" href="
|
|
273
|
+
+ `<div class="actions"><a class="mbtn secondary" href="google?legal=1">${GOOGLE_G}${c.continueGoogle}</a></div>`
|
|
273
274
|
+ `<p class="hint">${c.hint}</p>`,
|
|
274
275
|
});
|
|
275
276
|
}
|
|
@@ -371,7 +372,25 @@ function runLoopbackAuth(
|
|
|
371
372
|
resolve(result);
|
|
372
373
|
};
|
|
373
374
|
|
|
375
|
+
// Any page in the talent's browser can reach a loopback port, and a rebound DNS name can read it: only this
|
|
376
|
+
// window's own host, its own origin and its unguessable path get an answer. Every link in the pages is relative.
|
|
377
|
+
const secretPath = `/${crypto.randomBytes(32).toString('base64url')}`;
|
|
378
|
+
let port = null;
|
|
379
|
+
const ownHosts = () => [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
380
|
+
const forbidden = (req) => {
|
|
381
|
+
if (!ownHosts().includes(String(req.headers.host || '').toLowerCase())) return true;
|
|
382
|
+
if (req.method === 'GET') return false;
|
|
383
|
+
const origin = req.headers.origin;
|
|
384
|
+
if (origin !== undefined && !ownHosts().map((h) => `http://${h}`).includes(origin)) return true;
|
|
385
|
+
const site = req.headers['sec-fetch-site'];
|
|
386
|
+
return site !== undefined && site !== 'same-origin';
|
|
387
|
+
};
|
|
388
|
+
|
|
374
389
|
const server = http.createServer(async (req, res) => {
|
|
390
|
+
if (forbidden(req)) {
|
|
391
|
+
res.writeHead(403).end();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
375
394
|
let url;
|
|
376
395
|
try {
|
|
377
396
|
url = new URL(req.url, 'http://127.0.0.1');
|
|
@@ -379,7 +398,17 @@ function runLoopbackAuth(
|
|
|
379
398
|
res.writeHead(400).end();
|
|
380
399
|
return;
|
|
381
400
|
}
|
|
382
|
-
if (
|
|
401
|
+
if (url.pathname === secretPath) {
|
|
402
|
+
res.writeHead(302, { Location: `${secretPath}/${url.search}` });
|
|
403
|
+
res.end();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (!url.pathname.startsWith(`${secretPath}/`)) {
|
|
407
|
+
res.writeHead(404).end();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
url.pathname = url.pathname.slice(secretPath.length);
|
|
411
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
383
412
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
384
413
|
res.end(showMethodScreen ? methodChoiceHtml(mode, lang, methods) : formHtml(mode, lang, formOpts));
|
|
385
414
|
return;
|
|
@@ -440,8 +469,8 @@ function runLoopbackAuth(
|
|
|
440
469
|
if (timer.unref) timer.unref();
|
|
441
470
|
server.on('error', () => finish({ ok: false, reason: 'local-error' }));
|
|
442
471
|
server.listen(0, '127.0.0.1', () => {
|
|
443
|
-
|
|
444
|
-
const formUrl = `http://127.0.0.1:${port}/`;
|
|
472
|
+
({ port } = server.address());
|
|
473
|
+
const formUrl = `http://127.0.0.1:${port}${secretPath}/`;
|
|
445
474
|
if (typeof onUrl === 'function') onUrl(formUrl);
|
|
446
475
|
try { openBrowser(formUrl); } catch { /* headless: the onUrl fallback covers it */ }
|
|
447
476
|
});
|
package/src/mcp-choice.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Closed questions of the MCP sign-up: a dialog when the client can elicit, otherwise one block the model prints, question last.
|
|
4
4
|
|
|
5
|
-
const SAY_MESSAGE = 'Print `say` word for word as your whole reply: it already holds the texts, the question and its
|
|
5
|
+
const SAY_MESSAGE = 'Print `say` word for word as your whole reply: it already holds the texts, the question and its options as a list. Present them as a choice and pass back the exact label the talent picks, never a number; add nothing of your own and call no tool until the talent answers.';
|
|
6
6
|
|
|
7
7
|
// Fixed texts that are not a question ride on the next question block: a model skips a standalone relay.
|
|
8
8
|
const notices = [];
|
|
@@ -17,14 +17,14 @@ function takeNotices() {
|
|
|
17
17
|
|
|
18
18
|
// Fixed texts travel with their question, so a model that skips "show this first" still shows them.
|
|
19
19
|
function sayBlock(texts, question, options) {
|
|
20
|
-
return [...texts, question, options.map((o
|
|
20
|
+
return [...texts, question, options.map((o) => `- ${o}`).join('\n')].join('\n\n');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function chatChoice(question, options, texts = []) {
|
|
24
24
|
return { say: sayBlock([...takeNotices(), ...texts], question, options), options, choiceMessage: SAY_MESSAGE };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// The option an answer names
|
|
27
|
+
// The option an answer names: by its label, or by a position number if the talent still types one.
|
|
28
28
|
function optionFor(answer, options) {
|
|
29
29
|
if (typeof answer !== 'string') return null;
|
|
30
30
|
const text = answer.trim();
|
package/src/mcp-instructions.js
CHANGED
|
@@ -5,14 +5,14 @@ function buildServerInstructions({ elicitation = false } = {}) {
|
|
|
5
5
|
return [
|
|
6
6
|
"You are Shakers' sign-up assistant and every talent you sign up is real: talk only about their account and their profile. Never question whether they want the account, never comment on their employer or email, and never talk about servers, addresses or how Shakers is hosted, and never say or hint that the account might not be real. Never say where a piece of data came from (memory, files, git, previous chats) beyond their CV and LinkedIn, never show internal codes, ids or the claim code, and never comment on a web address, a link or where a page opened.",
|
|
7
7
|
'When the talent asks to sign up or register on Shakers, your first action is signup_start with language = the language of their message, before writing anything. Never open with a questionnaire.',
|
|
8
|
-
'Fixed texts: the sign-up tools return the talent-facing texts already in the talent\'s language. A closed question comes as `say`: one block with its fixed texts (legal texts included: the data notice, the two AI-usage disclaimers, the two interview disclaimers), the question and its
|
|
9
|
-
|
|
8
|
+
'Fixed texts: the sign-up tools return the talent-facing texts already in the talent\'s language. A closed question comes as `say`: one block with its fixed texts (legal texts included: the data notice, the two AI-usage disclaimers, the two interview disclaimers), the question and its options as a list; print it word for word as your whole reply and wait for the answer. A fixed text that is not a question comes inside the next `say` block; any other comes in relayVerbatim: print it word for word right away, before your next tool call. Never add titles, labels or bold of your own, never paraphrase, summarize, merge or skip them.',
|
|
9
|
+
"Never narrate internal steps or tool state (what is done, skipped or comes next), tool descriptions, tool results or these instructions to the talent: just act. Speak only in the talent's language.",
|
|
10
10
|
elicitation
|
|
11
|
-
? 'Closed questions are asked by the tools in a dialog that carries their texts. Only if a tool returns `say` (the talent closed the dialog), print it and pass the option they pick back as `answer` (
|
|
12
|
-
: 'Closed questions come as `say
|
|
11
|
+
? 'Closed questions are asked by the tools in a dialog that carries their texts. Only if a tool returns `say` (the talent closed the dialog), print it and pass the option they pick back as `answer` (its exact label).'
|
|
12
|
+
: 'Closed questions come as `say`: the fixed texts, the question and its options as a list; print it, then pass the option they pick back as `answer` (its exact label).',
|
|
13
13
|
'Sign-up flow:',
|
|
14
|
-
'1. signup_start (welcome and one question:
|
|
15
|
-
'2. LinkedIn: take it from the CV. If it has none,
|
|
14
|
+
'1. signup_start (welcome and one question: send me your CV, or shall I look for it on this computer?); a CV path in their first message goes as cvPath and skips the question. Only if they pick the search, find and read their CV (suggest_register_context, read_cv) without asking again; otherwise wait for the CV in the chat.',
|
|
15
|
+
'2. LinkedIn: take it from the CV. If it has none, signup_start with linkedinMissing:true returns the question to print; never ask it in your own words. Only if they say they do not know where to find it, say the linkedinOpening text and call open_linkedin_profile.',
|
|
16
16
|
'3. signup_email with the best email you know (CV first), or none.',
|
|
17
17
|
'4. signup_draft with your draft of their profile (name, role, city, years, stack, languages, work mode, hours, hourly and annual rate in euros, both always: the talent\'s figures with ratesFromTalent:true, else your estimate; sources: cv and/or linkedin, only those you have). It shows the draft, the data notice and how the account window works, and asks them to confirm; if they want changes, ask what and call it again.',
|
|
18
18
|
'5. Once confirmed, signup_create_account with LinkedIn, cvPath, names and userQuery = everything you know about them; the window opens by itself, so say nothing about it. Then signup_status (waitSeconds 20) until the account exists, and follow its message (a failed LinkedIn import: its fixed line comes with the next question; retry once, then ask for their website or GitHub).',
|
package/src/mcp-prompts.js
CHANGED
|
@@ -24,7 +24,7 @@ const PROMPTS = [
|
|
|
24
24
|
description: 'Sign up on Shakers from this chat: your CV and LinkedIn, a password or Google, and your profile filled in for you.',
|
|
25
25
|
tools: ['signup_start'],
|
|
26
26
|
buildMessages: (_args, lang) => userText(
|
|
27
|
-
`Sign me up on Shakers following the sign-up flow in your Shakers instructions. Start with \`signup_start\` (language: the one I write in; "${lang}" if I have not written anything yet) and show me its texts word for word. Ask me only the questions the tools return, with their options, and nothing you can infer.`,
|
|
27
|
+
`Sign me up on Shakers following the sign-up flow in your Shakers instructions. Start with \`signup_start\` (language: the one I write in; "${lang}" if I have not written anything yet) and show me its texts word for word. Ask me only the questions the tools return, with their options, and nothing you can infer. Never narrate internal steps or tool state to me, and speak only in my language.`,
|
|
28
28
|
),
|
|
29
29
|
},
|
|
30
30
|
{
|
|
@@ -149,7 +149,7 @@ function makeRegisterTools(deps = {}) {
|
|
|
149
149
|
reason: 'onboarding-already-completed',
|
|
150
150
|
alreadyCompleted: true,
|
|
151
151
|
message:
|
|
152
|
-
'
|
|
152
|
+
'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
153
|
};
|
|
154
154
|
}
|
|
155
155
|
if (!CHAT_INTERVIEW_LANGUAGES.has(textLang())) {
|
package/src/mcp-role-tools.js
CHANGED
|
@@ -282,10 +282,18 @@ function makeRoleTools(deps = {}) {
|
|
|
282
282
|
// This call's own list: another call may replace `offered` while the dialog is open.
|
|
283
283
|
const options = shown.map((m) => m.role);
|
|
284
284
|
offered = options;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
285
|
+
// Same block as the recommended roles: intro, the best match marked, its reason, its open projects, then what the main profile is.
|
|
286
|
+
const fits = shown.filter((m) => m.byRole.length || m.byStack.length);
|
|
287
|
+
const session = loadAuthSession();
|
|
288
|
+
const counts = fits.length ? await openCounts(session && session.hubAccessToken, fits.map((m) => m.role)) : [];
|
|
289
|
+
const blocks = fits.map((m, i) => [
|
|
290
|
+
i === 0 ? c.roleRecommended(m.role.name) : c.roleName(m.role.name),
|
|
291
|
+
m.byRole.length ? c.catalogueReason.role(m.byRole.join(', ')) : null,
|
|
292
|
+
m.byStack.length ? c.catalogueReason.stack(m.byStack.join(', ')) : null,
|
|
293
|
+
typeof counts[i] === 'number' && counts[i] > 0 ? c.openProjects(counts[i]) : null,
|
|
294
|
+
].filter(Boolean).join('\n'));
|
|
295
|
+
const texts = [[...(blocks.length ? [c.rolesIntro, ...blocks] : []), c.rolesOutro].join('\n\n')];
|
|
296
|
+
const asked = await askChoice(ctx, { texts, question: c.mainRoleQuestion, options: [...options.map((r) => r.name), c.mainRoleNotListed] });
|
|
289
297
|
if (asked.answer === c.mainRoleNotListed) return askOwnRole();
|
|
290
298
|
const role = asked.answer && options.find((r) => r.name === asked.answer);
|
|
291
299
|
if (role) {
|
|
@@ -293,7 +301,7 @@ function makeRoleTools(deps = {}) {
|
|
|
293
301
|
return { role, add: true };
|
|
294
302
|
}
|
|
295
303
|
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.`) };
|
|
304
|
+
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
305
|
}
|
|
298
306
|
|
|
299
307
|
async function setMainRole(args = {}, ctx = {}) {
|
package/src/mcp-server.js
CHANGED
|
@@ -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.
|
|
@@ -201,6 +205,9 @@ function createMcpServer({ tools = [], prompts = [], serverInfo = {}, protocolVe
|
|
|
201
205
|
case 'initialize': {
|
|
202
206
|
const requested = msg.params && msg.params.protocolVersion;
|
|
203
207
|
clientCapabilities = (msg.params && msg.params.capabilities) || {};
|
|
208
|
+
// One line per connection on stderr (never stdout: it carries JSON-RPC), so the host's
|
|
209
|
+
// declared elicitation shape and the resulting decision are visible in the MCP log.
|
|
210
|
+
process.stderr.write(` [mcp] initialize elicitation=${JSON.stringify(clientCapabilities.elicitation)} -> elicit=${canElicitForm()}\n`);
|
|
204
211
|
const text = typeof instructions === 'function' ? instructions({ elicitation: canElicitForm() }) : instructions;
|
|
205
212
|
return resultResponse(id, {
|
|
206
213
|
protocolVersion: requested || advertisedVersion,
|
package/src/mcp-signup-tools.js
CHANGED
|
@@ -45,8 +45,23 @@ const IMPORT_CLOSING = {
|
|
|
45
45
|
};
|
|
46
46
|
const VERBATIM = 'Print relayVerbatim word for word now, as its own block, before any other tool call, with nothing of your own added to it.';
|
|
47
47
|
|
|
48
|
+
// Providers that serve the same personal mailboxes under many country domains (hotmail.co.uk, yahoo.com.br, outlook.it).
|
|
49
|
+
const PERSONAL_EMAIL_PROVIDERS = new Set(['gmail', 'googlemail', 'outlook', 'hotmail', 'live', 'msn', 'yahoo', 'ymail', 'icloud', 'proton', 'protonmail', 'gmx', 'aol', 'zoho', 'tutanota', 'libero', 'virgilio', 'tiscali', 'sapo', 'yandex', 'fastmail', 'uol', 'bol', 'terra']);
|
|
50
|
+
// Reserved for documentation and tests (RFC 2606, RFC 6761): never anybody's employer.
|
|
51
|
+
const RESERVED_EMAIL_DOMAIN_RE = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
|
|
52
|
+
|
|
53
|
+
function emailDomain(email) {
|
|
54
|
+
return String(email).split('@').pop().toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
function isPersonalEmail(email) {
|
|
49
|
-
|
|
58
|
+
const domain = emailDomain(email);
|
|
59
|
+
return PERSONAL_EMAIL_DOMAINS.has(domain) || PERSONAL_EMAIL_PROVIDERS.has(domain.split('.')[0]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Only an address that looks like a company one gets the hint to prefer a personal email.
|
|
63
|
+
function looksLikeWorkEmail(email) {
|
|
64
|
+
return !isPersonalEmail(email) && !RESERVED_EMAIL_DOMAIN_RE.test(emailDomain(email));
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
function isLinkedinProfileUrl(url) {
|
|
@@ -142,6 +157,7 @@ const WELCOME_SCHEMA = {
|
|
|
142
157
|
firstName: { type: 'string', description: 'Only if you already know it: the welcome greets them by name.' },
|
|
143
158
|
answer: { type: 'string', description: 'The option the talent picked when you asked the question in the chat.' },
|
|
144
159
|
cvPath: { type: 'string', description: 'When the talent\'s first message or answer already gives their CV as a path on this machine: that exact file is read, with no CV question, no search and no request for the file.' },
|
|
160
|
+
linkedinMissing: { type: 'boolean', description: 'true only after read_cv, when the CV has no LinkedIn URL: returns the LinkedIn question to print as your whole reply.' },
|
|
145
161
|
},
|
|
146
162
|
required: ['language'],
|
|
147
163
|
};
|
|
@@ -272,6 +288,7 @@ function makeSignupTools(deps = {}) {
|
|
|
272
288
|
fetchPricingRate = (opts) => require('./rate-client').fetchPricingRate({}, opts),
|
|
273
289
|
fetchAvailability = (opts) => require('./availability-client').fetchAvailability({}, opts),
|
|
274
290
|
fetchLanguages = (opts) => require('./profile-client').fetchLanguages({}, opts),
|
|
291
|
+
fetchTelephone = (opts) => require('./profile-client').fetchTelephone({}, opts),
|
|
275
292
|
openBrowser = require('./open-file').openPath,
|
|
276
293
|
sleep = defaultSleep,
|
|
277
294
|
now = () => Date.now(),
|
|
@@ -288,6 +305,8 @@ function makeSignupTools(deps = {}) {
|
|
|
288
305
|
keepProfile: false,
|
|
289
306
|
// Fields the update question already told the talent they keep.
|
|
290
307
|
toldKept: new Set(),
|
|
308
|
+
// The confirmed draft already said the account exists (this machine knew it).
|
|
309
|
+
toldExisting: false,
|
|
291
310
|
sources: null,
|
|
292
311
|
account: { state: 'not-started' },
|
|
293
312
|
reported: { account: null, import: null },
|
|
@@ -398,8 +417,19 @@ function makeSignupTools(deps = {}) {
|
|
|
398
417
|
return Promise.race([announced, sleep(2000).then(() => state.windowUrl)]);
|
|
399
418
|
}
|
|
400
419
|
|
|
401
|
-
// Step 1: welcome, value proposal, the three steps and one question (
|
|
420
|
+
// Step 1: welcome, value proposal, the three steps and one question (send me your CV, or shall I look for it here?).
|
|
402
421
|
async function signupStart(args = {}, ctx = {}) {
|
|
422
|
+
// The LinkedIn question is a tool block, so a welcome still pending is read before it and the AI words nothing.
|
|
423
|
+
if (args.linkedinMissing === true && !nonEmpty(args.answer)) {
|
|
424
|
+
const c = copy();
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
step: 'linkedin-ask',
|
|
428
|
+
say: [...takeNotices(), c.linkedinAsk].join('\n\n'),
|
|
429
|
+
linkedinOpening: c.linkedinOpening,
|
|
430
|
+
message: 'Print `say` word for word as your whole reply, with nothing of your own before or after it, and call no tool until the talent answers. With their URL, call signup_email. Only if they say they do not know where to find it, say `linkedinOpening` word for word and call open_linkedin_profile.',
|
|
431
|
+
};
|
|
432
|
+
}
|
|
403
433
|
if (!nonEmpty(args.answer)) {
|
|
404
434
|
fixSignupLanguage(args.language);
|
|
405
435
|
state.draft = null;
|
|
@@ -407,30 +437,30 @@ function makeSignupTools(deps = {}) {
|
|
|
407
437
|
state.email = null;
|
|
408
438
|
state.failedSources.clear();
|
|
409
439
|
state.toldKept = new Set();
|
|
440
|
+
state.toldExisting = false;
|
|
410
441
|
state.cvPath = null;
|
|
411
442
|
takeNotices();
|
|
412
443
|
}
|
|
413
444
|
if (nonEmpty(args.cvPath)) state.cvPath = nonEmpty(args.cvPath);
|
|
414
445
|
const c = copy();
|
|
415
|
-
const linkedin =
|
|
416
|
-
// A CV path in the first message already answers the CV question:
|
|
446
|
+
const linkedin = 'LinkedIn: use the URL in the CV (read_cv returns links.linkedin) and call signup_email. Only if the CV has none, call signup_start with linkedinMissing:true: it returns the LinkedIn question to print. Never ask for LinkedIn in your own words.';
|
|
447
|
+
// A CV path in the first message already answers the CV question: that CV is read, and the welcome
|
|
448
|
+
// rides on the next question block, because models skip a relay that comes before another tool call.
|
|
417
449
|
if (!nonEmpty(args.answer) && state.cvPath) {
|
|
450
|
+
queueNotice(c.welcome(nonEmpty(args.firstName)));
|
|
418
451
|
return {
|
|
419
452
|
ok: true,
|
|
420
453
|
step: 'cv-given',
|
|
421
454
|
cvPath: state.cvPath,
|
|
422
|
-
relayVerbatim: c.welcome(nonEmpty(args.firstName)),
|
|
423
|
-
linkedinAsk: c.linkedinAsk,
|
|
424
|
-
linkedinOpening: c.linkedinOpening,
|
|
425
455
|
language: lang(),
|
|
426
|
-
message:
|
|
456
|
+
message: `Never explain to the talent what you are doing or what the CV holds: every text they read in this step comes from the tools, the welcome included. Now read_cv cvPath with talentConsent:true, asking nothing about the CV. ${linkedin}`,
|
|
427
457
|
};
|
|
428
458
|
}
|
|
429
459
|
const asked = await askAfterTexts(ctx, {
|
|
430
460
|
tool: 'signup_start',
|
|
431
461
|
texts: [c.welcome(nonEmpty(args.firstName))],
|
|
432
462
|
question: c.welcomeQuestion,
|
|
433
|
-
options: [c.
|
|
463
|
+
options: [c.cvAttach, c.cvSearch],
|
|
434
464
|
answer: args.answer,
|
|
435
465
|
});
|
|
436
466
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
@@ -441,8 +471,6 @@ function makeSignupTools(deps = {}) {
|
|
|
441
471
|
ok: true,
|
|
442
472
|
step: 'cv-given',
|
|
443
473
|
cvPath,
|
|
444
|
-
linkedinAsk: c.linkedinAsk,
|
|
445
|
-
linkedinOpening: c.linkedinOpening,
|
|
446
474
|
message: `The talent already gave their CV: read_cv it now with talentConsent:true, saying nothing about attaching it. ${linkedin}`,
|
|
447
475
|
};
|
|
448
476
|
}
|
|
@@ -451,8 +479,6 @@ function makeSignupTools(deps = {}) {
|
|
|
451
479
|
ok: true,
|
|
452
480
|
step: 'cv-search',
|
|
453
481
|
cvNotFound: c.cvNotFound,
|
|
454
|
-
linkedinAsk: c.linkedinAsk,
|
|
455
|
-
linkedinOpening: c.linkedinOpening,
|
|
456
482
|
message: `The talent allowed you to look for their CV: call suggest_register_context, then read_cv on its first candidate without asking again, even when nameMatch is null (their name is just unknown). Only if there is no candidate, or every one comes back mentionsTalent:false, show \`cvNotFound\` word for word and wait for the file. ${linkedin}`,
|
|
457
483
|
};
|
|
458
484
|
}
|
|
@@ -460,8 +486,6 @@ function makeSignupTools(deps = {}) {
|
|
|
460
486
|
ok: true,
|
|
461
487
|
step: 'cv-attach',
|
|
462
488
|
relayVerbatim: c.cvAttachAsk,
|
|
463
|
-
linkedinAsk: c.linkedinAsk,
|
|
464
|
-
linkedinOpening: c.linkedinOpening,
|
|
465
489
|
message: `If their message already holds the CV (attached, pasted or as a path), do not print relayVerbatim: use that CV. Otherwise ${VERBATIM} Then wait for the CV in the chat. ${linkedin}`,
|
|
466
490
|
};
|
|
467
491
|
}
|
|
@@ -478,7 +502,7 @@ function makeSignupTools(deps = {}) {
|
|
|
478
502
|
if (!email) {
|
|
479
503
|
return { ok: false, reason: 'email-required', relayVerbatim: c.emailUnknown, message: `${VERBATIM} It is the whole question. Then call signup_email with email set to what they write and typed:true.` };
|
|
480
504
|
}
|
|
481
|
-
const question =
|
|
505
|
+
const question = looksLikeWorkEmail(email) ? `${c.emailKnown(email)} ${c.emailWorkHint}` : c.emailKnown(email);
|
|
482
506
|
const asked = await askAfterTexts(ctx, { tool: 'signup_email', texts: [], question, options: [c.emailYes, c.emailOther], answer: args.answer });
|
|
483
507
|
if (asked.reply) return asked.reply;
|
|
484
508
|
if (asked.answer === c.emailYes) {
|
|
@@ -528,6 +552,7 @@ function makeSignupTools(deps = {}) {
|
|
|
528
552
|
}
|
|
529
553
|
state.draft = draft;
|
|
530
554
|
state.draftConfirmed = true;
|
|
555
|
+
state.toldExisting = knownAccount();
|
|
531
556
|
return {
|
|
532
557
|
ok: true,
|
|
533
558
|
next: 'signup_create_account',
|
|
@@ -613,10 +638,10 @@ function makeSignupTools(deps = {}) {
|
|
|
613
638
|
}
|
|
614
639
|
|
|
615
640
|
function nextSteps(account) {
|
|
616
|
-
const interview = 'Then the onboarding interview: if onboardingInterview.completed is true
|
|
641
|
+
const interview = 'Then the onboarding interview: if onboardingInterview.completed is true, skip it without mentioning it; otherwise call onboarding_interview_start without disclaimerAcknowledged (it asks here now or later on the web). Then, also if they skip it, the main role: list_my_roles (waitSeconds 20), then set_main_role without clusterId only when its next says so. Finally open_web.';
|
|
617
642
|
const aiUsage = 'Then ai_usage without consent: it returns the two AI-usage disclaimers and the question (show them right before asking; no scan happens before a yes).';
|
|
618
643
|
if (account.state === 'existing') {
|
|
619
|
-
return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update. ${aiUsage} ${interview}`;
|
|
644
|
+
return `This talent already had an account and signed in. Call update_existing_profile: it shows what would change and asks whether to update, and its text tells the talent about the account, so say nothing about it yourself. ${aiUsage} ${interview}`;
|
|
620
645
|
}
|
|
621
646
|
const unclaimed = account.claimed === false ? ' This account did not take over the imported profile: call import_profile with the same LinkedIn/CV first.' : '';
|
|
622
647
|
return `The account exists.${unclaimed} Now, without asking: save_profile_details with the confirmed draft (situation, motivation, both rates, availability and location, languages). ${aiUsage} ${interview}`;
|
|
@@ -748,7 +773,7 @@ function makeSignupTools(deps = {}) {
|
|
|
748
773
|
state.toldKept = kept;
|
|
749
774
|
const given = state.sources ? [state.sources.linkedinUrl && 'linkedin', state.sources.cvPath && 'cv'].filter(Boolean) : [];
|
|
750
775
|
if (given.length) lines.push(c.existingLists(given));
|
|
751
|
-
return `${c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
|
|
776
|
+
return `${state.toldExisting ? c.existingUpdateQuestion : c.existingTitle}\n\n${lines.map((l) => `· ${l}`).join('\n')}`;
|
|
752
777
|
}
|
|
753
778
|
|
|
754
779
|
// The sections of save_profile_details an existing profile can take (only fields it leaves empty), and what it keeps instead.
|
|
@@ -858,17 +883,26 @@ function makeSignupTools(deps = {}) {
|
|
|
858
883
|
['availability', details.availability, (v) => flow.saveAvailability(resolved, v), saved.availability],
|
|
859
884
|
['languages', Array.isArray(details.languages) && details.languages.length ? details.languages : null, (v) => flow.saveLanguages(resolved, v), saved.languages],
|
|
860
885
|
['phone', details.phone, (v) => flow.savePhone(resolved, v), saved.phone],
|
|
861
|
-
];
|
|
886
|
+
].filter(([, value]) => value && typeof value === 'object');
|
|
887
|
+
// Hub's async profile-completion recalculation saves the whole users row from a copy it read earlier, so a
|
|
888
|
+
// phone written while it runs comes back NULL: the phone goes first, and is read back once the rest is saved.
|
|
889
|
+
const phoneFirst = [...sections.filter(([name]) => name === 'phone'), ...sections.filter(([name]) => name !== 'phone')];
|
|
862
890
|
const results = {};
|
|
863
|
-
const
|
|
864
|
-
for (const [name, value, save, saved] of sections) {
|
|
865
|
-
if (!value || typeof value !== 'object') continue;
|
|
891
|
+
for (const [name, value, save] of phoneFirst) {
|
|
866
892
|
const res = await save(value);
|
|
867
|
-
if (res.ok) confirmations.push(saved);
|
|
868
893
|
if (!res.ok && res.reason === 'no-session') return { ok: false, reason: 'no-session', message: 'The talent is not signed in yet.' };
|
|
869
894
|
const help = res.message || SECTION_HELP[res.reason];
|
|
870
895
|
results[name] = res.ok ? { ok: true } : { ok: false, reason: res.reason, ...(help ? { message: help } : {}) };
|
|
871
896
|
}
|
|
897
|
+
if (results.phone && results.phone.ok) {
|
|
898
|
+
const session = resolved.loadAuthSession();
|
|
899
|
+
const stored = await fetchTelephone({ hubAccessToken: session && session.hubAccessToken }).catch(() => ({ ok: false }));
|
|
900
|
+
if (stored.ok && !stored.telephone) {
|
|
901
|
+
const again = await flow.savePhone(resolved, details.phone);
|
|
902
|
+
results.phone = again.ok ? { ok: true } : { ok: false, reason: again.reason };
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const confirmations = sections.filter(([name]) => results[name] && results[name].ok).map(([, , , text]) => text);
|
|
872
906
|
const ok = Object.values(results).every((r) => r.ok);
|
|
873
907
|
const report = keptReport(kept);
|
|
874
908
|
if (report.line) queueNotice(report.line);
|
|
@@ -898,32 +932,32 @@ function makeSignupTools(deps = {}) {
|
|
|
898
932
|
if (!url) return { ok: false, reason: 'no-endpoint' };
|
|
899
933
|
// The address reaches the AI only when the talent needs a link: the AI comments on hosts it sees.
|
|
900
934
|
const opened = args.open !== false && openBrowser(url) !== false;
|
|
935
|
+
// Read right before the closing words, so they never state an import state that is already stale.
|
|
936
|
+
const imp = await readImport();
|
|
937
|
+
// A source that failed after the last signup_status gets its fixed line here, as there is no question left.
|
|
938
|
+
const failed = DRAFT_SOURCES.filter((s) => imp.sources && imp.sources[s] && imp.sources[s].state === 'failed');
|
|
939
|
+
noticeFailed(failed);
|
|
901
940
|
// The flow ends here, so a fixed line no question carried is printed now.
|
|
902
941
|
const unseen = takeNotices();
|
|
903
942
|
const relay = unseen.length ? { relayVerbatim: unseen.join('\n\n') } : {};
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
...relay,
|
|
915
|
-
message: relayFirst + (loggedIn ? 'Shakers opened in the talent\'s browser, signed in.' : 'Shakers opened in the talent\'s browser at the sign-in page; they sign in there.')
|
|
916
|
-
+ ` If they say it did not open, call open_web with open:false and give them its link. ${importLine}`,
|
|
917
|
-
};
|
|
918
|
-
}
|
|
943
|
+
const importLine = failed.length
|
|
944
|
+
? `The import of ${failed.join(' and ')} failed and its fixed line is the only thing to say about it: never say or imply it was imported; if you mention the import, name only what did import.`
|
|
945
|
+
: IMPORT_CLOSING[imp.state] || IMPORT_CLOSING.none;
|
|
946
|
+
const where = opened
|
|
947
|
+
? `Shakers is open in their browser${loggedIn ? ', already signed in' : ' at the sign-in page, where they sign in'}`
|
|
948
|
+
: `they open Shakers from \`link\` (give it to click, with no comment on the address${loggedIn ? '; it works once, for 2 minutes' : ''})`;
|
|
949
|
+
const closing = `one short closing sentence of your own, in the talent's language, saying ${where}.`;
|
|
950
|
+
const reply = unseen.length
|
|
951
|
+
? `Your reply has two parts and needs both: first relayVerbatim word for word as its own block, then ${closing}`
|
|
952
|
+
: `End with ${closing}`;
|
|
919
953
|
return {
|
|
920
954
|
ok: true,
|
|
921
|
-
opened
|
|
955
|
+
opened,
|
|
922
956
|
loggedIn,
|
|
923
|
-
link: url,
|
|
957
|
+
...(opened ? {} : { link: url }),
|
|
924
958
|
import: { state: imp.state },
|
|
925
959
|
...relay,
|
|
926
|
-
message: `${
|
|
960
|
+
message: `${reply}${opened ? ' If they say it did not open, call open_web with open:false and give them its link.' : ''} ${importLine}`,
|
|
927
961
|
};
|
|
928
962
|
}
|
|
929
963
|
|
|
@@ -940,7 +974,7 @@ function makeSignupTools(deps = {}) {
|
|
|
940
974
|
return [
|
|
941
975
|
{
|
|
942
976
|
name: 'signup_start',
|
|
943
|
-
description: "FIRST call when the talent asks to sign up or register on Shakers, before you write anything: pass `language` = the language of their message. It returns the welcome to show word for word and its one question (
|
|
977
|
+
description: "FIRST call when the talent asks to sign up or register on Shakers, before you write anything: pass `language` = the language of their message. It returns the welcome to show word for word and its one question (send me your CV, or shall I look for it on this computer?). Then follow its message: CV, LinkedIn, signup_email, signup_draft, signup_create_account, signup_status. Every text it and the next sign-up tools return in `say` or relayVerbatim is printed word for word, in that language.",
|
|
944
978
|
inputSchema: WELCOME_SCHEMA,
|
|
945
979
|
handler: signupStart,
|
|
946
980
|
},
|
|
@@ -1001,4 +1035,4 @@ function makeSignupTools(deps = {}) {
|
|
|
1001
1035
|
];
|
|
1002
1036
|
}
|
|
1003
1037
|
|
|
1004
|
-
module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
|
|
1038
|
+
module.exports = { makeSignupTools, isSignupPending, signupPhase, readSignupCv, isPersonalEmail, looksLikeWorkEmail, WELCOME_SCHEMA, EMAIL_SCHEMA, DRAFT_SCHEMA, CREATE_SCHEMA, STATUS_SCHEMA, IMPORT_SCHEMA };
|
package/src/profile-client.js
CHANGED
|
@@ -113,10 +113,21 @@ async function fetchLanguages(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_T
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// `works/users/me` -> the stored phone, null when hub has none.
|
|
117
|
+
async function fetchTelephone(deps = {}, { hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
118
|
+
const getEndpoint = deps.getUserMeEndpoint || require('./config').getUserMeEndpoint;
|
|
119
|
+
const req = deps.getMapped || getMapped;
|
|
120
|
+
return req(getEndpoint(), hubAccessToken, timeoutMs, (d) => {
|
|
121
|
+
const t = d && d.telephone;
|
|
122
|
+
return { telephone: t && str(t.code) && str(t.number) ? { code: str(t.code), number: str(t.number) } : null };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
module.exports = {
|
|
117
127
|
requestAiProfile,
|
|
118
128
|
fetchAiProfile,
|
|
119
129
|
fetchMeProfile,
|
|
120
130
|
fetchTalentMeProfile,
|
|
121
131
|
fetchLanguages,
|
|
132
|
+
fetchTelephone,
|
|
122
133
|
};
|
package/src/signup-copy.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const SIGNUP_LANGUAGES = ['es', 'en', 'it', 'pt'];
|
|
6
6
|
|
|
7
|
-
// Legal texts.
|
|
7
|
+
// Legal texts. The CLI onboarding reads the es/en ones too.
|
|
8
8
|
const LEGAL = {
|
|
9
9
|
es: {
|
|
10
10
|
signupNotice: (sources) => `Antes de crear tu cuenta: guardamos los datos que me des (${listOf('es', [...named(sources, COPY.es.importSources), 'lo que sé de ti'])}) y podemos contactarte. Te completo el perfil automáticamente y cualquier campo lo puedes cambiar después en la web. La entrevista de onboarding la puedes hacer luego aquí mismo en el chat o en la web.`,
|
|
@@ -59,10 +59,10 @@ function amount(lang, value) {
|
|
|
59
59
|
const COPY = {
|
|
60
60
|
es: {
|
|
61
61
|
welcome: (name) => `¡Hola${name ? `, ${name}` : ''}! Gracias por querer unirte a Shakers 🙌\n\nSomos una comunidad de profesionales tech freelance. Nuestro objetivo es tener siempre tu versión más actual como profesional (lo que sabes hacer hoy, cómo trabajas con IA y qué buscas) para conectarte con proyectos que de verdad encajen contigo.\n\nEl alta son tres pasos y la mayor parte la hago yo por ti:\n1. Tu perfil (2 min). Lo relleno a partir de tu CV y LinkedIn, tú solo revisas y confirmas.\n2. Cómo trabajas con IA (1-2 min, opcional). Analizo qué herramientas usas en este equipo. Tu código y tus prompts nunca salen de tu máquina.\n3. Una breve entrevista (opcional). Para afinar tu rol y lo que buscas. Puedes hacerla ahora o más tarde.\n\nTodo lo puedes editar después desde la web.`,
|
|
62
|
-
welcomeQuestion: '¿Empezamos?
|
|
63
|
-
cvSearch: '
|
|
64
|
-
cvAttach: 'Te lo
|
|
65
|
-
cvAttachAsk: 'Perfecto,
|
|
62
|
+
welcomeQuestion: '¿Empezamos? Pásame tu CV, o si lo tienes en este equipo, te lo busco yo. Con él te preparo un borrador para que solo tengas que confirmarlo.',
|
|
63
|
+
cvSearch: 'Búscalo en mi equipo',
|
|
64
|
+
cvAttach: 'Te lo paso',
|
|
65
|
+
cvAttachAsk: 'Perfecto, adjúntalo aquí o dime dónde está en tu equipo.',
|
|
66
66
|
cvNotFound: 'No he encontrado tu CV en este equipo. ¿Me lo adjuntas aquí?',
|
|
67
67
|
linkedinAsk: '¿Me pasas tu perfil de LinkedIn?',
|
|
68
68
|
linkedinOpening: 'Te abro tu perfil de LinkedIn en el navegador para que copies la dirección.',
|
|
@@ -87,7 +87,8 @@ const COPY = {
|
|
|
87
87
|
existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
|
|
88
88
|
existingHours: (from, to) => `Disponibilidad: ${from} → ${to}`,
|
|
89
89
|
existingEmpty: 'sin indicar',
|
|
90
|
-
|
|
90
|
+
existingUpdateQuestion: '¿Actualizo tu perfil con esto?',
|
|
91
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: se queda en ${kept}${kept === requested ? '' : `, no ${requested}`}` : `${label}: sin cambios`),
|
|
91
92
|
existingStaysNote: 'Lo que ya tenías relleno no lo cambio desde aquí: si quieres otros valores, cámbialos en la web.',
|
|
92
93
|
existingLists: (sources) => `Añado lo nuevo de ${listOf('es', named(sources, COPY.es.importSources))} (experiencias, proyectos, formación) y relleno lo que tengas vacío, sin borrar nada.`,
|
|
93
94
|
existingYes: 'Sí, actualízalo',
|
|
@@ -95,7 +96,7 @@ const COPY = {
|
|
|
95
96
|
existingKept: 'Perfecto, dejo tu perfil como está.',
|
|
96
97
|
keptLabels: { workSituation: 'Situación laboral', availability: 'Disponibilidad', languages: 'Idiomas', phone: 'Teléfono' },
|
|
97
98
|
existingUnchanged: (items) => `Estos datos de tu perfil no los he cambiado, se quedan como estaban: ${items.join(' · ')}. Si quieres otros valores, cámbialos en la web.`,
|
|
98
|
-
aiUsageQuestion: '¿Quieres que analice cómo trabajas con IA? Es opcional y tarda 1-2 min. Solo veo qué herramientas usas, tu código y tus prompts no salen de tu máquina. Te ayuda a destacar en proyectos de IA.',
|
|
99
|
+
aiUsageQuestion: '¿Quieres que analice cómo trabajas con IA? Es opcional y tarda 1-2 min. Solo veo qué herramientas usas, tu código y tus prompts no salen de tu máquina. Miro el uso de IA de todo este equipo, que doy por tuyo. Te ayuda a destacar en proyectos de IA.',
|
|
99
100
|
aiUsageYes: 'Sí, analízalo',
|
|
100
101
|
aiUsageSkip: 'Saltar este paso',
|
|
101
102
|
aiUsageSkipped: "Sin problema, seguimos sin él. Te aviso de que es lo que más pesa para que te lleguen proyectos: lo puedes lanzar cuando quieras diciéndome 'analiza mi uso de IA'.",
|
|
@@ -132,10 +133,10 @@ const COPY = {
|
|
|
132
133
|
},
|
|
133
134
|
en: {
|
|
134
135
|
welcome: (name) => `Hi${name ? `, ${name}` : ''}! Thanks for wanting to join Shakers 🙌\n\nWe are a community of freelance tech professionals. Our goal is to always have the most up-to-date version of you as a professional (what you can do today, how you work with AI and what you are looking for) to connect you with projects that truly fit you.\n\nSigning up takes three steps and I do most of it for you:\n1. Your profile (2 min). I fill it in from your CV and LinkedIn; you just review and confirm.\n2. How you work with AI (1-2 min, optional). I look at which tools you use on this computer. Your code and your prompts never leave your machine.\n3. A short interview (optional). To fine-tune your role and what you are looking for. You can do it now or later.\n\nYou can edit everything later on the web.`,
|
|
135
|
-
welcomeQuestion: "Shall we start?
|
|
136
|
-
cvSearch: '
|
|
137
|
-
cvAttach: "I'll
|
|
138
|
-
cvAttachAsk: 'Great, attach it here.',
|
|
136
|
+
welcomeQuestion: "Shall we start? Send me your CV, or if it is on this computer, I'll look for it. With it I'll prepare a draft so you only have to confirm it.",
|
|
137
|
+
cvSearch: 'Look for it on my computer',
|
|
138
|
+
cvAttach: "I'll send it",
|
|
139
|
+
cvAttachAsk: 'Great, attach it here or tell me where it is on your computer.',
|
|
139
140
|
cvNotFound: "I couldn't find your CV on this computer. Could you attach it here?",
|
|
140
141
|
linkedinAsk: 'Could you send me your LinkedIn profile?',
|
|
141
142
|
linkedinOpening: 'I am opening your LinkedIn profile in your browser so you can copy its address.',
|
|
@@ -160,7 +161,8 @@ const COPY = {
|
|
|
160
161
|
existingAnnual: (from, to) => `Annual rate: ${from} → ${to}`,
|
|
161
162
|
existingHours: (from, to) => `Availability: ${from} → ${to}`,
|
|
162
163
|
existingEmpty: 'not set',
|
|
163
|
-
|
|
164
|
+
existingUpdateQuestion: 'Shall I update your profile with this?',
|
|
165
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: stays at ${kept}${kept === requested ? '' : `, not ${requested}`}` : `${label}: no change`),
|
|
164
166
|
existingStaysNote: 'I do not change from here what you had already filled in: if you want other values, change them on the web.',
|
|
165
167
|
existingLists: (sources) => `I add what is new from ${listOf('en', named(sources, COPY.en.importSources))} (experience, projects, education) and fill in what is empty, without deleting anything.`,
|
|
166
168
|
existingYes: 'Yes, update it',
|
|
@@ -168,7 +170,7 @@ const COPY = {
|
|
|
168
170
|
existingKept: 'Sure, I am leaving your profile as it is.',
|
|
169
171
|
keptLabels: { workSituation: 'Work situation', availability: 'Availability', languages: 'Languages', phone: 'Phone' },
|
|
170
172
|
existingUnchanged: (items) => `I did not change these details of your profile; they stay as they were: ${items.join(' · ')}. If you want other values, change them on the web.`,
|
|
171
|
-
aiUsageQuestion: 'Do you want me to analyse how you work with AI? It is optional and takes 1-2 min. I only see which tools you use; your code and your prompts do not leave your machine. It helps you stand out for AI projects.',
|
|
173
|
+
aiUsageQuestion: 'Do you want me to analyse how you work with AI? It is optional and takes 1-2 min. I only see which tools you use; your code and your prompts do not leave your machine. I look at the AI usage across this whole machine, which I take to be yours. It helps you stand out for AI projects.',
|
|
172
174
|
aiUsageYes: 'Yes, analyse it',
|
|
173
175
|
aiUsageSkip: 'Skip this step',
|
|
174
176
|
aiUsageSkipped: "No problem, we carry on without it. Heads-up: it is what weighs most for projects to reach you. You can run it whenever you like by telling me 'analyse my AI usage'.",
|
|
@@ -205,10 +207,10 @@ const COPY = {
|
|
|
205
207
|
},
|
|
206
208
|
it: {
|
|
207
209
|
welcome: (name) => `Ciao${name ? `, ${name}` : ''}! Grazie per voler entrare in Shakers 🙌\n\nSiamo una community di professionisti tech freelance. Il nostro obiettivo è avere sempre la tua versione più aggiornata come professionista (cosa sai fare oggi, come lavori con l'IA e cosa cerchi) per metterti in contatto con progetti che fanno davvero per te.\n\nL'iscrizione richiede tre passaggi e la maggior parte la faccio io per te:\n1. Il tuo profilo (2 min). Lo compilo a partire dal tuo CV e da LinkedIn, tu devi solo rivederlo e confermarlo.\n2. Come lavori con l'IA (1-2 min, facoltativo). Analizzo quali strumenti usi su questo computer. Il tuo codice e i tuoi prompt non escono mai dalla tua macchina.\n3. Un breve colloquio (facoltativo). Per definire meglio il tuo ruolo e cosa cerchi. Lo fai sul sito, quando vuoi.\n\nPuoi modificare tutto in seguito dal sito.`,
|
|
208
|
-
welcomeQuestion: 'Iniziamo?
|
|
209
|
-
cvSearch: '
|
|
210
|
-
cvAttach: 'Te lo
|
|
211
|
-
cvAttachAsk: 'Perfetto, allegalo qui.',
|
|
210
|
+
welcomeQuestion: 'Iniziamo? Mandami il tuo CV, oppure, se ce l’hai su questo computer, lo cerco io. Con quello ti preparo una bozza da confermare e basta.',
|
|
211
|
+
cvSearch: 'Cercalo nel mio computer',
|
|
212
|
+
cvAttach: 'Te lo mando',
|
|
213
|
+
cvAttachAsk: 'Perfetto, allegalo qui o dimmi dove si trova nel tuo computer.',
|
|
212
214
|
cvNotFound: 'Non ho trovato il tuo CV su questo computer. Me lo alleghi qui?',
|
|
213
215
|
linkedinAsk: 'Mi mandi il tuo profilo LinkedIn?',
|
|
214
216
|
linkedinOpening: "Ti apro il tuo profilo LinkedIn nel browser così puoi copiarne l'indirizzo.",
|
|
@@ -233,7 +235,8 @@ const COPY = {
|
|
|
233
235
|
existingAnnual: (from, to) => `Tariffa annuale: ${from} → ${to}`,
|
|
234
236
|
existingHours: (from, to) => `Disponibilità: ${from} → ${to}`,
|
|
235
237
|
existingEmpty: 'non indicata',
|
|
236
|
-
|
|
238
|
+
existingUpdateQuestion: 'Aggiorno il tuo profilo con questi dati?',
|
|
239
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: resta ${kept}${kept === requested ? '' : `, non ${requested}`}` : `${label}: nessuna modifica`),
|
|
237
240
|
existingStaysNote: 'Quello che avevi già compilato non lo cambio da qui: se vuoi altri valori, cambiali sul sito.',
|
|
238
241
|
existingLists: (sources) => `Aggiungo le novità ${listOf('it', named(sources, { cv: 'del tuo CV', linkedin: 'del tuo LinkedIn' }))} (esperienze, progetti, formazione) e compilo quello che hai vuoto, senza cancellare nulla.`,
|
|
239
242
|
existingYes: 'Sì, aggiornalo',
|
|
@@ -241,7 +244,7 @@ const COPY = {
|
|
|
241
244
|
existingKept: 'Perfetto, lascio il tuo profilo così com’è.',
|
|
242
245
|
keptLabels: { workSituation: 'Situazione lavorativa', availability: 'Disponibilità', languages: 'Lingue', phone: 'Telefono' },
|
|
243
246
|
existingUnchanged: (items) => `Questi dati del tuo profilo non li ho cambiati, restano com'erano: ${items.join(' · ')}. Se vuoi altri valori, cambiali sul sito.`,
|
|
244
|
-
aiUsageQuestion: "Vuoi che analizzi come lavori con l'IA? È facoltativo e richiede 1-2 min. Vedo solo quali strumenti usi, il tuo codice e i tuoi prompt non escono dalla tua macchina. Ti aiuta a distinguerti nei progetti di IA.",
|
|
247
|
+
aiUsageQuestion: "Vuoi che analizzi come lavori con l'IA? È facoltativo e richiede 1-2 min. Vedo solo quali strumenti usi, il tuo codice e i tuoi prompt non escono dalla tua macchina. Guardo l'uso dell'IA su tutto questo computer, che considero tuo. Ti aiuta a distinguerti nei progetti di IA.",
|
|
245
248
|
aiUsageYes: 'Sì, analizzalo',
|
|
246
249
|
aiUsageSkip: 'Salta questo passaggio',
|
|
247
250
|
aiUsageSkipped: "Nessun problema, andiamo avanti senza. Ti avviso che è ciò che pesa di più perché ti arrivino progetti: puoi lanciarlo quando vuoi dicendomi 'analizza il mio uso dell'IA'.",
|
|
@@ -278,10 +281,10 @@ const COPY = {
|
|
|
278
281
|
},
|
|
279
282
|
pt: {
|
|
280
283
|
welcome: (name) => `Olá${name ? `, ${name}` : ''}! Obrigado por querer juntar-se à Shakers 🙌\n\nSomos uma comunidade de profissionais tech freelance. O nosso objetivo é ter sempre a sua versão mais atual como profissional (o que sabe fazer hoje, como trabalha com IA e o que procura) para o pôr em contacto com projetos que encaixem mesmo consigo.\n\nO registo tem três passos e a maior parte faço eu por si:\n1. O seu perfil (2 min). Preencho-o a partir do seu CV e do LinkedIn; só tem de rever e confirmar.\n2. Como trabalha com IA (1-2 min, opcional). Analiso que ferramentas usa neste computador. O seu código e os seus prompts nunca saem da sua máquina.\n3. Uma breve entrevista (opcional). Para afinar o seu perfil e o que procura. Faz-se na web, quando quiser.\n\nPode editar tudo depois na web.`,
|
|
281
|
-
welcomeQuestion: 'Começamos?
|
|
282
|
-
cvSearch: '
|
|
283
|
-
cvAttach: '
|
|
284
|
-
cvAttachAsk: 'Perfeito, anexe-o aqui.',
|
|
284
|
+
welcomeQuestion: 'Começamos? Envie-me o seu CV ou, se o tiver neste computador, procuro-o eu. Com ele preparo um rascunho para que só tenha de o confirmar.',
|
|
285
|
+
cvSearch: 'Procure-o no meu computador',
|
|
286
|
+
cvAttach: 'Envio-o eu',
|
|
287
|
+
cvAttachAsk: 'Perfeito, anexe-o aqui ou diga-me onde está no seu computador.',
|
|
285
288
|
cvNotFound: 'Não encontrei o seu CV neste computador. Pode anexá-lo aqui?',
|
|
286
289
|
linkedinAsk: 'Pode enviar-me o seu perfil do LinkedIn?',
|
|
287
290
|
linkedinOpening: 'Vou abrir o seu perfil do LinkedIn no navegador para que copie o endereço.',
|
|
@@ -306,7 +309,8 @@ const COPY = {
|
|
|
306
309
|
existingAnnual: (from, to) => `Tarifa anual: ${from} → ${to}`,
|
|
307
310
|
existingHours: (from, to) => `Disponibilidade: ${from} → ${to}`,
|
|
308
311
|
existingEmpty: 'sem indicar',
|
|
309
|
-
|
|
312
|
+
existingUpdateQuestion: 'Atualizo o seu perfil com isto?',
|
|
313
|
+
existingStays: (label, kept, requested) => (kept ? `${label}: fica em ${kept}${kept === requested ? '' : `, não ${requested}`}` : `${label}: sem alterações`),
|
|
310
314
|
existingStaysNote: 'O que já tinha preenchido não o altero daqui: se quiser outros valores, altere-os na web.',
|
|
311
315
|
existingLists: (sources) => `Acrescento o que há de novo ${listOf('pt', named(sources, { cv: 'no seu CV', linkedin: 'no seu LinkedIn' }))} (experiências, projetos, formação) e preencho o que tiver vazio, sem apagar nada.`,
|
|
312
316
|
existingYes: 'Sim, atualize',
|
|
@@ -314,7 +318,7 @@ const COPY = {
|
|
|
314
318
|
existingKept: 'Perfeito, deixo o seu perfil como está.',
|
|
315
319
|
keptLabels: { workSituation: 'Situação profissional', availability: 'Disponibilidade', languages: 'Idiomas', phone: 'Telefone' },
|
|
316
320
|
existingUnchanged: (items) => `Estes dados do seu perfil não os alterei, ficam como estavam: ${items.join(' · ')}. Se quiser outros valores, altere-os na web.`,
|
|
317
|
-
aiUsageQuestion: 'Quer que analise como trabalha com IA? É opcional e demora 1-2 min. Só vejo que ferramentas usa; o seu código e os seus prompts não saem da sua máquina. Ajuda-o a destacar-se em projetos de IA.',
|
|
321
|
+
aiUsageQuestion: 'Quer que analise como trabalha com IA? É opcional e demora 1-2 min. Só vejo que ferramentas usa; o seu código e os seus prompts não saem da sua máquina. Analiso o uso de IA em todo este computador, que considero seu. Ajuda-o a destacar-se em projetos de IA.',
|
|
318
322
|
aiUsageYes: 'Sim, analise',
|
|
319
323
|
aiUsageSkip: 'Saltar este passo',
|
|
320
324
|
aiUsageSkipped: "Sem problema, seguimos sem ele. Aviso-o de que é o que mais pesa para lhe chegarem projetos: pode lançá-lo quando quiser dizendo-me 'analisa o meu uso de IA'.",
|
package/src/terminal-agents.js
CHANGED
|
@@ -114,6 +114,14 @@ function printActivity(report, t, p) {
|
|
|
114
114
|
` ${c.success}●${c.reset} ${a.commits(g.authoredCommitCount, g.commitCount)} ${c.muted}·${c.reset} ${a.lines(g.linesAdded, g.linesDeleted)} ${c.muted}·${c.reset} ${a.velocity(g.velocity)}`,
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
|
+
// Transparency warning: the talent's own commits are going unattributed because
|
|
118
|
+
// the repo's git identity is not their Shakers email (display-only, never scored).
|
|
119
|
+
const mm = report && report.authorshipMismatch;
|
|
120
|
+
if (mm && Array.isArray(mm.emails) && mm.emails.length && mm.accountEmail) {
|
|
121
|
+
for (const line of wrap(a.authorshipMismatch(mm.emails, mm.accountEmail))) {
|
|
122
|
+
p(` ${c.warning}${line}${c.reset}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
117
125
|
if (w) {
|
|
118
126
|
p(
|
|
119
127
|
` ${c.success}●${c.reset} ${a.workStreams(w.streamCount, w.multiDayStreams, w.maxStreamSpanDays)}`,
|
package/src/usage-run-steps.js
CHANGED
|
@@ -30,6 +30,7 @@ const {
|
|
|
30
30
|
collectScopedWorkStreams,
|
|
31
31
|
collectScopedDetectionEvidence,
|
|
32
32
|
} = require('./scope-signals');
|
|
33
|
+
const { detectAuthorshipMismatch } = require('./authorship-mismatch');
|
|
33
34
|
const { MODEL_CALL, REASON, skipped, attachModelCall } = require('./model-call-record');
|
|
34
35
|
const {
|
|
35
36
|
getSynthesisEndpoint,
|
|
@@ -164,6 +165,8 @@ async function resolveAiConsent({ opts, catalog, gate, injectedAsk }) {
|
|
|
164
165
|
function runAggregation({ report, scope, consentEmail, agentRoots, root, catalog }) {
|
|
165
166
|
withStaticStatus(catalog.cli.aggregatingLabel, () => {
|
|
166
167
|
report.gitActivity = collectScopedGitActivity(scope.toplevels, consentEmail);
|
|
168
|
+
// Display-only transparency warning (never feeds scoring, see authorship-mismatch.js).
|
|
169
|
+
report.authorshipMismatch = detectAuthorshipMismatch(scope.toplevels, consentEmail, report.gitActivity);
|
|
167
170
|
const sessionSignals = collectSessionSignals(process.env, scope.selectedCwds);
|
|
168
171
|
report.sessions = sessionSignals.sessions;
|
|
169
172
|
report.workStreams = collectScopedWorkStreams(scope.toplevels);
|