shakers-cli 0.16.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 +11 -6
- package/bin/ai-usage.js +31 -12
- package/bin/mcp.js +5 -4
- package/bin/register.js +2 -1
- package/bin/shakers-cli.js +1 -1
- package/package.json +1 -1
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/ai-usage-review.js +101 -0
- package/src/auth-session-store.js +7 -0
- package/src/backend-request.js +2 -0
- package/src/config.js +2 -0
- package/src/consent-flow.js +3 -2
- package/src/i18n.js +24 -14
- package/src/mcp-ai-usage-result-tool.js +34 -12
- package/src/mcp-ai-usage-tool.js +281 -162
- package/src/mcp-auth-form.js +6 -2
- package/src/mcp-install.js +279 -44
- package/src/mcp-instructions.js +1 -1
- package/src/mcp-prompts.js +2 -2
- package/src/mcp-role-tools.js +24 -1
- package/src/mcp-signup-tools.js +18 -12
- package/src/onboarding-flow.js +47 -26
- package/src/onboarding-funnel.js +46 -6
- 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/share-consent-state.js +2 -2
- package/src/signup-copy.js +176 -1
- package/src/stdin-ask.js +13 -1
- package/src/usage-run-steps.js +57 -4
- package/src/usage-scan.js +8 -8
package/src/onboarding-flow.js
CHANGED
|
@@ -669,18 +669,15 @@ function trackImport(funnel, attempted, importRes, durationMs) {
|
|
|
669
669
|
const bySource = new Map();
|
|
670
670
|
const report = importRes.ok && importRes.report ? importRes.report.sources : [];
|
|
671
671
|
for (const src of report) bySource.set(sourceTypeFor(src.source), src);
|
|
672
|
-
const connected = [];
|
|
673
672
|
for (const type of attempted) {
|
|
674
673
|
const src = bySource.get(type);
|
|
675
674
|
const ok = importRes.ok && (!src || src.status === 'imported');
|
|
676
675
|
if (ok) {
|
|
677
|
-
connected.push(type);
|
|
678
676
|
funnel.track('source_import_completed', 'import_sources', { source_type: type, duration_ms: durationMs });
|
|
679
677
|
} else {
|
|
680
678
|
funnel.track('source_import_failed', 'import_sources', { source_type: type, duration_ms: durationMs, failure_reason: (src && src.code) || importRes.reason });
|
|
681
679
|
}
|
|
682
680
|
}
|
|
683
|
-
return connected;
|
|
684
681
|
}
|
|
685
682
|
|
|
686
683
|
// Blocking orchestrator used by the CLI (`bin/onboarding.js`).
|
|
@@ -689,9 +686,10 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
689
686
|
const c = getCatalog(io.lang).onboarding;
|
|
690
687
|
|
|
691
688
|
funnel.begin();
|
|
689
|
+
funnel.viewed('start');
|
|
692
690
|
funnel.completed('start');
|
|
693
691
|
io.section(c.signupTitle);
|
|
694
|
-
funnel.viewed('account_creation');
|
|
692
|
+
funnel.viewed('account_creation', { auth_methods_offered: require('./auth-methods').methodIdsForFlow('register') });
|
|
695
693
|
funnel.flush();
|
|
696
694
|
const accountFailed = (reason) => {
|
|
697
695
|
funnel.errored('account_creation', reason);
|
|
@@ -711,8 +709,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
711
709
|
io.error(c.signupError);
|
|
712
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);
|
|
@@ -738,8 +736,8 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
738
736
|
io.warn(c.accountExists);
|
|
739
737
|
const logged = await io.login(account.email);
|
|
740
738
|
if (!logged) return accountFailed('login-failed');
|
|
741
|
-
funnel.completed('account_creation', { auth_method: 'email', is_returning_account: true });
|
|
742
|
-
funnel.identified();
|
|
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 });
|
|
@@ -747,13 +745,13 @@ async function runOnboarding(io, deps = makeDeps(), { method = 'email', openBrow
|
|
|
747
745
|
io.error(c.sessionError);
|
|
748
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
|
}
|
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(),
|
|
@@ -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();
|
package/src/report-gating.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
const { loadAuthSession, sessionStatus, isPlatformTalent } = require('./auth-session-store');
|
|
4
4
|
const { getProfile } = require('./config');
|
|
5
|
+
const { hasAccountTrace } = require('./account-trace');
|
|
5
6
|
|
|
6
7
|
// THE ONE LAYER THAT DECIDES WHAT THE REPORT SHOWS ACCORDING TO IDENTITY.
|
|
7
8
|
|
|
8
9
|
// Pure: identity in, policy out.
|
|
9
|
-
function resolveReportGate({ loggedIn = false, status = null, email = null, userType = null, profile = 'talent' } = {}) {
|
|
10
|
+
function resolveReportGate({ loggedIn = false, accountTrace = false, status = null, email = null, userType = null, profile = 'talent' } = {}) {
|
|
10
11
|
const showEverything = !loggedIn;
|
|
11
12
|
const resolvedProfile = profile === 'external' ? 'external' : 'talent';
|
|
12
13
|
return Object.freeze({
|
|
@@ -16,7 +17,7 @@ function resolveReportGate({ loggedIn = false, status = null, email = null, user
|
|
|
16
17
|
email: loggedIn && typeof email === 'string' && email ? email : null,
|
|
17
18
|
// --roadmap AND its server-side computation/egress (see bin/report.js): a
|
|
18
19
|
// logged-in Talent's roadmap is not merely hidden, it is not requested.
|
|
19
|
-
showRoadmap: showEverything,
|
|
20
|
+
showRoadmap: showEverything && !accountTrace,
|
|
20
21
|
// Per-agent "how to improve this agent" tips in the footprint report.
|
|
21
22
|
showAgentSuggestions: showEverything,
|
|
22
23
|
// The copyable remediation prompt in a `certify` verdict.
|
|
@@ -35,6 +36,7 @@ function reportGateForIdentity(env = process.env, now = Date.now()) {
|
|
|
35
36
|
const status = sessionStatus(session, now);
|
|
36
37
|
return resolveReportGate({
|
|
37
38
|
loggedIn: status === 'active',
|
|
39
|
+
accountTrace: hasAccountTrace(env),
|
|
38
40
|
status,
|
|
39
41
|
email: session ? session.email : null,
|
|
40
42
|
userType: session ? session.userType : null,
|
package/src/report-store.js
CHANGED
|
@@ -54,10 +54,10 @@ function migrateFromV1(parsed) {
|
|
|
54
54
|
return { schemaVersion: SCHEMA_VERSION, updatedAt: (parsed && parsed.updatedAt) || null, projects };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function loadState() {
|
|
57
|
+
function loadState(env = process.env) {
|
|
58
58
|
let parsed;
|
|
59
59
|
try {
|
|
60
|
-
parsed = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
|
|
60
|
+
parsed = JSON.parse(fs.readFileSync(statePath(env), 'utf8'));
|
|
61
61
|
} catch {
|
|
62
62
|
return freshState();
|
|
63
63
|
}
|
|
@@ -154,6 +154,10 @@ function recordBackendAcceptance({ root, kind, backend }) {
|
|
|
154
154
|
return stampAndSaveState(state, absRoot);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
function hasRecordedUpload(env = process.env) {
|
|
158
|
+
return Object.values(loadState(env).projects).some((p) => !!(p && p.backendAcceptance && Object.keys(p.backendAcceptance).length));
|
|
159
|
+
}
|
|
160
|
+
|
|
157
161
|
function loadFootprint({ root } = {}) {
|
|
158
162
|
const absRoot = path.resolve(root || process.cwd());
|
|
159
163
|
const state = loadState();
|
|
@@ -220,6 +224,7 @@ module.exports = {
|
|
|
220
224
|
loadAgentClassifications,
|
|
221
225
|
loadFootprint,
|
|
222
226
|
recordBackendAcceptance,
|
|
227
|
+
hasRecordedUpload,
|
|
223
228
|
materializeProjectReport,
|
|
224
229
|
certKey,
|
|
225
230
|
SCHEMA_VERSION,
|
package/src/roles-flow.js
CHANGED
|
@@ -88,6 +88,7 @@ async function runAddRole({ io, session, catalog, deps = makeRolesDeps() }) {
|
|
|
88
88
|
// deduped, for setting/switching the MAIN role.
|
|
89
89
|
async function fetchHeldRoles({ deps, hubAccessToken }) {
|
|
90
90
|
const held = [];
|
|
91
|
+
const assigned = [];
|
|
91
92
|
let mainClusterId = null;
|
|
92
93
|
const certEndpoint = deps.getMyCertificationsEndpoint();
|
|
93
94
|
if (certEndpoint) {
|
|
@@ -99,10 +100,11 @@ async function fetchHeldRoles({ deps, hubAccessToken }) {
|
|
|
99
100
|
}
|
|
100
101
|
const assignedEndpoint = deps.getAssignedClustersEndpoint();
|
|
101
102
|
if (assignedEndpoint) {
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
103
|
+
const res = await deps.requestAssignedClusters({ hubAccessToken }, { endpoint: assignedEndpoint });
|
|
104
|
+
if (res.ok) assigned.push(...(res.clusters || []));
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
+
held.push(...assigned);
|
|
107
|
+
return { held: dedupeByCluster(held), assigned, mainClusterId };
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
// `change-role`: switch the MAIN role to one of the roles the talent already holds.
|
|
@@ -130,12 +132,12 @@ async function runChangeMainRole({ io, session, catalog, deps = makeRolesDeps()
|
|
|
130
132
|
|
|
131
133
|
// The onboarding final step: pick a MAIN role from the roles resolved for the
|
|
132
134
|
// talent (interview recommendations + any held). Degrades gracefully to a skip.
|
|
133
|
-
async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRolesDeps() }) {
|
|
135
|
+
async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRolesDeps(), onRolesShown = () => {} }) {
|
|
134
136
|
const r = catalog.roles;
|
|
135
137
|
const hubAccessToken = session ? session.hubAccessToken : null;
|
|
136
138
|
|
|
137
139
|
io.section(r.mainRoleTitle);
|
|
138
|
-
const { held, mainClusterId } = await io.withProgress(r.loadingHeld, () => fetchHeldRoles({ deps, hubAccessToken }));
|
|
140
|
+
const { held, assigned, mainClusterId } = await io.withProgress(r.loadingHeld, () => fetchHeldRoles({ deps, hubAccessToken }));
|
|
139
141
|
|
|
140
142
|
// Prefer the talent's own/recommended roles; fall back to the catalog for a
|
|
141
143
|
// fresh talent so a main role can always be set (picking one adds it first).
|
|
@@ -150,6 +152,7 @@ async function runOnboardingMainRoleStep({ io, session, catalog, deps = makeRole
|
|
|
150
152
|
io.notify(r.mainRolePending);
|
|
151
153
|
return { ok: true, chosen: null };
|
|
152
154
|
}
|
|
155
|
+
onRolesShown({ suggested: assigned.filter((c) => c.type === 'RECOMMENDED').map((c) => c.clusterId), clustersCount: assigned.length });
|
|
153
156
|
const choice = await chooseRole({
|
|
154
157
|
io, roles, header: r.mainRolePrompt, hint: r.selectHint, initialClusterId: mainClusterId,
|
|
155
158
|
});
|
|
@@ -19,9 +19,9 @@ const SEND_THROTTLE_MS = 60 * 60 * 1000;
|
|
|
19
19
|
|
|
20
20
|
// consent state (~/.config/ai-footprint/consent.json) Replaces the old `credentials.json` (token model).
|
|
21
21
|
|
|
22
|
-
function loadConsentState() {
|
|
22
|
+
function loadConsentState(env = process.env) {
|
|
23
23
|
try {
|
|
24
|
-
return JSON.parse(fs.readFileSync(consentPath(), 'utf8'));
|
|
24
|
+
return JSON.parse(fs.readFileSync(consentPath(env), 'utf8'));
|
|
25
25
|
} catch {
|
|
26
26
|
return null;
|
|
27
27
|
}
|