shakers-cli 0.15.0 → 0.17.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 +56 -1203
- package/bin/ai-usage.js +32 -58
- package/bin/login.js +10 -10
- package/bin/mcp.js +17 -12
- package/bin/mcpb.js +1 -1
- package/bin/onboarding.js +3 -2
- package/bin/register.js +19 -5
- package/bin/report-html.js +2 -2
- package/bin/shakers-cli.js +352 -0
- package/bin/shakers.js +20 -344
- package/bin/share.js +1 -1
- package/bin/superadmin.js +5 -5
- package/bin/version.js +3 -0
- package/package.json +12 -3
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/agent-category.js +1 -1
- package/src/agent-evaluation.js +3 -3
- package/src/agent-org-chart.js +5 -5
- package/src/agent-synthesis.js +3 -3
- package/src/agent-usage.js +1 -1
- package/src/agents-client.js +4 -4
- package/src/ai-usage-review.js +101 -0
- package/src/ai.js +1 -1
- package/src/ansi.js +1 -1
- package/src/auth-client.js +18 -4
- package/src/auth-method-picker.js +1 -1
- package/src/auth-methods.js +1 -1
- package/src/auth-session-store.js +18 -8
- package/src/authorship-mismatch.js +1 -1
- package/src/authorship.js +2 -2
- package/src/automations-detector.js +1 -1
- package/src/backend-request.js +5 -3
- package/src/brand-ansi.js +1 -1
- package/src/browser-tools-detector.js +1 -1
- package/src/certify-disclaimer.js +1 -1
- package/src/certify-remediation-prompt.js +1 -1
- package/src/cli-args.js +2 -2
- package/src/command-audit.js +8 -5
- package/src/config-dir.js +1 -1
- package/src/config.js +114 -48
- package/src/consent-flow.js +7 -6
- package/src/consent-skip.js +1 -1
- package/src/detection-evidence.js +2 -2
- package/src/detection-noise.js +1 -1
- package/src/detectors.js +1 -1
- package/src/device-login.js +12 -1
- package/src/email-verification.js +3 -3
- package/src/env-paths.js +1 -1
- package/src/find-projects-client.js +12 -0
- package/src/find-projects-flow.js +1 -13
- package/src/git-activity.js +1 -1
- package/src/graph-certs.js +3 -3
- package/src/graph-scan.js +9 -9
- package/src/i18n.js +148 -138
- package/src/interactive-select.js +1 -1
- package/src/interview-client.js +6 -1
- package/src/interview-transcript.js +1 -1
- package/src/legal-notice.js +1 -1
- package/src/maturity.js +6 -6
- package/src/mcp-ai-usage-result-tool.js +34 -12
- package/src/mcp-ai-usage-tool.js +282 -163
- package/src/mcp-auth-form.js +7 -3
- package/src/mcp-detector.js +3 -3
- package/src/mcp-device-login-tools.js +2 -1
- package/src/mcp-find-projects-tools.js +1 -2
- package/src/mcp-install.js +279 -44
- package/src/mcp-instructions.js +1 -1
- package/src/mcp-onboarding-tools.js +4 -4
- package/src/mcp-prompts.js +4 -4
- package/src/mcp-register-tools.js +1 -0
- package/src/mcp-role-tools.js +24 -1
- package/src/mcp-server.js +5 -1
- package/src/mcp-signup-client.js +2 -2
- package/src/mcp-signup-tools.js +39 -9
- package/src/memory-structure-detector.js +1 -1
- package/src/model-call-record.js +2 -2
- package/src/onboarding-flow.js +63 -42
- package/src/onboarding-funnel.js +153 -20
- package/src/open-file.js +1 -1
- package/src/osc-link.js +1 -1
- package/src/portfolio-client.js +11 -1
- package/src/portfolio-git-info.js +1 -1
- package/src/profile-flow.js +1 -1
- package/src/profile-switch.js +5 -12
- package/src/render-certification.js +10 -10
- package/src/render-html.js +6 -6
- package/src/render-report-text.js +1 -1
- package/src/render-sheet.js +10 -10
- package/src/render-terminal.js +6 -6
- package/src/repo-scope.js +132 -0
- package/src/report-gating.js +5 -3
- package/src/report-store.js +13 -8
- package/src/roadmap-content.js +5 -5
- package/src/roadmap-personalization.js +3 -3
- package/src/roadmap-prompt.js +2 -2
- package/src/roles-flow.js +8 -5
- package/src/scan-exclusions.js +1 -1
- package/src/scanner.js +14 -14
- package/src/secret-prompt.js +1 -1
- package/src/session-gate.js +1 -1
- package/src/session-refresh.js +33 -3
- package/src/session-scan.js +1 -1
- package/src/shakers-wordmark.js +1 -1
- package/src/share-card.js +2 -2
- package/src/share-certification.js +4 -4
- package/src/share-consent-state.js +9 -9
- package/src/share-payload.js +7 -7
- package/src/share.js +7 -7
- package/src/signup-client.js +1 -1
- package/src/signup-copy.js +177 -2
- package/src/skills-client.js +1 -1
- package/src/start-add-agents.js +2 -2
- package/src/start-add-portfolio.js +3 -13
- package/src/start-add-skills.js +2 -2
- package/src/stdin-ask.js +16 -4
- package/src/strip-internal-comments.js +1 -1
- package/src/tech-detector.js +2 -2
- package/src/templates/report-sheet.html +11 -13
- package/src/terminal-agents.js +8 -8
- package/src/terminal-format.js +3 -3
- package/src/terminal-sections.js +11 -11
- package/src/tier-engine.js +3 -3
- package/src/tier-projection.js +2 -2
- package/src/transcript-turns.js +1 -1
- package/src/typewriter.js +1 -1
- package/src/usage-model-calls.js +6 -6
- package/src/usage-oneshot-commands.js +5 -5
- package/src/usage-repo-scope.js +11 -129
- package/src/usage-run-steps.js +82 -181
- package/src/usage-scan.js +235 -0
- package/src/work-streams.js +1 -1
package/src/mcp-signup-tools.js
CHANGED
|
@@ -13,7 +13,7 @@ const { askAfterTexts, askConsent, optionFor, queueNotice, takeNotices } = requi
|
|
|
13
13
|
const { expandHome, tildify } = require('./env-paths');
|
|
14
14
|
const { NOOP_FUNNEL, weeklyHours, rateTypes, workSituationValue } = require('./onboarding-funnel');
|
|
15
15
|
|
|
16
|
-
// MCP sign-up
|
|
16
|
+
// MCP sign-up: hub imports into an UNREGISTERED profile that the local window claims with a code kept only in memory.
|
|
17
17
|
|
|
18
18
|
const SIGNUP_WINDOW_TIMEOUT_MS = 15 * 60 * 1000;
|
|
19
19
|
// The browser opens this long after the tool answers, so the AI's reply is on screen first.
|
|
@@ -302,6 +302,7 @@ function makeSignupTools(deps = {}) {
|
|
|
302
302
|
pollDeviceToken = require('./device-login').pollDeviceToken,
|
|
303
303
|
finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
|
|
304
304
|
appendProviderParam = require('./device-login').appendProviderParam,
|
|
305
|
+
deviceSessionRecord = require('./device-login').deviceSessionRecord,
|
|
305
306
|
buildRegistrationContext = require('./signup-client').buildRegistrationContext,
|
|
306
307
|
runLoopbackAuth = require('./mcp-auth-form').runLoopbackAuth,
|
|
307
308
|
checkOnboardingCompleted = require('./onboarding-availability').checkOnboardingCompleted,
|
|
@@ -337,6 +338,7 @@ function makeSignupTools(deps = {}) {
|
|
|
337
338
|
wake: null,
|
|
338
339
|
importStartedAt: null,
|
|
339
340
|
importReported: new Set(),
|
|
341
|
+
authAttempts: undefined,
|
|
340
342
|
};
|
|
341
343
|
currentSignup = state;
|
|
342
344
|
|
|
@@ -344,8 +346,12 @@ function makeSignupTools(deps = {}) {
|
|
|
344
346
|
const before = state.account.state;
|
|
345
347
|
state.account = next;
|
|
346
348
|
if ((next.state === 'created' || next.state === 'existing') && before !== next.state) {
|
|
347
|
-
funnel.completed('account_creation', { auth_method: next.via, is_returning_account: next.state === 'existing' });
|
|
348
|
-
funnel.identified();
|
|
349
|
+
funnel.completed('account_creation', { auth_method: next.via, auth_attempts: state.authAttempts, is_returning_account: next.state === 'existing' });
|
|
350
|
+
funnel.identified({ newAccount: next.state === 'created' });
|
|
351
|
+
}
|
|
352
|
+
if (next.state === 'failed' && before !== 'failed') {
|
|
353
|
+
funnel.errored('account_creation', next.reason);
|
|
354
|
+
funnel.flush();
|
|
349
355
|
}
|
|
350
356
|
if (state.wake) state.wake();
|
|
351
357
|
}
|
|
@@ -385,7 +391,7 @@ function makeSignupTools(deps = {}) {
|
|
|
385
391
|
}
|
|
386
392
|
const email = res.email || emailFromHubToken(fin.token);
|
|
387
393
|
try {
|
|
388
|
-
resolved.saveAuthSession({
|
|
394
|
+
resolved.saveAuthSession(deviceSessionRecord({ token: fin.token, sessionToken: res.accessToken, sessionExpiresIn: res.expiresIn, email }));
|
|
389
395
|
} catch {
|
|
390
396
|
setAccount({ state: 'failed', reason: 'session-persist-failed' });
|
|
391
397
|
return;
|
|
@@ -434,6 +440,7 @@ function makeSignupTools(deps = {}) {
|
|
|
434
440
|
onUrl: (url) => { state.windowUrl = url; announce(url); },
|
|
435
441
|
},
|
|
436
442
|
).then((outcome) => {
|
|
443
|
+
if (outcome && outcome.ok) state.authAttempts = outcome.attempts;
|
|
437
444
|
if (outcome && outcome.viaSocial) return;
|
|
438
445
|
if (!outcome || !outcome.ok) {
|
|
439
446
|
setAccount({ state: 'failed', reason: (outcome && outcome.reason) || 'window-failed' });
|
|
@@ -482,6 +489,7 @@ function makeSignupTools(deps = {}) {
|
|
|
482
489
|
// rides on the next question block, because models skip a relay that comes before another tool call.
|
|
483
490
|
if (!nonEmpty(args.answer) && state.cvPath) {
|
|
484
491
|
funnel.completed('start');
|
|
492
|
+
funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
|
|
485
493
|
queueNotice(c.welcome(nonEmpty(args.firstName)));
|
|
486
494
|
return {
|
|
487
495
|
ok: true,
|
|
@@ -500,6 +508,7 @@ function makeSignupTools(deps = {}) {
|
|
|
500
508
|
});
|
|
501
509
|
if (asked.reply) return { ...asked.reply, language: lang() };
|
|
502
510
|
funnel.completed('start');
|
|
511
|
+
funnel.viewed('import_sources', { sources_offered: DRAFT_SOURCES });
|
|
503
512
|
// A CV path the talent gave wins over both answers: searching could read another candidate.
|
|
504
513
|
const cvPath = state.cvPath;
|
|
505
514
|
if (cvPath) {
|
|
@@ -576,6 +585,7 @@ function makeSignupTools(deps = {}) {
|
|
|
576
585
|
return { ok: false, reason: 'draft-sources-required', message: "The draft needs sources: 'cv' if you read their CV, 'linkedin' only if you have their LinkedIn URL. Then call signup_draft again." };
|
|
577
586
|
}
|
|
578
587
|
if (!state.firstDraft) state.firstDraft = draft;
|
|
588
|
+
trackDraftShown(draft);
|
|
579
589
|
const asked = await askAfterTexts(ctx, {
|
|
580
590
|
tool: 'signup_draft',
|
|
581
591
|
texts: [renderDraft(lang(), draft), legalCopy(lang()).signupNotice(sources), knownAccount() ? c.windowNoticeSignIn : c.windowNotice],
|
|
@@ -659,10 +669,10 @@ function makeSignupTools(deps = {}) {
|
|
|
659
669
|
const attempted = DRAFT_SOURCES.filter((source) => (source === 'cv' ? !!cv : !!sources.linkedinUrl));
|
|
660
670
|
state.importStartedAt = Object.fromEntries(attempted.map((source) => [source, now()]));
|
|
661
671
|
for (const source of attempted) funnel.track('source_import_started', 'import_sources', { source_type: source });
|
|
662
|
-
funnel.completed('import_sources', { sources_attempted: attempted });
|
|
672
|
+
funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
|
|
663
673
|
}
|
|
664
674
|
if (!reused) {
|
|
665
|
-
funnel.viewed('account_creation');
|
|
675
|
+
funnel.viewed('account_creation', { auth_methods_offered: require('./mcp-auth-form').WINDOW_METHODS.register });
|
|
666
676
|
await openWindow(identity);
|
|
667
677
|
}
|
|
668
678
|
return {
|
|
@@ -708,7 +718,7 @@ function makeSignupTools(deps = {}) {
|
|
|
708
718
|
|
|
709
719
|
function nextSteps(account) {
|
|
710
720
|
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 45), then set_main_role without clusterId only when its next says so. Finally open_web.';
|
|
711
|
-
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).';
|
|
721
|
+
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). After a yes, ai_usage_upload (waitSeconds 30) shows the talent what would be sent and asks; nothing is uploaded without their pick.';
|
|
712
722
|
if (account.state === 'existing') {
|
|
713
723
|
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}`;
|
|
714
724
|
}
|
|
@@ -951,6 +961,21 @@ function makeSignupTools(deps = {}) {
|
|
|
951
961
|
return c.ratesSaved(shown, !(state.draft && state.draft.ratesFromTalent));
|
|
952
962
|
}
|
|
953
963
|
|
|
964
|
+
function trackDraftShown(draft) {
|
|
965
|
+
const prefilled = (step) => ({ is_prefilled: STEP_DRAFT_FIELDS[step].some((key) => draft[key] !== undefined) });
|
|
966
|
+
funnel.viewed('work_situation', prefilled('work_situation'));
|
|
967
|
+
funnel.viewed('availability', prefilled('availability'));
|
|
968
|
+
funnel.viewed('rates', { suggested_rate_shown: draft.ratesFromTalent !== true });
|
|
969
|
+
funnel.viewed('languages', prefilled('languages'));
|
|
970
|
+
funnel.viewed('location', prefilled('location'));
|
|
971
|
+
funnel.viewed('profile_summary');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function draftFieldsEdited() {
|
|
975
|
+
if (!state.firstDraft || !state.draft) return undefined;
|
|
976
|
+
return DRAFT_FIELDS.filter((key) => String(state.firstDraft[key] ?? '') !== String(state.draft[key] ?? '')).length;
|
|
977
|
+
}
|
|
978
|
+
|
|
954
979
|
function wasEdited(step) {
|
|
955
980
|
if (!state.firstDraft || !state.draft) return undefined;
|
|
956
981
|
return STEP_DRAFT_FIELDS[step].some((key) => String(state.firstDraft[key] ?? '') !== String(state.draft[key] ?? ''));
|
|
@@ -958,7 +983,8 @@ function makeSignupTools(deps = {}) {
|
|
|
958
983
|
|
|
959
984
|
function trackSavedSteps(details, results) {
|
|
960
985
|
const saved = (section) => results[section] && results[section].ok === true;
|
|
961
|
-
const
|
|
986
|
+
const draftTime = funnel.stepTime('profile_summary');
|
|
987
|
+
const step = (name, props) => funnel.completed(name, { ...props, is_prefilled: true, was_edited: wasEdited(name), time_on_step_ms: draftTime });
|
|
962
988
|
if (saved('workSituation')) {
|
|
963
989
|
step('work_situation', { work_situation: workSituationValue(String(details.workSituation.situation || '').trim().toUpperCase()) });
|
|
964
990
|
}
|
|
@@ -976,12 +1002,16 @@ function makeSignupTools(deps = {}) {
|
|
|
976
1002
|
}
|
|
977
1003
|
if (saved('pricing')) {
|
|
978
1004
|
const types = rateTypes(pricingFromRates(details.pricing).input);
|
|
979
|
-
funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates') });
|
|
1005
|
+
funnel.completed('rates', { rate_types_selected: types, rate_type: types[0], rate_currency: 'EUR', is_prefilled: !(state.draft && state.draft.ratesFromTalent), was_edited: wasEdited('rates'), time_on_step_ms: draftTime });
|
|
980
1006
|
}
|
|
981
1007
|
if (saved('languages')) {
|
|
982
1008
|
const codes = details.languages.map((row) => String(row.language || '').trim().toLowerCase()).filter(Boolean);
|
|
983
1009
|
step('languages', { languages_count: codes.length, languages: codes });
|
|
984
1010
|
}
|
|
1011
|
+
const sections = Object.entries(results).filter(([name]) => name !== 'phone');
|
|
1012
|
+
if (sections.length && sections.every(([, r]) => r.ok === true)) {
|
|
1013
|
+
funnel.completed('profile_summary', { fields_edited_count: draftFieldsEdited(), time_on_step_ms: draftTime });
|
|
1014
|
+
}
|
|
985
1015
|
funnel.flush();
|
|
986
1016
|
}
|
|
987
1017
|
|
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { getHomeDir } = require('./env-paths');
|
|
6
6
|
|
|
7
|
-
// Deterministic (no-LLM) memory STRUCTURE detector
|
|
7
|
+
// Deterministic (no-LLM) memory STRUCTURE detector.
|
|
8
8
|
|
|
9
9
|
const MAX_FILE_BYTES = 2_000_000; // safety cap: never read a pathologically huge file
|
|
10
10
|
const MAX_IMPORT_DEPTH = 5; // mirrors Claude Code's own documented import-hop limit
|
package/src/model-call-record.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// What happened with each model call, written down
|
|
3
|
+
// What happened with each model call, written down.
|
|
4
4
|
|
|
5
5
|
// The three keys.
|
|
6
6
|
const MODEL_CALL = Object.freeze({
|
|
@@ -163,7 +163,7 @@ function callPartial(report, key) {
|
|
|
163
163
|
return !!c && c.ok === true && Array.isArray(c.omitted) && c.omitted.length > 0;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
// THE CALL LOST TIME TO A DEADLINE — and this is NOT `reason === TIMEOUT` (
|
|
166
|
+
// THE CALL LOST TIME TO A DEADLINE — and this is NOT `reason === TIMEOUT` (found by measuring, not by reading).
|
|
167
167
|
function callTimedOut(report, key) {
|
|
168
168
|
const c = modelCall(report, key);
|
|
169
169
|
if (!c || c.ok !== false) return false;
|
package/src/onboarding-flow.js
CHANGED
|
@@ -168,7 +168,7 @@ function normalizeAmount(value) {
|
|
|
168
168
|
return cents / 100;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// Pricing input
|
|
171
|
+
// Pricing input.
|
|
172
172
|
function validatePricing(input = {}) {
|
|
173
173
|
const fullSelected = input.fullTimeSelected === true;
|
|
174
174
|
const partSelected = input.partTimeSelected === true;
|
|
@@ -212,7 +212,7 @@ function buildPricingUpsert({ fullSelected, fullAmount, fullCurrency, partSelect
|
|
|
212
212
|
};
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
// Availability + location
|
|
215
|
+
// Availability + location.
|
|
216
216
|
function validateAvailability(input = {}) {
|
|
217
217
|
const body = { confirmed: true };
|
|
218
218
|
if (typeof input.available === 'boolean') body.available = input.available;
|
|
@@ -299,7 +299,6 @@ function makeDeps(overrides = {}) {
|
|
|
299
299
|
runMainRoleStep: require('./roles-flow').runOnboardingMainRoleStep,
|
|
300
300
|
makeRolesDeps: require('./roles-flow').makeRolesDeps,
|
|
301
301
|
requestDiscoveredInventory: inventory.requestDiscoveredInventory,
|
|
302
|
-
makeInterviewClient: (opts) => new (require('./interview-client').InterviewClient)(opts),
|
|
303
302
|
// Used by reattachConsentIdentity (A) to re-anchor the ingest identity.
|
|
304
303
|
recordConsent: require('./share-consent-state').recordConsent,
|
|
305
304
|
...overrides,
|
|
@@ -371,7 +370,7 @@ async function establishSessionFromCredentials(deps, { email, password } = {}) {
|
|
|
371
370
|
|
|
372
371
|
// Register a talent via a social provider (device flow, RFC 8628); a live session results either way.
|
|
373
372
|
async function registerWithSocialAccount(deps, { provider = 'google', freelanceType, language } = {}, { openBrowser, onAuthUrl } = {}) {
|
|
374
|
-
const { appendProviderParam } = require('./device-login');
|
|
373
|
+
const { appendProviderParam, deviceSessionRecord } = require('./device-login');
|
|
375
374
|
const authorizeEndpoint = deps.getDeviceAuthorizeEndpoint();
|
|
376
375
|
const tokenEndpoint = deps.getDeviceTokenEndpoint();
|
|
377
376
|
const authTokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
|
|
@@ -397,13 +396,8 @@ async function registerWithSocialAccount(deps, { provider = 'google', freelanceT
|
|
|
397
396
|
);
|
|
398
397
|
if (!result.ok) return { ok: false, reason: result.reason || 'register-failed' };
|
|
399
398
|
|
|
400
|
-
// Persist the API JWT the exchange minted (no cookie), like the retired Google flow did.
|
|
401
399
|
try {
|
|
402
|
-
deps.saveAuthSession(
|
|
403
|
-
accessToken: result.token,
|
|
404
|
-
hubAccessToken: result.token,
|
|
405
|
-
email: result.email || null,
|
|
406
|
-
});
|
|
400
|
+
deps.saveAuthSession(deviceSessionRecord(result));
|
|
407
401
|
} catch {
|
|
408
402
|
return { ok: false, reason: 'session-persist-failed' };
|
|
409
403
|
}
|
|
@@ -675,18 +669,15 @@ function trackImport(funnel, attempted, importRes, durationMs) {
|
|
|
675
669
|
const bySource = new Map();
|
|
676
670
|
const report = importRes.ok && importRes.report ? importRes.report.sources : [];
|
|
677
671
|
for (const src of report) bySource.set(sourceTypeFor(src.source), src);
|
|
678
|
-
const connected = [];
|
|
679
672
|
for (const type of attempted) {
|
|
680
673
|
const src = bySource.get(type);
|
|
681
674
|
const ok = importRes.ok && (!src || src.status === 'imported');
|
|
682
675
|
if (ok) {
|
|
683
|
-
connected.push(type);
|
|
684
676
|
funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
|
|
685
677
|
} else {
|
|
686
678
|
funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
|
|
687
679
|
}
|
|
688
680
|
}
|
|
689
|
-
return connected;
|
|
690
681
|
}
|
|
691
682
|
|
|
692
683
|
// Blocking orchestrator used by the CLI (`bin/onboarding.js`).
|
|
@@ -695,9 +686,16 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
695
686
|
const c = getCatalog(io.lang).onboarding;
|
|
696
687
|
|
|
697
688
|
funnel.begin();
|
|
689
|
+
funnel.viewed('start');
|
|
698
690
|
funnel.completed('start');
|
|
699
691
|
io.section(c.signupTitle);
|
|
700
|
-
funnel.viewed('account_creation');
|
|
692
|
+
funnel.viewed('account_creation', { auth_methods_offered: require('./auth-methods').methodIdsForFlow('register') });
|
|
693
|
+
funnel.flush();
|
|
694
|
+
const accountFailed = (reason) => {
|
|
695
|
+
funnel.errored('account_creation', reason);
|
|
696
|
+
funnel.flush();
|
|
697
|
+
return { ok: false, reason, step: 'signup' };
|
|
698
|
+
};
|
|
701
699
|
|
|
702
700
|
if (method === 'google' || method === 'linkedin') {
|
|
703
701
|
// The provider supplies name/email; freelanceType is derived later from work situation.
|
|
@@ -709,10 +707,10 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
709
707
|
);
|
|
710
708
|
if (!reg.ok) {
|
|
711
709
|
io.error(c.signupError);
|
|
712
|
-
return
|
|
710
|
+
return accountFailed(reg.reason);
|
|
713
711
|
}
|
|
714
|
-
funnel.completed('account_creation', { auth_method: method, is_returning_account: reg.kind === 'account-already-exists' });
|
|
715
|
-
funnel.identified();
|
|
712
|
+
funnel.completed('account_creation', { auth_method: method, auth_attempts: 1, is_returning_account: reg.kind === 'account-already-exists' });
|
|
713
|
+
funnel.identified({ newAccount: reg.kind !== 'account-already-exists' });
|
|
716
714
|
if (reg.kind === 'account-already-exists') {
|
|
717
715
|
// Existing account: already signed in — stop, no wizard re-run.
|
|
718
716
|
io.warn(c.accountExists);
|
|
@@ -721,39 +719,39 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
721
719
|
notifyOk(io, c.accountReady);
|
|
722
720
|
} else {
|
|
723
721
|
const account = await io.askSignUp(c);
|
|
724
|
-
if (!account || !account.email) return
|
|
722
|
+
if (!account || !account.email) return accountFailed('missing-fields');
|
|
725
723
|
if (!isAcceptablePassword(account.password)) {
|
|
726
724
|
io.error(c.weakPassword);
|
|
727
|
-
return
|
|
725
|
+
return accountFailed('weak-password');
|
|
728
726
|
}
|
|
729
727
|
|
|
730
728
|
const su = await signUp(deps, { ...account, preferredLanguage: account.preferredLanguage || io.lang });
|
|
731
729
|
if (!su.ok) {
|
|
732
730
|
io.error(su.message || c.signupError);
|
|
733
|
-
return
|
|
731
|
+
return accountFailed(su.reason);
|
|
734
732
|
}
|
|
735
733
|
|
|
736
734
|
if (su.accountExists) {
|
|
737
735
|
// Existing account: sign them in, then STOP — no wizard re-run.
|
|
738
736
|
io.warn(c.accountExists);
|
|
739
737
|
const logged = await io.login(account.email);
|
|
740
|
-
if (!logged) return
|
|
741
|
-
funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
|
|
742
|
-
funnel.identified();
|
|
738
|
+
if (!logged) return accountFailed('login-failed');
|
|
739
|
+
funnel.completed('account_creation', { auth_method: 'email', auth_attempts: 1, is_returning_account: true });
|
|
740
|
+
funnel.identified({ newAccount: false });
|
|
743
741
|
return { ok: true, alreadyRegistered: true };
|
|
744
742
|
}
|
|
745
743
|
const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
|
|
746
744
|
if (!sess.ok) {
|
|
747
745
|
io.error(c.sessionError);
|
|
748
|
-
return
|
|
746
|
+
return accountFailed(sess.reason);
|
|
749
747
|
}
|
|
750
|
-
funnel.completed('account_creation', { auth_method: 'email', is_returning_account: false });
|
|
748
|
+
funnel.completed('account_creation', { auth_method: 'email', auth_attempts: 1, is_returning_account: false });
|
|
751
749
|
funnel.identified();
|
|
752
750
|
notifyOk(io, c.accountReady);
|
|
753
751
|
}
|
|
754
752
|
|
|
755
753
|
io.section(c.title);
|
|
756
|
-
funnel.viewed('import_sources');
|
|
754
|
+
funnel.viewed('import_sources', { sources_offered: ['linkedin', 'cv', 'github', 'web_page'] });
|
|
757
755
|
const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
|
|
758
756
|
if (!linkedinUrl) {
|
|
759
757
|
funnel.errored('import_sources', 'no-linkedin', { error_field: 'linkedin' });
|
|
@@ -772,15 +770,15 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
772
770
|
const importStartedAt = Date.now();
|
|
773
771
|
const importRes = await withProgress(c.importChecking, () =>
|
|
774
772
|
startProfileImport(deps, { linkedinUrl, cvPath, githubUrl, websiteUrl, language: io.lang }));
|
|
775
|
-
|
|
776
|
-
funnel.completed('import_sources', { sources_attempted: attempted, sources_connected:
|
|
773
|
+
trackImport(funnel, attempted, importRes, Date.now() - importStartedAt);
|
|
774
|
+
funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
|
|
777
775
|
funnel.flush();
|
|
778
776
|
if (importRes.ok) notifyOk(io, c.importDone);
|
|
779
777
|
else io.warn(c.importUnavailable);
|
|
780
778
|
|
|
781
779
|
// Steps 4-8 are saved out of funnel order: sent together once all are answered.
|
|
782
780
|
io.section(c.professionalTitle);
|
|
783
|
-
funnel.viewed('work_situation');
|
|
781
|
+
funnel.viewed('work_situation', { is_prefilled: false });
|
|
784
782
|
const workSituationInput = await io.askWorkSituation(c);
|
|
785
783
|
const workSituationRes = await saveWorkSituation(deps, workSituationInput);
|
|
786
784
|
if (!workSituationRes.ok) {
|
|
@@ -788,11 +786,11 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
788
786
|
funnel.flush();
|
|
789
787
|
return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
|
|
790
788
|
}
|
|
791
|
-
funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()) });
|
|
789
|
+
funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()), was_edited: true });
|
|
792
790
|
notifyOk(io, c.professionalSaved);
|
|
793
791
|
|
|
794
792
|
io.section(c.pricingTitle);
|
|
795
|
-
funnel.viewed('rates');
|
|
793
|
+
funnel.viewed('rates', { suggested_rate_shown: false });
|
|
796
794
|
const pricingInput = await io.askPricing(c);
|
|
797
795
|
const priceRes = await savePricing(deps, pricingInput);
|
|
798
796
|
if (!priceRes.ok) {
|
|
@@ -805,23 +803,31 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
805
803
|
rate_types_selected: selectedRates,
|
|
806
804
|
rate_type: selectedRates[0],
|
|
807
805
|
rate_currency: normalizeCurrency(pricingInput.partTimeSelected === true ? pricingInput.partTimeCurrency : pricingInput.fullTimeCurrency) || undefined,
|
|
806
|
+
was_edited: true,
|
|
808
807
|
});
|
|
809
808
|
notifyOk(io, c.pricingSaved);
|
|
810
809
|
|
|
811
810
|
// Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
|
|
812
811
|
io.section(c.availabilityTitle);
|
|
813
|
-
funnel.viewed('availability');
|
|
812
|
+
funnel.viewed('availability', { is_prefilled: false });
|
|
814
813
|
if (await io.confirm(c.availabilityAsk)) {
|
|
815
|
-
|
|
814
|
+
let availabilityTime;
|
|
815
|
+
const availabilityInput = await io.askAvailability(c, {
|
|
816
|
+
onLocation: () => {
|
|
817
|
+
availabilityTime = funnel.stepTime('availability');
|
|
818
|
+
funnel.viewed('location', { is_prefilled: false });
|
|
819
|
+
},
|
|
820
|
+
});
|
|
816
821
|
const availRes = await saveAvailability(deps, availabilityInput);
|
|
817
822
|
if (availRes.ok) {
|
|
818
|
-
funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)) });
|
|
823
|
+
funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)), time_on_step_ms: availabilityTime, was_edited: true });
|
|
819
824
|
const country = normalizeCountry(availabilityInput.country);
|
|
820
825
|
const modes = Array.isArray(availabilityInput.workModes) ? availabilityInput.workModes.map((m) => String(m).trim().toUpperCase()) : [];
|
|
821
826
|
funnel.completed('location', {
|
|
822
827
|
location_country: country ? country.toLowerCase() : undefined,
|
|
823
828
|
location_method: 'manual',
|
|
824
829
|
is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
|
|
830
|
+
was_edited: true,
|
|
825
831
|
});
|
|
826
832
|
notifyOk(io, c.availabilitySaved);
|
|
827
833
|
} else {
|
|
@@ -839,13 +845,13 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
839
845
|
}
|
|
840
846
|
|
|
841
847
|
io.section(c.languagesTitle);
|
|
842
|
-
funnel.viewed('languages');
|
|
848
|
+
funnel.viewed('languages', { is_prefilled: false });
|
|
843
849
|
if (await io.confirm(c.languagesAsk)) {
|
|
844
850
|
const languageRows = await io.askLanguages(c);
|
|
845
851
|
const langRes = await saveLanguages(deps, languageRows);
|
|
846
852
|
if (langRes.ok) {
|
|
847
853
|
const codes = validateLanguageRows(languageRows).rows.map((r) => r.language);
|
|
848
|
-
funnel.completed('languages', { languages_count: codes.length, languages: codes });
|
|
854
|
+
funnel.completed('languages', { languages_count: codes.length, languages: codes, was_edited: true });
|
|
849
855
|
notifyOk(io, c.languagesSaved);
|
|
850
856
|
} else {
|
|
851
857
|
funnel.errored('languages', langRes.reason);
|
|
@@ -888,20 +894,35 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
888
894
|
// Every ending points the Talent at their profile, and opens it when a browser is at hand.
|
|
889
895
|
const showProfile = () => {
|
|
890
896
|
const url = profileUrl(deps);
|
|
897
|
+
funnel.viewed('profile_summary');
|
|
891
898
|
notifyOk(io, url ? c.profileReadyUrl(url) : c.profileReadyNoUrl);
|
|
892
899
|
if (url && typeof openProfile === 'function') {
|
|
893
900
|
try { openProfile(url); } catch { /* the printed link covers it */ }
|
|
894
901
|
}
|
|
902
|
+
funnel.completed('profile_summary');
|
|
903
|
+
funnel.flush();
|
|
895
904
|
return url;
|
|
896
905
|
};
|
|
897
906
|
|
|
898
907
|
const mainRole = async () => {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
908
|
+
let shown = null;
|
|
909
|
+
const role = await runMainRoleFinalStep(io, deps, {
|
|
910
|
+
onRolesShown: (info) => {
|
|
911
|
+
shown = info;
|
|
912
|
+
funnel.viewed('supercluster', { suggested_superclusters: info.suggested, suggestion_source: 'assigned_clusters', suggestions_count: info.suggested.length });
|
|
913
|
+
},
|
|
914
|
+
});
|
|
915
|
+
if (role && role.ok && role.chosen) {
|
|
916
|
+
funnel.completed('supercluster', {
|
|
917
|
+
supercluster: role.chosen,
|
|
918
|
+
clusters_count: shown ? shown.clustersCount : undefined,
|
|
919
|
+
was_suggestion_accepted: shown ? shown.suggested.includes(role.chosen) : undefined,
|
|
920
|
+
});
|
|
921
|
+
} else if (role && role.ok) funnel.skipped('supercluster');
|
|
902
922
|
funnel.flush();
|
|
903
923
|
};
|
|
904
924
|
|
|
925
|
+
funnel.holdUntil('profile_summary');
|
|
905
926
|
io.section(c.interviewTitle);
|
|
906
927
|
funnel.viewed('pre_interview');
|
|
907
928
|
io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
|
|
@@ -1010,14 +1031,14 @@ async function conductLivekitInterview(io, deps, { interviewId = null, language
|
|
|
1010
1031
|
}
|
|
1011
1032
|
|
|
1012
1033
|
// Runs the final main-role step, degrading softly (never aborts the flow / never throws).
|
|
1013
|
-
async function runMainRoleFinalStep(io, deps,
|
|
1014
|
-
const active =
|
|
1034
|
+
async function runMainRoleFinalStep(io, deps, { onRolesShown } = {}) {
|
|
1035
|
+
const active = typeof deps.loadAuthSession === 'function' ? deps.loadAuthSession() : null;
|
|
1015
1036
|
if (typeof deps.runMainRoleStep !== 'function' || !active) return;
|
|
1016
1037
|
const { getCatalog } = require('./i18n');
|
|
1017
1038
|
const catalog = getCatalog(io.lang);
|
|
1018
1039
|
try {
|
|
1019
1040
|
const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
|
|
1020
|
-
return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
|
|
1041
|
+
return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps, onRolesShown });
|
|
1021
1042
|
} catch {
|
|
1022
1043
|
return null;
|
|
1023
1044
|
}
|