shakers-cli-beta 0.8.0-beta.5 → 0.10.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.
Files changed (45) hide show
  1. package/README.md +39 -9
  2. package/bin/ai-usage.js +37 -19
  3. package/bin/login.js +1 -1
  4. package/bin/mcp.js +25 -21
  5. package/bin/mcpb.js +5 -0
  6. package/package.json +3 -2
  7. package/src/auth-session-store.js +1 -0
  8. package/src/authorship.js +7 -6
  9. package/src/backend-request.js +2 -2
  10. package/src/certify-dimension-client.js +0 -54
  11. package/src/certify-dimension-flow.js +20 -81
  12. package/src/config.js +29 -8
  13. package/src/cv-file.js +62 -0
  14. package/src/device-login.js +5 -3
  15. package/src/find-projects-client.js +16 -6
  16. package/src/git-activity.js +8 -7
  17. package/src/git-log-prefetch.js +53 -0
  18. package/src/i18n.js +33 -58
  19. package/src/mcp-ai-usage-tool.js +150 -65
  20. package/src/mcp-auth-form.js +213 -44
  21. package/src/mcp-certify-dimension-tools.js +2 -2
  22. package/src/mcp-choice.js +47 -0
  23. package/src/mcp-install.js +32 -3
  24. package/src/mcp-instructions.js +27 -0
  25. package/src/mcp-onboarding-tools.js +3 -3
  26. package/src/mcp-prompts.js +73 -0
  27. package/src/mcp-rate-tools.js +2 -2
  28. package/src/mcp-register-tools.js +89 -313
  29. package/src/mcp-role-tools.js +177 -16
  30. package/src/mcp-server.js +108 -4
  31. package/src/mcp-signup-client.js +118 -0
  32. package/src/mcp-signup-tools.js +818 -0
  33. package/src/onboarding-client.js +28 -52
  34. package/src/onboarding-flow.js +3 -147
  35. package/src/onboarding-status-client.js +16 -1
  36. package/src/register-context.js +1 -17
  37. package/src/roles-client.js +3 -1
  38. package/src/scanner.js +13 -1
  39. package/src/session-normalizers.js +174 -0
  40. package/src/session-scan.js +149 -10
  41. package/src/signup-client.js +33 -21
  42. package/src/signup-copy.js +338 -0
  43. package/src/signup-language.js +21 -0
  44. package/src/usage-run-steps.js +3 -2
  45. package/src/work-streams.js +7 -2
@@ -1,11 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
5
3
  const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
6
4
  const { WORKS_CODE_TO_REASON } = require('./skills-client');
7
5
  const { toLowerLang, toUpperLang } = require('./lang-codes');
8
- const { expandHome } = require('./env-paths');
6
+ const { readLocalCv } = require('./cv-file');
9
7
 
10
8
  const DEFAULT_TIMEOUT_MS = 20000;
11
9
  const IMPORT_KICKOFF_TIMEOUT_MS = 90000;
@@ -42,46 +40,48 @@ function envelopeData(raw) {
42
40
  return json.data;
43
41
  }
44
42
 
45
- function buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language } = {}) {
43
+ function buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language, userQuery, fillEmptyOnly } = {}) {
46
44
  const sources = { language: toLowerLang(language) };
47
45
  if (typeof linkedinUrl === 'string' && linkedinUrl) sources.linkedinUrl = linkedinUrl;
48
46
  if (typeof cvPath === 'string' && cvPath) sources.cvPath = cvPath;
49
47
  if (typeof githubUrl === 'string' && githubUrl) sources.githubUrl = githubUrl;
50
48
  if (typeof websiteUrl === 'string' && websiteUrl) sources.websiteUrl = websiteUrl;
49
+ if (typeof userQuery === 'string' && userQuery) sources.userQuery = userQuery;
50
+ if (fillEmptyOnly === true) sources.fillEmptyOnly = true;
51
51
  return sources;
52
52
  }
53
53
 
