shakers-cli 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -6
- package/bin/ai-usage.js +31 -12
- package/bin/command-audit-worker.js +19 -0
- package/bin/login.js +4 -8
- package/bin/mcp.js +32 -21
- package/bin/register.js +12 -30
- package/bin/shakers-cli.js +15 -10
- package/package.json +1 -1
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/agents-client.js +1 -0
- package/src/ai-usage-review.js +103 -0
- package/src/auth-client.js +7 -107
- package/src/auth-session-store.js +12 -11
- package/src/backend-request.js +3 -24
- package/src/cli-skill.js +77 -0
- package/src/command-audit.js +47 -4
- package/src/config.js +2 -16
- package/src/consent-flow.js +9 -3
- package/src/detect-repos.js +4 -8
- package/src/detectors.js +23 -0
- package/src/device-auth-store.js +2 -2
- package/src/device-login.js +11 -9
- package/src/env-paths.js +9 -1
- package/src/i18n.js +186 -102
- package/src/mcp-ai-usage-result-tool.js +38 -14
- package/src/mcp-ai-usage-tool.js +317 -172
- package/src/mcp-choice.js +171 -34
- package/src/mcp-detector.js +26 -2
- package/src/mcp-device-login-tools.js +40 -8
- package/src/mcp-install.js +292 -53
- package/src/mcp-instructions.js +43 -17
- package/src/mcp-onboarding-tools.js +1 -3
- package/src/mcp-prompts.js +8 -6
- package/src/mcp-register-tools.js +25 -14
- package/src/mcp-role-tools.js +59 -18
- package/src/mcp-server-name.js +7 -0
- package/src/mcp-server.js +35 -7
- package/src/mcp-signup-client.js +3 -6
- package/src/mcp-signup-flow.js +389 -0
- package/src/mcp-signup-tools.js +250 -133
- package/src/onboarding-flow.js +72 -146
- package/src/onboarding-funnel.js +46 -6
- package/src/opencode-sessions.js +92 -0
- package/src/profile-agents.js +26 -0
- package/src/render-report-text.js +1 -1
- package/src/report-gating.js +4 -2
- package/src/report-store.js +7 -2
- package/src/roles-flow.js +8 -5
- package/src/saved-positions-flow.js +3 -3
- package/src/scanner.js +104 -12
- package/src/session-gate.js +1 -4
- package/src/session-normalizers.js +114 -8
- package/src/session-refresh.js +5 -16
- package/src/session-scan.js +167 -22
- package/src/share-consent-state.js +2 -2
- package/src/share-payload.js +4 -1
- package/src/share.js +2 -2
- package/src/signup-client.js +12 -39
- package/src/signup-copy.js +545 -58
- package/src/skill-install.js +102 -0
- package/src/stdin-ask.js +13 -1
- package/src/tier-engine.js +14 -2
- package/src/trust-narration.js +32 -0
- package/src/usage-run-steps.js +68 -6
- package/src/usage-scan.js +11 -8
- package/src/brand-svg.js +0 -9
- package/src/inter-fonts.js +0 -6
- package/src/mcp-auth-form.js +0 -480
package/src/onboarding-flow.js
CHANGED
|
@@ -249,7 +249,6 @@ function makeDeps(overrides = {}) {
|
|
|
249
249
|
const ai = require('./ai');
|
|
250
250
|
const inventory = require('./inventory-client');
|
|
251
251
|
const auth = require('./auth-session-store');
|
|
252
|
-
const authClient = require('./auth-client');
|
|
253
252
|
const signup = require('./signup-client');
|
|
254
253
|
const livekit = require('./onboarding-livekit-client');
|
|
255
254
|
return {
|
|
@@ -257,17 +256,14 @@ function makeDeps(overrides = {}) {
|
|
|
257
256
|
sessionStatus: auth.sessionStatus,
|
|
258
257
|
saveAuthSession: auth.saveAuthSession,
|
|
259
258
|
ensureFreshSession: require('./session-refresh').ensureFreshSession,
|
|
260
|
-
getSignUpEndpoint: config.getSignUpEndpoint,
|
|
261
259
|
getCompleteRegistrationEndpoint: config.getCompleteRegistrationEndpoint,
|
|
262
|
-
getLoginEndpoint: config.getLoginEndpoint,
|
|
263
260
|
getAuthTokenEndpoint: config.getAuthTokenEndpoint,
|
|
264
261
|
getDeviceAuthorizeEndpoint: config.getDeviceAuthorizeEndpoint,
|
|
265
262
|
getDeviceTokenEndpoint: config.getDeviceTokenEndpoint,
|
|
266
263
|
runDeviceLogin: require('./device-login').runDeviceLogin,
|
|
267
264
|
requestCompleteRegistration: signup.requestCompleteRegistration,
|
|
268
265
|
buildRegistrationContext: signup.buildRegistrationContext,
|
|
269
|
-
|
|
270
|
-
requestLogin: authClient.requestLogin,
|
|
266
|
+
buildRegistrationConsent: signup.buildRegistrationConsent,
|
|
271
267
|
getImportProfileEndpoint: config.getImportProfileEndpoint,
|
|
272
268
|
getProfessionalDetailsEndpoint: config.getProfessionalDetailsEndpoint,
|
|
273
269
|
getPricingRateEndpoint: config.getPricingRateEndpoint,
|
|
@@ -305,7 +301,6 @@ function makeDeps(overrides = {}) {
|
|
|
305
301
|
};
|
|
306
302
|
}
|
|
307
303
|
|
|
308
|
-
// Refreshes the email session's hub JWT from its stored cookie when the cached one has lapsed (session-refresh.js), then gates on the durable session status.
|
|
309
304
|
async function requireActiveSession(deps) {
|
|
310
305
|
const session = typeof deps.ensureFreshSession === 'function'
|
|
311
306
|
? await deps.ensureFreshSession(process.env, { loadAuthSession: deps.loadAuthSession, saveAuthSession: deps.saveAuthSession })
|
|
@@ -316,79 +311,26 @@ async function requireActiveSession(deps) {
|
|
|
316
311
|
return { ok: true, session };
|
|
317
312
|
}
|
|
318
313
|
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
async function signUp(deps, fields = {}) {
|
|
324
|
-
// The CLI no longer asks freelanceType at signup → transient default (corrected later from the work situation via professional-details).
|
|
325
|
-
if (!isAcceptablePassword(fields.password)) return { ok: false, reason: 'weak-password' };
|
|
326
|
-
const signUpEndpoint = deps.getSignUpEndpoint();
|
|
327
|
-
if (!signUpEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
328
|
-
const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function'
|
|
329
|
-
? deps.getCompleteRegistrationEndpoint()
|
|
330
|
-
: null;
|
|
331
|
-
return deps.requestSignUp(
|
|
332
|
-
{
|
|
333
|
-
name: fields.name,
|
|
334
|
-
lastName: fields.lastName,
|
|
335
|
-
email: fields.email,
|
|
336
|
-
password: fields.password,
|
|
337
|
-
preferredLanguage: fields.preferredLanguage,
|
|
338
|
-
newsletterConsent: fields.newsletterConsent === true,
|
|
339
|
-
freelanceType: normalizeFreelanceType(fields.freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
|
|
340
|
-
freelanceIntent: fields.freelanceIntent,
|
|
341
|
-
claimCode: fields.claimCode || null,
|
|
342
|
-
},
|
|
343
|
-
{ signUpEndpoint, completeRegistrationEndpoint },
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async function establishSessionFromCredentials(deps, { email, password } = {}) {
|
|
348
|
-
const signInEndpoint = deps.getLoginEndpoint();
|
|
349
|
-
if (!signInEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
350
|
-
const tokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
|
|
351
|
-
const result = await deps.requestLogin({ email, password }, { signInEndpoint, tokenEndpoint });
|
|
352
|
-
if (!result.ok) return { ok: false, reason: result.reason || 'login-failed', status: result.status || null };
|
|
353
|
-
try {
|
|
354
|
-
// For an email session the durable credential is the cookie; `accessToken` is
|
|
355
|
-
// the fresh JWT and `expiresAt` its own exp (stored as the refresh horizon).
|
|
356
|
-
deps.saveAuthSession({
|
|
357
|
-
accessToken: result.accessToken,
|
|
358
|
-
expiresAt: result.expiresAt,
|
|
359
|
-
accessTokenExpiresAt: result.expiresAt,
|
|
360
|
-
cookie: result.cookie || null,
|
|
361
|
-
cookieExpiresAt: result.cookieExpiresAt || null,
|
|
362
|
-
email: result.email || email || null,
|
|
363
|
-
hubAccessToken: result.hubAccessToken || null,
|
|
364
|
-
});
|
|
365
|
-
} catch {
|
|
366
|
-
return { ok: false, reason: 'session-persist-failed' };
|
|
367
|
-
}
|
|
368
|
-
return { ok: true };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Register a talent via a social provider (device flow, RFC 8628); a live session results either way.
|
|
372
|
-
async function registerWithSocialAccount(deps, { provider = 'google', freelanceType, language } = {}, { openBrowser, onAuthUrl } = {}) {
|
|
373
|
-
const { appendProviderParam, deviceSessionRecord } = require('./device-login');
|
|
314
|
+
async function registerWithDeviceFlow(deps, { provider = 'email', freelanceType, language, newsletterConsent = false } = {}, { openBrowser, onAuthUrl } = {}) {
|
|
315
|
+
const { withDeviceParams, deviceSessionRecord } = require('./device-login');
|
|
374
316
|
const authorizeEndpoint = deps.getDeviceAuthorizeEndpoint();
|
|
375
317
|
const tokenEndpoint = deps.getDeviceTokenEndpoint();
|
|
376
318
|
const authTokenEndpoint = typeof deps.getAuthTokenEndpoint === 'function' ? deps.getAuthTokenEndpoint() : null;
|
|
377
319
|
const completeRegistrationEndpoint = typeof deps.getCompleteRegistrationEndpoint === 'function' ? deps.getCompleteRegistrationEndpoint() : null;
|
|
378
320
|
if (!authorizeEndpoint || !tokenEndpoint || !authTokenEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
379
321
|
|
|
380
|
-
// freelanceType is a transient default (corrected later); feeds complete-registration for a new account.
|
|
381
322
|
const registrationContext = deps.buildRegistrationContext({
|
|
382
323
|
preferredLanguage: language,
|
|
383
|
-
newsletterConsent:
|
|
324
|
+
newsletterConsent: newsletterConsent === true,
|
|
384
325
|
freelanceType: normalizeFreelanceType(freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
|
|
385
326
|
});
|
|
327
|
+
const consent = deps.buildRegistrationConsent({ newsletterConsent: newsletterConsent === true });
|
|
386
328
|
|
|
387
329
|
const result = await deps.runDeviceLogin(
|
|
388
|
-
{ authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext },
|
|
330
|
+
{ authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext, consent },
|
|
389
331
|
{
|
|
390
332
|
onPrompt: ({ verificationUriComplete, userCode }) => {
|
|
391
|
-
const url =
|
|
333
|
+
const url = withDeviceParams(verificationUriComplete, { provider, mode: 'register', surface: 'cli' });
|
|
392
334
|
if (typeof onAuthUrl === 'function') onAuthUrl(url, userCode);
|
|
393
335
|
if (typeof openBrowser === 'function') { try { openBrowser(url); } catch { /* printed URL covers it */ } }
|
|
394
336
|
},
|
|
@@ -402,7 +344,6 @@ async function registerWithSocialAccount(deps, { provider = 'google', freelanceT
|
|
|
402
344
|
return { ok: false, reason: 'session-persist-failed' };
|
|
403
345
|
}
|
|
404
346
|
|
|
405
|
-
// isNewUser distinguishes a fresh registration from signing an existing account in.
|
|
406
347
|
return { ok: true, kind: result.isNewUser ? 'registered' : 'account-already-exists', email: result.email || null };
|
|
407
348
|
}
|
|
408
349
|
|
|
@@ -669,18 +610,15 @@ function trackImport(funnel, attempted, importRes, durationMs) {
|
|
|
669
610
|
const bySource = new Map();
|
|
670
611
|
const report = importRes.ok && importRes.report ? importRes.report.sources : [];
|
|
671
612
|
for (const src of report) bySource.set(sourceTypeFor(src.source), src);
|
|
672
|
-
const connected = [];
|
|
673
613
|
for (const type of attempted) {
|
|
674
614
|
const src = bySource.get(type);
|
|
675
615
|
const ok = importRes.ok && (!src || src.status === 'imported');
|
|
676
616
|
if (ok) {
|
|
677
|
-
connected.push(type);
|
|
678
617
|
funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
|
|
679
618
|
} else {
|
|
680
619
|
funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
|
|
681
620
|
}
|
|
682
621
|
}
|
|
683
|
-
return connected;
|
|
684
622
|
}
|
|
685
623
|
|
|
686
624
|
// Blocking orchestrator used by the CLI (`bin/onboarding.js`).
|
|
@@ -689,9 +627,10 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
689
627
|
const c = getCatalog(io.lang).onboarding;
|
|
690
628
|
|
|
691
629
|
funnel.begin();
|
|
630
|
+
funnel.viewed('start');
|
|
692
631
|
funnel.completed('start');
|
|
693
632
|
io.section(c.signupTitle);
|
|
694
|
-
funnel.viewed('account_creation');
|
|
633
|
+
funnel.viewed('account_creation', { auth_methods_offered: require('./auth-methods').methodIdsForFlow('register') });
|
|
695
634
|
funnel.flush();
|
|
696
635
|
const accountFailed = (reason) => {
|
|
697
636
|
funnel.errored('account_creation', reason);
|
|
@@ -699,61 +638,30 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
699
638
|
return { ok: false, reason, step: 'signup' };
|
|
700
639
|
};
|
|
701
640
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
notifyOk(io, c.accountReady);
|
|
722
|
-
} else {
|
|
723
|
-
const account = await io.askSignUp(c);
|
|
724
|
-
if (!account || !account.email) return accountFailed('missing-fields');
|
|
725
|
-
if (!isAcceptablePassword(account.password)) {
|
|
726
|
-
io.error(c.weakPassword);
|
|
727
|
-
return accountFailed('weak-password');
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const su = await signUp(deps, { ...account, preferredLanguage: account.preferredLanguage || io.lang });
|
|
731
|
-
if (!su.ok) {
|
|
732
|
-
io.error(su.message || c.signupError);
|
|
733
|
-
return accountFailed(su.reason);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (su.accountExists) {
|
|
737
|
-
// Existing account: sign them in, then STOP — no wizard re-run.
|
|
738
|
-
io.warn(c.accountExists);
|
|
739
|
-
const logged = await io.login(account.email);
|
|
740
|
-
if (!logged) return accountFailed('login-failed');
|
|
741
|
-
funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
|
|
742
|
-
funnel.identified();
|
|
743
|
-
return { ok: true, alreadyRegistered: true };
|
|
744
|
-
}
|
|
745
|
-
const sess = await establishSessionFromCredentials(deps, { email: account.email, password: account.password });
|
|
746
|
-
if (!sess.ok) {
|
|
747
|
-
io.error(c.sessionError);
|
|
748
|
-
return accountFailed(sess.reason);
|
|
749
|
-
}
|
|
750
|
-
funnel.completed('account_creation', { auth_method: 'email', is_returning_account: false });
|
|
751
|
-
funnel.identified();
|
|
752
|
-
notifyOk(io, c.accountReady);
|
|
641
|
+
const terms = await io.askTerms(c);
|
|
642
|
+
if (!terms.accepted) {
|
|
643
|
+
io.warn(c.termsDeclined);
|
|
644
|
+
return accountFailed('terms-declined');
|
|
645
|
+
}
|
|
646
|
+
const reg = await registerWithDeviceFlow(
|
|
647
|
+
deps,
|
|
648
|
+
{ provider: method, language: io.lang, newsletterConsent: terms.newsletterConsent },
|
|
649
|
+
{ openBrowser, onAuthUrl },
|
|
650
|
+
);
|
|
651
|
+
if (!reg.ok) {
|
|
652
|
+
io.error(c.signupError);
|
|
653
|
+
return accountFailed(reg.reason);
|
|
654
|
+
}
|
|
655
|
+
funnel.completed('account_creation', { auth_method: method, auth_attempts: 1, is_returning_account: reg.kind === 'account-already-exists' });
|
|
656
|
+
funnel.identified({ newAccount: reg.kind !== 'account-already-exists' });
|
|
657
|
+
if (reg.kind === 'account-already-exists') {
|
|
658
|
+
io.warn(c.accountExists);
|
|
659
|
+
return { ok: true, alreadyRegistered: true };
|
|
753
660
|
}
|
|
661
|
+
notifyOk(io, c.accountReady);
|
|
754
662
|
|
|
755
663
|
io.section(c.title);
|
|
756
|
-
funnel.viewed('import_sources');
|
|
664
|
+
funnel.viewed('import_sources', { sources_offered: ['linkedin', 'cv', 'github', 'web_page'] });
|
|
757
665
|
const linkedinUrl = (await io.ask(c.askLinkedin) || '').trim();
|
|
758
666
|
if (!linkedinUrl) {
|
|
759
667
|
funnel.errored('import_sources', 'no-linkedin', { error_field: 'linkedin' });
|
|
@@ -772,15 +680,15 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
772
680
|
const importStartedAt = Date.now();
|
|
773
681
|
const importRes = await withProgress(c.importChecking, () =>
|
|
774
682
|
startProfileImport(deps, { linkedinUrl, cvPath, githubUrl, websiteUrl, language: io.lang }));
|
|
775
|
-
|
|
776
|
-
funnel.completed('import_sources', { sources_attempted: attempted, sources_connected:
|
|
683
|
+
trackImport(funnel, attempted, importRes, Date.now() - importStartedAt);
|
|
684
|
+
funnel.completed('import_sources', { sources_attempted: attempted, sources_connected: attempted, sources_connected_count: attempted.length });
|
|
777
685
|
funnel.flush();
|
|
778
686
|
if (importRes.ok) notifyOk(io, c.importDone);
|
|
779
687
|
else io.warn(c.importUnavailable);
|
|
780
688
|
|
|
781
689
|
// Steps 4-8 are saved out of funnel order: sent together once all are answered.
|
|
782
690
|
io.section(c.professionalTitle);
|
|
783
|
-
funnel.viewed('work_situation');
|
|
691
|
+
funnel.viewed('work_situation', { is_prefilled: false });
|
|
784
692
|
const workSituationInput = await io.askWorkSituation(c);
|
|
785
693
|
const workSituationRes = await saveWorkSituation(deps, workSituationInput);
|
|
786
694
|
if (!workSituationRes.ok) {
|
|
@@ -788,11 +696,11 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
788
696
|
funnel.flush();
|
|
789
697
|
return { ok: false, reason: workSituationRes.reason, step: 'work-situation' };
|
|
790
698
|
}
|
|
791
|
-
funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()) });
|
|
699
|
+
funnel.completed('work_situation', { work_situation: workSituationValue(String(workSituationInput.situation || '').trim().toUpperCase()), was_edited: true });
|
|
792
700
|
notifyOk(io, c.professionalSaved);
|
|
793
701
|
|
|
794
702
|
io.section(c.pricingTitle);
|
|
795
|
-
funnel.viewed('rates');
|
|
703
|
+
funnel.viewed('rates', { suggested_rate_shown: false });
|
|
796
704
|
const pricingInput = await io.askPricing(c);
|
|
797
705
|
const priceRes = await savePricing(deps, pricingInput);
|
|
798
706
|
if (!priceRes.ok) {
|
|
@@ -805,23 +713,31 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
805
713
|
rate_types_selected: selectedRates,
|
|
806
714
|
rate_type: selectedRates[0],
|
|
807
715
|
rate_currency: normalizeCurrency(pricingInput.partTimeSelected === true ? pricingInput.partTimeCurrency : pricingInput.fullTimeCurrency) || undefined,
|
|
716
|
+
was_edited: true,
|
|
808
717
|
});
|
|
809
718
|
notifyOk(io, c.pricingSaved);
|
|
810
719
|
|
|
811
720
|
// Optional/skippable and fail-soft: a decline or a failed write never aborts the alta.
|
|
812
721
|
io.section(c.availabilityTitle);
|
|
813
|
-
funnel.viewed('availability');
|
|
722
|
+
funnel.viewed('availability', { is_prefilled: false });
|
|
814
723
|
if (await io.confirm(c.availabilityAsk)) {
|
|
815
|
-
|
|
724
|
+
let availabilityTime;
|
|
725
|
+
const availabilityInput = await io.askAvailability(c, {
|
|
726
|
+
onLocation: () => {
|
|
727
|
+
availabilityTime = funnel.stepTime('availability');
|
|
728
|
+
funnel.viewed('location', { is_prefilled: false });
|
|
729
|
+
},
|
|
730
|
+
});
|
|
816
731
|
const availRes = await saveAvailability(deps, availabilityInput);
|
|
817
732
|
if (availRes.ok) {
|
|
818
|
-
funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)) });
|
|
733
|
+
funnel.completed('availability', { availability_hours_per_week: weeklyHours(normalizeMonthlyHours(availabilityInput.monthlyHours)), time_on_step_ms: availabilityTime, was_edited: true });
|
|
819
734
|
const country = normalizeCountry(availabilityInput.country);
|
|
820
735
|
const modes = Array.isArray(availabilityInput.workModes) ? availabilityInput.workModes.map((m) => String(m).trim().toUpperCase()) : [];
|
|
821
736
|
funnel.completed('location', {
|
|
822
737
|
location_country: country ? country.toLowerCase() : undefined,
|
|
823
738
|
location_method: 'manual',
|
|
824
739
|
is_remote_only: modes.length ? modes.every((m) => m === 'REMOTE') : undefined,
|
|
740
|
+
was_edited: true,
|
|
825
741
|
});
|
|
826
742
|
notifyOk(io, c.availabilitySaved);
|
|
827
743
|
} else {
|
|
@@ -839,13 +755,13 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
839
755
|
}
|
|
840
756
|
|
|
841
757
|
io.section(c.languagesTitle);
|
|
842
|
-
funnel.viewed('languages');
|
|
758
|
+
funnel.viewed('languages', { is_prefilled: false });
|
|
843
759
|
if (await io.confirm(c.languagesAsk)) {
|
|
844
760
|
const languageRows = await io.askLanguages(c);
|
|
845
761
|
const langRes = await saveLanguages(deps, languageRows);
|
|
846
762
|
if (langRes.ok) {
|
|
847
763
|
const codes = validateLanguageRows(languageRows).rows.map((r) => r.language);
|
|
848
|
-
funnel.completed('languages', { languages_count: codes.length, languages: codes });
|
|
764
|
+
funnel.completed('languages', { languages_count: codes.length, languages: codes, was_edited: true });
|
|
849
765
|
notifyOk(io, c.languagesSaved);
|
|
850
766
|
} else {
|
|
851
767
|
funnel.errored('languages', langRes.reason);
|
|
@@ -888,20 +804,35 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
888
804
|
// Every ending points the Talent at their profile, and opens it when a browser is at hand.
|
|
889
805
|
const showProfile = () => {
|
|
890
806
|
const url = profileUrl(deps);
|
|
807
|
+
funnel.viewed('profile_summary');
|
|
891
808
|
notifyOk(io, url ? c.profileReadyUrl(url) : c.profileReadyNoUrl);
|
|
892
809
|
if (url && typeof openProfile === 'function') {
|
|
893
810
|
try { openProfile(url); } catch { /* the printed link covers it */ }
|
|
894
811
|
}
|
|
812
|
+
funnel.completed('profile_summary');
|
|
813
|
+
funnel.flush();
|
|
895
814
|
return url;
|
|
896
815
|
};
|
|
897
816
|
|
|
898
817
|
const mainRole = async () => {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
818
|
+
let shown = null;
|
|
819
|
+
const role = await runMainRoleFinalStep(io, deps, {
|
|
820
|
+
onRolesShown: (info) => {
|
|
821
|
+
shown = info;
|
|
822
|
+
funnel.viewed('supercluster', { suggested_superclusters: info.suggested, suggestion_source: 'assigned_clusters', suggestions_count: info.suggested.length });
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
if (role && role.ok && role.chosen) {
|
|
826
|
+
funnel.completed('supercluster', {
|
|
827
|
+
supercluster: role.chosen,
|
|
828
|
+
clusters_count: shown ? shown.clustersCount : undefined,
|
|
829
|
+
was_suggestion_accepted: shown ? shown.suggested.includes(role.chosen) : undefined,
|
|
830
|
+
});
|
|
831
|
+
} else if (role && role.ok) funnel.skipped('supercluster');
|
|
902
832
|
funnel.flush();
|
|
903
833
|
};
|
|
904
834
|
|
|
835
|
+
funnel.holdUntil('profile_summary');
|
|
905
836
|
io.section(c.interviewTitle);
|
|
906
837
|
funnel.viewed('pre_interview');
|
|
907
838
|
io.disclaimer(c.interviewInfoAccessed, c.interviewGoalDuration);
|
|
@@ -1010,28 +941,26 @@ async function conductLivekitInterview(io, deps, { interviewId = null, language
|
|
|
1010
941
|
}
|
|
1011
942
|
|
|
1012
943
|
// Runs the final main-role step, degrading softly (never aborts the flow / never throws).
|
|
1013
|
-
async function runMainRoleFinalStep(io, deps,
|
|
1014
|
-
const active =
|
|
944
|
+
async function runMainRoleFinalStep(io, deps, { onRolesShown } = {}) {
|
|
945
|
+
const active = typeof deps.loadAuthSession === 'function' ? deps.loadAuthSession() : null;
|
|
1015
946
|
if (typeof deps.runMainRoleStep !== 'function' || !active) return;
|
|
1016
947
|
const { getCatalog } = require('./i18n');
|
|
1017
948
|
const catalog = getCatalog(io.lang);
|
|
1018
949
|
try {
|
|
1019
950
|
const rolesDeps = typeof deps.makeRolesDeps === 'function' ? deps.makeRolesDeps() : undefined;
|
|
1020
|
-
return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps });
|
|
951
|
+
return await deps.runMainRoleStep({ io, session: active, catalog, deps: rolesDeps, onRolesShown });
|
|
1021
952
|
} catch {
|
|
1022
953
|
return null;
|
|
1023
954
|
}
|
|
1024
955
|
}
|
|
1025
956
|
|
|
1026
|
-
// Overwrite-repeat: PATCH /interviews/onboarding/restart.
|
|
1027
|
-
// the talent hub JWT (same token roles use); falls back to accessToken when equal.
|
|
957
|
+
// Overwrite-repeat: PATCH /interviews/onboarding/restart. Candidate certs JWT, like its onboarding siblings.
|
|
1028
958
|
async function restartOnboarding(deps) {
|
|
1029
959
|
const gate = await requireActiveSession(deps);
|
|
1030
960
|
if (!gate.ok) return gate;
|
|
1031
961
|
const endpoint = deps.getOnboardingRestartEndpoint();
|
|
1032
962
|
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
1033
|
-
|
|
1034
|
-
return deps.requestRestartOnboardingInterview({ accessToken: token }, { endpoint });
|
|
963
|
+
return deps.requestRestartOnboardingInterview({ accessToken: gate.session.accessToken }, { endpoint });
|
|
1035
964
|
}
|
|
1036
965
|
|
|
1037
966
|
// `shakers onboarding --repeat`: warn + explicit confirm, then overwrite the prior
|
|
@@ -1123,7 +1052,6 @@ module.exports = {
|
|
|
1123
1052
|
validatePricing,
|
|
1124
1053
|
validateAvailability,
|
|
1125
1054
|
buildPricingUpsert,
|
|
1126
|
-
isAcceptablePassword,
|
|
1127
1055
|
deriveCurrentEmploymentStatus,
|
|
1128
1056
|
showsFreelanceOpinion,
|
|
1129
1057
|
deriveFreelanceIntent,
|
|
@@ -1132,9 +1060,7 @@ module.exports = {
|
|
|
1132
1060
|
validateLanguageRows,
|
|
1133
1061
|
validatePhone,
|
|
1134
1062
|
makeDeps,
|
|
1135
|
-
|
|
1136
|
-
establishSessionFromCredentials,
|
|
1137
|
-
registerWithSocialAccount,
|
|
1063
|
+
registerWithDeviceFlow,
|
|
1138
1064
|
startProfileImport,
|
|
1139
1065
|
reattachConsentIdentity,
|
|
1140
1066
|
saveWorkSituation,
|
package/src/onboarding-funnel.js
CHANGED
|
@@ -196,8 +196,17 @@ function createFunnel({
|
|
|
196
196
|
let active = false;
|
|
197
197
|
let mcpClient = null;
|
|
198
198
|
let cachedDeviceId;
|
|
199
|
+
let begunAt = null;
|
|
200
|
+
let lastCompletedAt = null;
|
|
201
|
+
let heldFrom = null;
|
|
199
202
|
const seen = new Set();
|
|
200
203
|
const inflight = new Set();
|
|
204
|
+
const viewedAt = new Map();
|
|
205
|
+
|
|
206
|
+
function stepTime(stepName) {
|
|
207
|
+
const since = viewedAt.has(stepName) ? viewedAt.get(stepName) : (lastCompletedAt ?? begunAt);
|
|
208
|
+
return since === null ? undefined : Math.max(0, now() - since);
|
|
209
|
+
}
|
|
201
210
|
|
|
202
211
|
function track(eventType, stepName, properties = {}) {
|
|
203
212
|
if (!active || !EVENT_TYPES.includes(eventType) || !STEP_INDEX[stepName]) return false;
|
|
@@ -206,9 +215,20 @@ function createFunnel({
|
|
|
206
215
|
if (seen.has(key)) return false;
|
|
207
216
|
seen.add(key);
|
|
208
217
|
}
|
|
218
|
+
const at = now();
|
|
219
|
+
const own = cleanProperties(properties);
|
|
220
|
+
if (eventType === 'onboarding_step_viewed') viewedAt.set(stepName, at);
|
|
221
|
+
if (eventType === 'onboarding_step_completed') {
|
|
222
|
+
if (own.time_on_step_ms === undefined) {
|
|
223
|
+
const elapsed = stepTime(stepName);
|
|
224
|
+
if (elapsed !== undefined) own.time_on_step_ms = elapsed;
|
|
225
|
+
}
|
|
226
|
+
lastCompletedAt = at;
|
|
227
|
+
}
|
|
228
|
+
if (heldFrom === STEP_INDEX[stepName] && (eventType === 'onboarding_step_completed' || eventType === 'onboarding_step_skipped')) heldFrom = null;
|
|
209
229
|
const common = { step_name: stepName, step_index: STEP_INDEX[stepName], flow_version: FLOW_VERSION, surface };
|
|
210
230
|
if (surface === 'mcp') common.mcp_client = mcpClient || 'other';
|
|
211
|
-
pending.push({ eventType, insertId: uuid(), at
|
|
231
|
+
pending.push({ eventType, insertId: uuid(), at, seq: seq++, properties: { ...own, ...common } });
|
|
212
232
|
return true;
|
|
213
233
|
}
|
|
214
234
|
|
|
@@ -220,9 +240,16 @@ function createFunnel({
|
|
|
220
240
|
// Funnel order, each time strictly after the previous one sent.
|
|
221
241
|
function stamp(batch) {
|
|
222
242
|
const ordered = [...batch].sort((a, b) => a.properties.step_index - b.properties.step_index || a.seq - b.seq);
|
|
223
|
-
|
|
243
|
+
const latest = [];
|
|
244
|
+
let next = Infinity;
|
|
245
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
246
|
+
const item = ordered[i];
|
|
247
|
+
latest[i] = item.time !== undefined ? item.time : Math.min(item.at, next - 1);
|
|
248
|
+
next = latest[i];
|
|
249
|
+
}
|
|
250
|
+
return ordered.map((item, i) => {
|
|
224
251
|
if (item.time === undefined) {
|
|
225
|
-
lastTime = Math.max(
|
|
252
|
+
lastTime = Math.max(latest[i], lastTime + 1);
|
|
226
253
|
item.time = lastTime;
|
|
227
254
|
}
|
|
228
255
|
return { item, time: item.time };
|
|
@@ -246,8 +273,9 @@ function createFunnel({
|
|
|
246
273
|
const url = safe(endpoint);
|
|
247
274
|
// Never send under another account signed in since.
|
|
248
275
|
if (!token || !url || accountOf(token) !== account) return Promise.resolve();
|
|
249
|
-
const batch = pending;
|
|
250
|
-
|
|
276
|
+
const batch = heldFrom === null ? pending : pending.filter((e) => e.properties.step_index < heldFrom);
|
|
277
|
+
if (!batch.length) return Promise.resolve();
|
|
278
|
+
pending = pending.filter((e) => !batch.includes(e));
|
|
251
279
|
const id = currentDeviceId();
|
|
252
280
|
const toWire = ({ item, time }) => ({ event_type: item.eventType, insert_id: item.insertId, time, ...(id ? { device_id: id } : {}), properties: item.properties });
|
|
253
281
|
return post(stamp(batch), toWire, (events) => send({ endpoint: url, events, hubAccessToken: token })).then(() => undefined);
|
|
@@ -288,6 +316,10 @@ function createFunnel({
|
|
|
288
316
|
surface,
|
|
289
317
|
begin() {
|
|
290
318
|
seen.clear();
|
|
319
|
+
viewedAt.clear();
|
|
320
|
+
lastCompletedAt = null;
|
|
321
|
+
heldFrom = null;
|
|
322
|
+
begunAt = now();
|
|
291
323
|
active = true;
|
|
292
324
|
},
|
|
293
325
|
get active() { return active; },
|
|
@@ -296,8 +328,13 @@ function createFunnel({
|
|
|
296
328
|
viewed: (step, props) => track('onboarding_step_viewed', step, props),
|
|
297
329
|
completed: (step, props) => track('onboarding_step_completed', step, props),
|
|
298
330
|
skipped: (step, props) => track('onboarding_step_skipped', step, props),
|
|
331
|
+
stepTime: (step) => (active ? stepTime(step) : undefined),
|
|
332
|
+
holdUntil(step) {
|
|
333
|
+
if (active && STEP_INDEX[step]) heldFrom = STEP_INDEX[step];
|
|
334
|
+
},
|
|
299
335
|
errored: (step, reason, props = {}) => track('onboarding_step_errored', step, { error_type: errorTypeFor(reason), ...props }),
|
|
300
|
-
identified() {
|
|
336
|
+
identified({ newAccount = true } = {}) {
|
|
337
|
+
if (!newAccount) pending = pending.filter((e) => !(e.eventType === 'onboarding_step_completed' && STEP_INDEX[e.properties.step_name] <= STEP_INDEX.account_creation));
|
|
301
338
|
const token = safe(hubToken);
|
|
302
339
|
if (!token) return Promise.resolve();
|
|
303
340
|
account = accountOf(token);
|
|
@@ -307,6 +344,7 @@ function createFunnel({
|
|
|
307
344
|
flush,
|
|
308
345
|
idle,
|
|
309
346
|
settle(capMs = EXIT_FLUSH_CAP_MS) {
|
|
347
|
+
heldFrom = null;
|
|
310
348
|
flush();
|
|
311
349
|
let timer;
|
|
312
350
|
const cap = new Promise((resolve) => { timer = setTimeout(resolve, capMs); });
|
|
@@ -334,6 +372,8 @@ const NOOP_FUNNEL = Object.freeze({
|
|
|
334
372
|
viewed: () => false,
|
|
335
373
|
completed: () => false,
|
|
336
374
|
skipped: () => false,
|
|
375
|
+
stepTime: () => undefined,
|
|
376
|
+
holdUntil() {},
|
|
337
377
|
errored: () => false,
|
|
338
378
|
identified: () => Promise.resolve(),
|
|
339
379
|
flush: () => Promise.resolve(),
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { getHomeDir } = require('./env-paths');
|
|
6
|
+
|
|
7
|
+
function loadSqlite() {
|
|
8
|
+
const emitWarning = process.emitWarning;
|
|
9
|
+
process.emitWarning = (warning, ...rest) => {
|
|
10
|
+
const type = typeof rest[0] === 'string' ? rest[0] : rest[0] && rest[0].type;
|
|
11
|
+
if (type === 'ExperimentalWarning' && /sqlite/i.test(String(warning))) return;
|
|
12
|
+
emitWarning.call(process, warning, ...rest);
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
return require('node:sqlite');
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
} finally {
|
|
19
|
+
process.emitWarning = emitWarning;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function opencodeDbPath(env = process.env) {
|
|
24
|
+
const dataDir = env.XDG_DATA_HOME
|
|
25
|
+
? path.join(env.XDG_DATA_HOME, 'opencode')
|
|
26
|
+
: path.join(getHomeDir(env), '.local', 'share', 'opencode');
|
|
27
|
+
if (env.OPENCODE_DB) return path.isAbsolute(env.OPENCODE_DB) ? env.OPENCODE_DB : path.join(dataDir, env.OPENCODE_DB);
|
|
28
|
+
return path.join(dataDir, 'opencode.db');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SESSIONS_SQL = 'SELECT id, directory, parent_id AS parentId, agent FROM session ORDER BY time_created DESC LIMIT ?';
|
|
32
|
+
const MESSAGES_SQL = `SELECT id, time_created AS createdAt, json_extract(data, '$.role') AS role, json_extract(data, '$.agent') AS agent
|
|
33
|
+
FROM message WHERE session_id = ? ORDER BY time_created, id`;
|
|
34
|
+
const PARTS_SQL = `SELECT p.message_id AS messageId, json_extract(p.data, '$.type') AS type, json_extract(p.data, '$.text') AS text,
|
|
35
|
+
json_extract(p.data, '$.tool') AS tool, json_extract(p.data, '$.synthetic') AS synthetic
|
|
36
|
+
FROM part p JOIN message m ON m.id = p.message_id WHERE m.session_id = ? ORDER BY p.id`;
|
|
37
|
+
|
|
38
|
+
function isoOf(ms) {
|
|
39
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sessionObjects(db, session) {
|
|
43
|
+
const parts = new Map();
|
|
44
|
+
for (const p of db.prepare(PARTS_SQL).all(session.id)) {
|
|
45
|
+
if (!parts.has(p.messageId)) parts.set(p.messageId, []);
|
|
46
|
+
parts.get(p.messageId).push(p);
|
|
47
|
+
}
|
|
48
|
+
const head = {};
|
|
49
|
+
if (typeof session.directory === 'string' && session.directory) head.cwd = session.directory;
|
|
50
|
+
if (session.agent === 'plan') head.planMode = true;
|
|
51
|
+
const objects = [head];
|
|
52
|
+
for (const m of db.prepare(MESSAGES_SQL).all(session.id)) {
|
|
53
|
+
const timestamp = isoOf(Number(m.createdAt));
|
|
54
|
+
const own = parts.get(m.id) || [];
|
|
55
|
+
if (m.agent === 'plan') head.planMode = true;
|
|
56
|
+
if (m.role === 'user') {
|
|
57
|
+
const text = own.filter((p) => p.type === 'text' && !p.synthetic && typeof p.text === 'string').map((p) => p.text).join('').trim();
|
|
58
|
+
if (text) {
|
|
59
|
+
objects.push({ type: 'user', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const uses = own.filter((p) => p.type === 'tool' && typeof p.tool === 'string' && p.tool).map((p) => ({ type: 'tool_use', name: p.tool }));
|
|
64
|
+
const o = timestamp ? { timestamp } : {};
|
|
65
|
+
if (uses.length) o.message = { role: 'assistant', content: uses };
|
|
66
|
+
if (o.timestamp || o.message) objects.push(o);
|
|
67
|
+
}
|
|
68
|
+
return objects;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readOpencodeSessions(env, limit) {
|
|
72
|
+
const file = opencodeDbPath(env);
|
|
73
|
+
if (!fs.existsSync(file)) return [];
|
|
74
|
+
const sqlite = loadSqlite();
|
|
75
|
+
const Database = sqlite && (sqlite.DatabaseSync || sqlite.Database);
|
|
76
|
+
if (!Database) return [];
|
|
77
|
+
let db;
|
|
78
|
+
try {
|
|
79
|
+
db = new Database(file, { readOnly: true });
|
|
80
|
+
return db.prepare(SESSIONS_SQL).all(limit).map((session) => ({
|
|
81
|
+
file: `${file}#${session.id}`,
|
|
82
|
+
objects: sessionObjects(db, session),
|
|
83
|
+
subagent: Boolean(session.parentId),
|
|
84
|
+
}));
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
} finally {
|
|
88
|
+
if (db) db.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { readOpencodeSessions, opencodeDbPath };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { requestListAgents } = require('./agents-client');
|
|
4
|
+
const { getAgentsListEndpoint } = require('./config');
|
|
5
|
+
const { ensureFreshSession } = require('./session-refresh');
|
|
6
|
+
|
|
7
|
+
const TALENT_ADDED_SOURCES = new Set(['DECLARED_BY_TALENT', 'CLI_DETECTED']);
|
|
8
|
+
|
|
9
|
+
async function fetchProfileAgentNames(env = process.env, deps = {}) {
|
|
10
|
+
const {
|
|
11
|
+
refresh = ensureFreshSession,
|
|
12
|
+
list = requestListAgents,
|
|
13
|
+
endpoint = getAgentsListEndpoint(env),
|
|
14
|
+
} = deps;
|
|
15
|
+
try {
|
|
16
|
+
const session = await refresh(env);
|
|
17
|
+
if (!session || !session.hubAccessToken) return [];
|
|
18
|
+
const listed = await list({ accessToken: session.accessToken, hubAccessToken: session.hubAccessToken }, { endpoint });
|
|
19
|
+
if (!listed || !listed.ok || !Array.isArray(listed.agents)) return [];
|
|
20
|
+
return listed.agents.filter((a) => a && TALENT_ADDED_SOURCES.has(a.source) && a.name).map((a) => a.name);
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { fetchProfileAgentNames, TALENT_ADDED_SOURCES };
|
|
@@ -14,7 +14,7 @@ function renderFullReportText(report, maturity, aiProfile, { lang = 'en', gate }
|
|
|
14
14
|
);
|
|
15
15
|
let terminal = '';
|
|
16
16
|
try {
|
|
17
|
-
terminal = stripAnsi(renderTerminal(report, maturity, lang, { gate: gate || reportGateForIdentity() }))
|
|
17
|
+
terminal = stripAnsi(renderTerminal(report, maturity, lang, { gate: { ...(gate || reportGateForIdentity()), showRoadmap: false } }))
|
|
18
18
|
.replace(/[ \t]+$/gm, '')
|
|
19
19
|
.replace(/\n{3,}/g, '\n\n')
|
|
20
20
|
.trim();
|