54
- // The CV formats and size hub accepts on import-profile.
55
- const CV_CONTENT_TYPES = {
56
- '.pdf': 'application/pdf',
57
- '.doc': 'application/msword',
58
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
59
- };
60
- const CV_MAX_BYTES = 10 * 1024 * 1024;
54
+ // Hub answers import-profile synchronously with a per-source report; null when the AI omitted it.
55
+ function importReportFrom(data) {
56
+ const report = data && typeof data === 'object' ? data.importReport : null;
57
+ if (!report || !Array.isArray(report.sources)) return null;
58
+ return {
59
+ outcome: typeof report.outcome === 'string' ? report.outcome : null,
60
+ sources: report.sources
61
+ .filter((src) => src && typeof src.source === 'string')
62
+ .map((src) => ({
63
+ source: src.source,
64
+ status: typeof src.status === 'string' ? src.status : null,
65
+ ...(typeof src.code === 'string' ? { code: src.code } : {}),
66
+ ...(typeof src.retryable === 'boolean' ? { retryable: src.retryable } : {}),
67
+ })),
68
+ };
69
+ }
61
70
 
71
+ // The CV formats hub accepts on import-profile.
62
72
  function readCvUpload(input) {
63
- const cvPath = expandHome(input);
64
- const contentType = CV_CONTENT_TYPES[path.extname(cvPath).toLowerCase()];
65
- if (!contentType) return { ok: false, reason: 'cv-bad-format' };
66
- let data;
67
- try {
68
- data = fs.readFileSync(cvPath);
69
- } catch {
70
- return { ok: false, reason: 'cv-not-found' };
71
- }
72
- if (data.length > CV_MAX_BYTES) return { ok: false, reason: 'cv-too-large' };
73
- return { ok: true, file: { filename: path.basename(cvPath), contentType, data } };
73
+ return readLocalCv(input, { extensions: ['.pdf', '.doc', '.docx'], badFormat: 'cv-bad-format' });
74
74
  }
75
75
 
76
76
  async function requestImportProfile(
77
- { linkedinUrl, cvPath, githubUrl, websiteUrl, language, accessToken, hubAccessToken } = {},
77
+ { linkedinUrl, cvPath, githubUrl, websiteUrl, language, userQuery, fillEmptyOnly, accessToken, hubAccessToken } = {},
78
78
  { endpoint, timeoutMs = IMPORT_KICKOFF_TIMEOUT_MS } = {},
79
79
  ) {
80
80
  if (!endpoint) return { ok: false, reason: 'no-endpoint' };
81
- if (!linkedinUrl) return { ok: false, reason: 'no-linkedin' };
81
+ if (!linkedinUrl && !cvPath && !githubUrl && !websiteUrl) return { ok: false, reason: 'no-source' };
82
82
  if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
83
83
 
84
- const body = buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language });
84
+ const body = buildImportSources({ linkedinUrl, cvPath, githubUrl, websiteUrl, language, userQuery, fillEmptyOnly });
85
85
  let res;
86
86
  try {
87
87
  if (body.cvPath) {
@@ -90,7 +90,8 @@ async function requestImportProfile(
90
90
  const cv = readCvUpload(body.cvPath);
91
91
  if (!cv.ok) return cv;
92
92
  const { cvPath: _localPath, ...fields } = body;
93
- res = await postMultipartWithTimeout(endpoint, { ...fields, cv: cv.file }, timeoutMs, 'POST', hubHeaders({ hubAccessToken }));
93
+ const form = { ...fields, fillEmptyOnly: fields.fillEmptyOnly ? 'true' : undefined };
94
+ res = await postMultipartWithTimeout(endpoint, { ...form, cv: cv.file }, timeoutMs, 'POST', hubHeaders({ hubAccessToken }));
94
95
  } else {
95
96
  res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, hubHeaders({ hubAccessToken }));
96
97
  }
@@ -101,31 +102,7 @@ async function requestImportProfile(
101
102
 
102
103
  const data = envelopeData(res.raw);
103
104
  if (data === undefined) return { ok: false, reason: 'bad-response' };
104
- const jobId = data && typeof data === 'object' && (data.jobId || data.importId || data.id) || null;
105
- const state = data && typeof data === 'object' && typeof data.state === 'string' ? data.state : 'running';
106
- return { ok: true, jobId, state };
107
- }
108
-
109
- async function requestImportStatus(
110
- { jobId, accessToken, hubAccessToken } = {},
111
- { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
112
- ) {
113
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
114
- if (!jobId) return { ok: false, reason: 'no-job' };
115
-
116
- const url = `${endpoint.replace(/\/+$/, '')}/${encodeURIComponent(jobId)}`;
117
- let res;
118
- try {
119
- res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, hubHeaders({ hubAccessToken }));
120
- } catch (e) {
121
- return { ok: false, reason: (e && e.kind) || 'network-error' };
122
- }
123
- if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
124
-
125
- const data = envelopeData(res.raw);
126
- if (data === undefined || !data || typeof data !== 'object') return { ok: false, reason: 'bad-response' };
127
- const state = typeof data.state === 'string' ? data.state : 'running';
128
- return { ok: true, state };
105
+ return { ok: true, report: importReportFrom(data) };
129
106
  }
130
107
 
131
108
  async function requestSetProfessionalDetails(
@@ -380,7 +357,6 @@ async function requestCompleteTextSession(
380
357
 
381
358
  module.exports = {
382
359
  requestImportProfile,
383
- requestImportStatus,
384
360
  requestSetProfessionalDetails,
385
361
  requestSetPricingRate,
386
362
  requestSetAvailability,
@@ -241,120 +241,6 @@ function validateAvailability(input = {}) {
241
241
  return { ok: true, availability: body };
242
242
  }
243
243
 
244
- // Fields the talent must ALWAYS confirm explicitly — never inferred/invented by
245
- // the model (a wrong price or motivation is worse than a gap).
246
- const MUST_CONFIRM_FIELDS = ['pricing', 'workSituation.changeMotivators'];
247
-
248
- // PURE preview of a (possibly partial) register payload: what WOULD be written,
249
- // what is still missing, and what is invalid — reusing the same validators the
250
- // write path uses. Touches no network and needs no session, so the model can show
251
- // the talent a review before the single `register` write.
252
- function buildRegisterPreview(args = {}) {
253
- const wouldWrite = [];
254
- const missingFields = [];
255
- const warnings = [];
256
- const addMissing = (field, required) => missingFields.push({ field, required });
257
-
258
- // --- account (always written at registration; password never in the payload) ---
259
- const account = {
260
- name: args.name,
261
- lastName: args.lastName,
262
- email: args.email,
263
- freelanceType: args.freelanceType,
264
- };
265
- if (!isNonEmptyString(account.name)) addMissing('name', true);
266
- if (!isNonEmptyString(account.lastName)) addMissing('lastName', true);
267
- if (!isNonEmptyString(account.email)) addMissing('email', true);
268
- const ftype = normalizeFreelanceType(account.freelanceType);
269
- if (!ftype) {
270
- if (isNonEmptyString(account.freelanceType)) warnings.push({ field: 'freelanceType', reason: 'bad-freelance-type' });
271
- else addMissing('freelanceType', true);
272
- }
273
- wouldWrite.push({
274
- section: 'account',
275
- endpoint: 'hub: auth/sign-up/email + works/auth/complete-registration',
276
- data: {
277
- name: isNonEmptyString(account.name) ? account.name : null,
278
- lastName: isNonEmptyString(account.lastName) ? account.lastName : null,
279
- email: isNonEmptyString(account.email) ? account.email : null,
280
- freelanceType: ftype || null,
281
- preferredLanguage: isNonEmptyString(args.preferredLanguage) ? args.preferredLanguage : null,
282
- newsletterConsent: args.newsletterConsent === true,
283
- },
284
- note: 'password is never part of this payload — the talent types it in the local sign-up window',
285
- });
286
-
287
- // --- profile import (linkedinUrl is the required source) ---
288
- const profile = args.profile && typeof args.profile === 'object' ? args.profile : {};
289
- if (!isNonEmptyString(profile.linkedinUrl)) addMissing('profile.linkedinUrl', true);
290
- else {
291
- const pdata = { linkedinUrl: profile.linkedinUrl };
292
- for (const k of ['githubUrl', 'websiteUrl', 'cvPath']) if (isNonEmptyString(profile[k])) pdata[k] = profile[k];
293
- wouldWrite.push({ section: 'profile', endpoint: 'hub: works/me/import-profile', data: pdata });
294
- }
295
-
296
- // --- work situation (situation required) ---
297
- const ws = args.workSituation && typeof args.workSituation === 'object' ? args.workSituation : {};
298
- if (!isNonEmptyString(ws.situation)) addMissing('workSituation.situation', true);
299
- else {
300
- const vws = validateWorkSituation(ws);
301
- if (!vws.ok) warnings.push({ field: 'workSituation', reason: vws.reason });
302
- else wouldWrite.push({ section: 'workSituation', endpoint: 'hub: works/me/professional-details', data: vws.professionalDetails });
303
- }
304
-
305
- // --- pricing (at least one variant; ALWAYS confirm) ---
306
- const pricing = args.pricing && typeof args.pricing === 'object' ? args.pricing : {};
307
- const pricingEmpty = pricing.fullTimeSelected !== true && pricing.partTimeSelected !== true
308
- && pricing.fullTimeAmount == null && pricing.partTimeAmount == null;
309
- if (pricingEmpty) addMissing('pricing', true);
310
- else {
311
- const vp = validatePricing(pricing);
312
- if (!vp.ok) warnings.push({ field: 'pricing', reason: vp.reason });
313
- else wouldWrite.push({ section: 'pricing', endpoint: 'hub: works/talents/me/work-details/pricing-rate', data: vp.pricing });
314
- }
315
-
316
- // --- availability (optional; suggest the sub-fields the talent did not give) ---
317
- if (args.availability && typeof args.availability === 'object') {
318
- const va = validateAvailability(args.availability);
319
- if (!va.ok) warnings.push({ field: 'availability', reason: va.reason });
320
- else {
321
- wouldWrite.push({ section: 'availability', endpoint: 'hub: works/talents/me/work-details/availability', data: va.availability });
322
- for (const k of ['available', 'monthlyHours', 'workModes', 'country', 'timezone']) {
323
- if (!(k in va.availability)) addMissing(`availability.${k}`, false);
324
- }
325
- }
326
- } else {
327
- addMissing('availability', false);
328
- }
329
-
330
- // --- languages (optional; ids resolve at write-time, so the preview shows codes) ---
331
- if (Array.isArray(args.languages) && args.languages.length > 0) {
332
- const vl = validateLanguageRows(args.languages);
333
- if (!vl.ok) warnings.push({ field: 'languages', reason: vl.reason });
334
- else wouldWrite.push({ section: 'languages', endpoint: 'hub: works/talents/me/work-details/languages', data: vl.rows, note: 'language codes resolve to ids at write time' });
335
- } else {
336
- addMissing('languages', false);
337
- }
338
-
339
- // --- phone (optional) ---
340
- if (args.phone && typeof args.phone === 'object') {
341
- const vph = validatePhone(args.phone);
342
- if (!vph.ok) warnings.push({ field: 'phone', reason: vph.reason });
343
- else wouldWrite.push({ section: 'phone', endpoint: 'hub: works/me', data: { telephoneCode: vph.telephoneCode, telephoneNumber: vph.telephoneNumber } });
344
- } else {
345
- addMissing('phone', false);
346
- }
347
-
348
- return {
349
- ok: true,
350
- provenance: 'self-declared',
351
- wouldWrite,
352
- missingFields,
353
- warnings,
354
- mustConfirm: MUST_CONFIRM_FIELDS,
355
- };
356
- }
357
-
358
244
  function makeDeps(overrides = {}) {
359
245
  const config = require('./config');
360
246
  const client = require('./onboarding-client');
@@ -392,7 +278,6 @@ function makeDeps(overrides = {}) {
392
278
  getTalentProfileUrl: config.getTalentProfileUrl,
393
279
  getUsageDiscoveredInventoryEndpoint: config.getUsageDiscoveredInventoryEndpoint,
394
280
  requestImportProfile: client.requestImportProfile,
395
- requestImportStatus: client.requestImportStatus,
396
281
  requestSetProfessionalDetails: client.requestSetProfessionalDetails,
397
282
  requestSetPricingRate: client.requestSetPricingRate,
398
283
  requestSetAvailability: client.requestSetAvailability,
@@ -434,19 +319,6 @@ function isAcceptablePassword(password) {
434
319
  return typeof password === 'string' && password.length >= 8;
435
320
  }
436
321
 
437
- function isNonEmptyString(value) {
438
- return typeof value === 'string' && value.trim().length > 0;
439
- }
440
-
441
- function validateRegisterFields(fields = {}) {
442
- if (!isNonEmptyString(fields.name)) return { ok: false, reason: 'missing-name' };
443
- if (!isNonEmptyString(fields.lastName)) return { ok: false, reason: 'missing-last-name' };
444
- if (!isNonEmptyString(fields.email)) return { ok: false, reason: 'missing-email' };
445
- const type = normalizeFreelanceType(fields.freelanceType);
446
- if (!type) return { ok: false, reason: 'bad-freelance-type' };
447
- return { ok: true };
448
- }
449
-
450
322
  async function signUp(deps, fields = {}) {
451
323
  // The CLI no longer asks freelanceType at signup → transient default (corrected later from the work situation via professional-details).
452
324
  if (!isAcceptablePassword(fields.password)) return { ok: false, reason: 'weak-password' };
@@ -465,6 +337,7 @@ async function signUp(deps, fields = {}) {
465
337
  newsletterConsent: fields.newsletterConsent === true,
466
338
  freelanceType: normalizeFreelanceType(fields.freelanceType) || DEFAULT_SIGNUP_FREELANCE_TYPE,
467
339
  freelanceIntent: fields.freelanceIntent,
340
+ claimCode: fields.claimCode || null,
468
341
  },
469
342
  { signUpEndpoint, completeRegistrationEndpoint },
470
343
  );
@@ -548,17 +421,6 @@ async function startProfileImport(deps, sources = {}) {
548
421
  );
549
422
  }
550
423
 
551
- async function pollProfileImport(deps, { jobId } = {}) {
552
- const gate = await requireActiveSession(deps);
553
- if (!gate.ok) return gate;
554
- const endpoint = deps.getImportProfileEndpoint();
555
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
556
- return deps.requestImportStatus(
557
- { jobId, accessToken: gate.session.accessToken, hubAccessToken: gate.session.hubAccessToken },
558
- { endpoint },
559
- );
560
- }
561
-
562
424
  // A: anchor the ingest identity to THIS talent's session email so a stale
563
425
  // consent.json from a prior alta doesn't misattribute the footprint. Never throws.
564
426
  function reattachConsentIdentity(deps) {
@@ -798,14 +660,12 @@ function profileUrl(deps) {
798
660
  }
799
661
 
800
662
  function usageDisclaimers(lang) {
801
- const { getCatalog } = require('./i18n');
802
- const c = getCatalog(lang).onboarding;
663
+ const c = require('./signup-copy').legalCopy(lang);
803
664
  return { infoAccessed: c.usageInfoAccessed, goalDuration: c.usageGoalDuration };
804
665
  }
805
666
 
806
667
  function interviewDisclaimers(lang) {
807
- const { getCatalog } = require('./i18n');
808
- const c = getCatalog(lang).onboarding;
668
+ const c = require('./signup-copy').legalCopy(lang);
809
669
  return { infoAccessed: c.interviewInfoAccessed, goalDuration: c.interviewGoalDuration };
810
670
  }
811
671
 
@@ -1165,9 +1025,6 @@ module.exports = {
1165
1025
  validateAvailability,
1166
1026
  buildPricingUpsert,
1167
1027
  isAcceptablePassword,
1168
- validateRegisterFields,
1169
- buildRegisterPreview,
1170
- MUST_CONFIRM_FIELDS,
1171
1028
  deriveCurrentEmploymentStatus,
1172
1029
  showsFreelanceOpinion,
1173
1030
  deriveFreelanceIntent,
@@ -1180,7 +1037,6 @@ module.exports = {
1180
1037
  establishSessionFromCredentials,
1181
1038
  registerWithSocialAccount,
1182
1039
  startProfileImport,
1183
- pollProfileImport,
1184
1040
  reattachConsentIdentity,
1185
1041
  saveWorkSituation,
1186
1042
  savePricing,
@@ -39,4 +39,19 @@ async function requestOnboardingStatus({ accessToken } = {}, { endpoint, timeout
39
39
  return { ok: true, completed: data.completed === true };
40
40
  }
41
41
 
42
- module.exports = { requestOnboardingStatus };
42
+ // GET <certs>/interviews/:id/finalization -> COLLECTING | PROCESSING | READY | FAILED for a completed interview's evaluation.
43
+ async function requestInterviewFinalization({ interviewId, accessToken } = {}, { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
44
+ if (!base || !interviewId) return { ok: false, reason: 'no-endpoint' };
45
+ let res;
46
+ try {
47
+ res = await postJsonWithTimeout(`${base}/${encodeURIComponent(interviewId)}/finalization`, null, timeoutMs, 'GET', null, authHeaders({ accessToken }));
48
+ } catch (e) {
49
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
50
+ }
51
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: `http-${res.status}` };
52
+ const json = parseJson(res.raw);
53
+ const status = json && json.data && typeof json.data.status === 'string' ? json.data.status : null;
54
+ return status ? { ok: true, status } : { ok: false, reason: 'bad-response' };
55
+ }
56
+
57
+ module.exports = { requestOnboardingStatus, requestInterviewFinalization };
@@ -123,21 +123,6 @@ function findCvCandidates(home, personName) {
123
123
  .map(({ path: cvPath, nameMatch, generic }) => ({ path: tildify(cvPath, { SHAKERS_CLI_HOME_DIR: home }), nameMatch, generic }));
124
124
  }
125
125
 
126
- // Which backend this install talks to, so the AI never calls a test sign-up
127
- // "production".
128
- function detectEnvironment(env) {
129
- let base = '';
130
- try {
131
- base = String(require('./config').getHubBase(env) || '');
132
- } catch {
133
- return 'unknown';
134
- }
135
- if (/localhost|127\.0\.0\.1/.test(base)) return 'local';
136
- if (/staging/.test(base)) return 'staging';
137
- if (/\.dev\.|new-works/.test(base)) return 'development';
138
- return base ? 'production' : 'unknown';
139
- }
140
-
141
126
  // Returns the signals found, each clearly a SUGGESTION to confirm. `talentName`
142
127
  // (what the AI already knows) sharpens the CV match beyond the git name.
143
128
  function readRegisterContextSuggestions({ cwd, env = process.env, talentName } = {}) {
@@ -146,7 +131,6 @@ function readRegisterContextSuggestions({ cwd, env = process.env, talentName } =
146
131
  const gitName = gitConfig('user.name', cwd);
147
132
  const personName = [talentName, gitName].filter((n) => typeof n === 'string' && n.trim()).join(' ');
148
133
  return {
149
- environment: detectEnvironment(env),
150
134
  timezone: detectTimezone(),
151
135
  gitName,
152
136
  gitEmail: gitConfig('user.email', cwd),
@@ -156,4 +140,4 @@ function readRegisterContextSuggestions({ cwd, env = process.env, talentName } =
156
140
  };
157
141
  }
158
142
 
159
- module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName, detectEnvironment, nameTokens, fold };
143
+ module.exports = { readRegisterContextSuggestions, detectTimezone, findCvCandidates, detectGithubUser, isGenericCvName, nameTokens, fold };
@@ -34,10 +34,12 @@ function normalizeRole(r) {
34
34
  if (!clusterId) return null;
35
35
  return {
36
36
  clusterId,
37
- name: typeof r.name === 'string' ? r.name : null,
37
+ // Hub's assigned-clusters rows name the role in clusterName.
38
+ name: typeof r.name === 'string' ? r.name : (typeof r.clusterName === 'string' ? r.clusterName : null),
38
39
  category: typeof r.category === 'string' ? r.category : null,
39
40
  proposalKind: typeof r.proposalKind === 'string' ? r.proposalKind : null,
40
41
  type: typeof r.type === 'string' ? r.type : null,
42
+ source: typeof r.source === 'string' ? r.source : null,
41
43
  };
42
44
  }
43
45
 
package/src/scanner.js CHANGED
@@ -202,7 +202,19 @@ function computeRecency(paths) {
202
202
  /* ---------- version: ONLY runs the ALREADY detected binary, with --version ---------- */
203
203
  /* Never arbitrary commands; all output is discarded except the version pattern. */
204
204
 
205
+ // scan() runs once per selected repo; remember each binary's version for a minute instead of asking again.
206
+ const VERSION_TTL_MS = 60 * 1000;
207
+ const versionCache = new Map();
208
+
205
209
  function getVersion(bin) {
210
+ const cached = versionCache.get(bin);
211
+ if (cached && Date.now() - cached.at <= VERSION_TTL_MS) return cached.version;
212
+ const version = readVersion(bin);
213
+ versionCache.set(bin, { version, at: Date.now() });
214
+ return version;
215
+ }
216
+
217
+ function readVersion(bin) {
206
218
  try {
207
219
  const out = execFileSync(bin, ['--version'], {
208
220
  timeout: 3000,
@@ -609,4 +621,4 @@ function mergeReports(reports) {
609
621
  };
610
622
  }
611
623
 
612
- module.exports = { scan, scanForRoots };
624
+ module.exports = { scan, scanForRoots, getVersion };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ // Input normalizers: map a RAW session record into the Claude-shaped internal object model
4
+ // that session-scan harvest + transcript-turns already consume
5
+ // ({ cwd, timestamp, type, message:{ role, content:[{type:'text'|'tool_use', ...}] } }).
6
+ // They ONLY translate input shape — downstream redaction/upload (share.js#derivePayload
7
+ // -> POST usage/reports) is unchanged; no field is read that the Claude path didn't expose.
8
+ // FAIL-CLOSED: every normalizer returns null / [] when a record does not match the expected
9
+ // shape — it never fabricates or guesses fields. A documented format that differs from the
10
+ // real one yields "no data for that tool", never wrong data.
11
+
12
+ function str(v) {
13
+ return typeof v === 'string' && v ? v : null;
14
+ }
15
+
16
+ function textFromParts(parts) {
17
+ if (typeof parts === 'string') return parts.trim() || null;
18
+ if (!Array.isArray(parts)) return null;
19
+ const out = [];
20
+ for (const p of parts) {
21
+ if (typeof p === 'string') out.push(p);
22
+ else if (p && typeof p === 'object' && typeof p.text === 'string') out.push(p.text);
23
+ }
24
+ const joined = out.join('').trim();
25
+ return joined || null;
26
+ }
27
+
28
+ // --- Codex (~/.codex/sessions/**/rollout-*.jsonl) ---
29
+ // Reads timestamp, cwd (session_meta), user text, and shell commands; a record matching none
30
+ // of those passes through only its own real timestamp/cwd, else is dropped.
31
+ function codexCommand(args) {
32
+ let a = args;
33
+ if (typeof a === 'string') {
34
+ try { a = JSON.parse(a); } catch { return a.trim() || null; }
35
+ }
36
+ if (!a || typeof a !== 'object') return null;
37
+ const cmd = a.command ?? a.cmd ?? a.commandLine;
38
+ if (Array.isArray(cmd)) return cmd.map(String).join(' ').trim() || null;
39
+ return str(cmd);
40
+ }
41
+
42
+ function normalizeCodexRecord(raw) {
43
+ if (!raw || typeof raw !== 'object') return null;
44
+ const payload = raw.payload && typeof raw.payload === 'object' ? raw.payload : null;
45
+ const item = payload || raw;
46
+ const out = {};
47
+ const ts = str(raw.timestamp) || str(item.timestamp);
48
+ if (ts) out.timestamp = ts;
49
+ const cwd = str(raw.cwd) || str(item.cwd);
50
+ if (cwd) out.cwd = cwd;
51
+
52
+ const role = str(item.role);
53
+ if (role === 'user') {
54
+ const text = textFromParts(item.content);
55
+ if (text) {
56
+ out.type = 'user';
57
+ out.message = { role: 'user', content: [{ type: 'text', text }] };
58
+ return out;
59
+ }
60
+ }
61
+
62
+ const type = str(raw.type) || str(item.type);
63
+ const name = str(item.name) || (type === 'function_call' ? 'shell' : null);
64
+ if (name && /shell|exec|bash|terminal|command/i.test(name)) {
65
+ const command = codexCommand(item.arguments ?? item.input ?? item.args);
66
+ if (command) {
67
+ out.message = { content: [{ type: 'tool_use', name: 'Shell', input: { command } }] };
68
+ return out;
69
+ }
70
+ }
71
+
72
+ return out.timestamp || out.cwd ? out : null;
73
+ }
74
+
75
+ // --- Gemini CLI (~/.gemini/tmp/<project-hash>/chats/logs.json | session-*.jsonl) ---
76
+ // logs.json = JSON array of { type:'user'|'model', message, timestamp }; session-*.jsonl =
77
+ // per-line { role, parts }. No cwd is recorded (the dir is a one-way SHA-256 hash), so cwd
78
+ // stays null — global session/time metrics still count; per-repo scoping can't.
79
+ function normalizeGeminiRecord(raw) {
80
+ if (!raw || typeof raw !== 'object') return null;
81
+ const out = {};
82
+ const ts = str(raw.timestamp) || str(raw.time);
83
+ if (ts) out.timestamp = ts;
84
+
85
+ const isUser = str(raw.type) === 'user' || str(raw.role) === 'user';
86
+ if (isUser) {
87
+ const text = str(raw.message) || textFromParts(raw.parts) || textFromParts(raw.content);
88
+ if (text) {
89
+ out.type = 'user';
90
+ out.message = { role: 'user', content: [{ type: 'text', text }] };
91
+ return out;
92
+ }
93
+ }
94
+ return out.timestamp ? out : null;
95
+ }
96
+
97
+ // --- Continue (continue.dev) ~/.continue/sessions/*.json ---
98
+ // Each file is one session object; expand its history into per-turn objects, carrying
99
+ // workspaceDirectory as cwd (good repo attribution).
100
+ function normalizeContinueRecord(raw) {
101
+ if (!raw || typeof raw !== 'object') return null;
102
+ const cwd = str(raw.workspaceDirectory) || str(raw.workspace) || str(raw.cwd);
103
+ const history = Array.isArray(raw.history) ? raw.history
104
+ : (Array.isArray(raw.messages) ? raw.messages : (Array.isArray(raw.conversation) ? raw.conversation : null));
105
+ // A single message object (not a session) — handle defensively too.
106
+ if (!history) {
107
+ const msg = raw.message && typeof raw.message === 'object' ? raw.message : raw;
108
+ if (str(msg.role) === 'user') {
109
+ const text = typeof msg.content === 'string' ? msg.content.trim() : textFromParts(msg.content);
110
+ if (text) return oneUser(text, cwd, str(raw.timestamp) || str(raw.createdAt));
111
+ }
112
+ return null;
113
+ }
114
+ const out = [];
115
+ for (const entry of history) {
116
+ const msg = entry && entry.message && typeof entry.message === 'object' ? entry.message : entry;
117
+ if (!msg || typeof msg !== 'object') continue;
118
+ const ts = str(entry.timestamp) || str(entry.createdAt) || str(msg.timestamp);
119
+ // Only emit on a real user turn (with text) or a record carrying a real timestamp.
120
+ if (str(msg.role) === 'user') {
121
+ const text = typeof msg.content === 'string' ? msg.content.trim() : textFromParts(msg.content);
122
+ if (text) { out.push(oneUser(text, cwd, ts)); continue; }
123
+ }
124
+ if (ts) out.push(cwd ? { cwd, timestamp: ts } : { timestamp: ts });
125
+ }
126
+ return out.length ? out : null;
127
+ }
128
+
129
+ // --- Cline / Roo Code: VS Code globalStorage .../tasks/<id>/api_conversation_history.json ---
130
+ // One JSON array of Anthropic messages (role/content); no cwd and no timestamp in this file
131
+ // (ui_messages.json has ts) → cwd/time attribution absent. Emits only real user turns.
132
+ function normalizeClineRecord(raw) {
133
+ if (!raw || typeof raw !== 'object') return null;
134
+ if (str(raw.role) !== 'user') return null;
135
+ const text = typeof raw.content === 'string' ? raw.content.trim() : textFromParts(raw.content);
136
+ return text ? oneUser(text, null, null) : null;
137
+ }
138
+
139
+ // --- Aider: per-repo .aider.chat.history.md (markdown) ---
140
+ // Repo-level source → cwd is the repo toplevel (good attribution). Markdown parse:
141
+ // '# aider chat started at <ts>' sets the session time; '#### <text>' lines are user
142
+ // instructions. A file matching neither pattern yields 0 records.
143
+ function parseAiderHistory(text, cwd) {
144
+ if (typeof text !== 'string' || !text) return [];
145
+ const out = [];
146
+ let ts = null;
147
+ for (const line of text.split('\n')) {
148
+ const started = /^#\s*aider chat started at\s+(.+?)\s*$/i.exec(line);
149
+ if (started) {
150
+ const t = Date.parse(started[1].replace(' ', 'T'));
151
+ ts = Number.isFinite(t) ? new Date(t).toISOString() : null;
152
+ if (ts) out.push(cwd ? { cwd, timestamp: ts } : { timestamp: ts });
153
+ continue;
154
+ }
155
+ const user = /^####\s+(.*)$/.exec(line);
156
+ if (user && user[1].trim()) out.push(oneUser(user[1].trim(), cwd, ts));
157
+ }
158
+ return out;
159
+ }
160
+
161
+ function oneUser(text, cwd, ts) {
162
+ const o = { type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } };
163
+ if (cwd) o.cwd = cwd;
164
+ if (ts) o.timestamp = ts;
165
+ return o;
166
+ }
167
+
168
+ module.exports = {
169
+ normalizeCodexRecord,
170
+ normalizeGeminiRecord,
171
+ normalizeContinueRecord,
172
+ normalizeClineRecord,
173
+ parseAiderHistory,
174
+ };