shakers-cli-beta 0.8.0-beta.4 → 0.9.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 CHANGED
@@ -97,7 +97,7 @@ The **usage** signal is entirely local (no network, no model). See
97
97
  One line:
98
98
 
99
99
  ```bash
100
- curl -fsSL https://raw.githubusercontent.com/devShakers/ai-usage-evaluator/main/install.sh | bash
100
+ curl -fsSL https://raw.githubusercontent.com/devShakers/ai-usage-evaluator/release/install.sh | bash
101
101
  ```
102
102
 
103
103
  The installer:
@@ -135,17 +135,21 @@ env vars and `config.json` always win over the baked default.
135
135
  | Flavor | Package name | Registry | Baked target | Publish |
136
136
  |---|---|---|---|---|
137
137
  | Private (current) | `@shakers/shakers-cli` | GitLab proj 201 (`publishConfig`) | dev (new-works) | `export SHAKERS_GITLAB_TOKEN=<PAT, scope write_registry on proj 201>` then `npm publish` |
138
- | Public beta | `shakers-cli-beta` | npmjs (public) | staging | `node scripts/prepare-beta.js` → `npm publish --tag beta` → `git checkout -- package.json` |
138
+ | Public beta | `shakers-cli-beta` | npmjs (public) | staging | bump `BETA.version` in `scripts/prepare-beta.js`, then `npm run publish:beta` |
139
139
  | Local | (checkout, `@shakers/shakers-cli`) | — | dev, or `SHAKERS_CLI_ENV=local` for localhost | run from the clone; no publish |
140
140
 
141
141
  Both publishes bump `version` first (the registry rejects re-publishing an
142
- existing version). The private one publishes from a checked-out `main` with the
143
- committed `@shakers/shakers-cli` manifest, unchanged. The beta one uses
144
- `scripts/prepare-beta.js` to stamp `name`/`version`/`publishConfig` (version
145
- `0.8.0-beta.0`, dist-tag `beta`), publishes, then restores the manifest with
146
- git. The npmjs org/account for `shakers-cli-beta` must exist and you must be
147
- `npm login`ed to it (or set `NODE_AUTH_TOKEN`) before publishing — nothing here
148
- publishes automatically.
142
+ existing version). The private one publishes from a checked-out `release` with the
143
+ committed `@shakers/shakers-cli` manifest, unchanged. For the beta, bump
144
+ `BETA.version` in `scripts/prepare-beta.js` (manual, one line), then run the
145
+ single command `npm run publish:beta`: it stamps `name`/`version`/`publishConfig`,
146
+ runs `npm publish --tag latest`, and restores the private manifest with git. The
147
+ tag is explicit because npm refuses to publish a prerelease (`0.8.0-beta.N`) to
148
+ `latest` without one; `--tag latest` still moves `latest` to the new version, so
149
+ testers install `shakers-cli-beta` bare (which resolves `latest`) with no manual
150
+ `npm dist-tag add` step. The npmjs account for `shakers-cli-beta` must
151
+ exist and you must be `npm login`ed (or set `NODE_AUTH_TOKEN`) first — nothing
152
+ here publishes automatically.
149
153
 
150
154
  `@livekit/rtc-node` is a real `dependencies` entry, so `npm i` / `npm i -g` of
151
155
  either package pulls it automatically (GitLab's packument keeps `dependencies`
@@ -583,9 +587,10 @@ run without a session (that is how you obtain/drop one); `logout` with no
583
587
  session is a silent no-op.
584
588
 
585
589
  ```bash
586
- shakers login # no method flag: interactive picker (Email / Google) on a real TTY
590
+ shakers login # no method flag: interactive picker (Email / Google / LinkedIn) on a real TTY
587
591
  shakers login --email you@shakers.com # skip the picker, prompt only for the password
588
592
  shakers login --google # skip the picker, go straight to the Google flow
593
+ shakers login --linkedin # skip the picker, go straight to the LinkedIn flow
589
594
  shakers logout # forget the stored session (no-op if none)
590
595
  ```
591
596
 
@@ -604,7 +609,11 @@ shakers logout # forget the stored session (no-op if none)
604
609
  your machine — no client id, no loopback server, no secret; the Hub owns all
605
610
  Google config and does the token exchange server-side. After you authorize, the
606
611
  Hub session is exchanged for the same API JWT the email flow stores.
607
- - **LinkedIn is parked** (ADR-041/046) — not implemented.
612
+ - **LinkedIn** signs in through the same device flow as Google, with
613
+ `provider=linkedin`: the `login --linkedin` / `--provider linkedin` flags and
614
+ the sign-in window served to AI apps (the loopback method-choice screen) both
615
+ route to it. This reverses the earlier parking (ADR-041/046). Whether it
616
+ completes depends on the Hub having a LinkedIn OAuth app provisioned.
608
617
  - The session (`{ accessToken, expiresAt, email }`) is stored **atomically**,
609
618
  permissions `600`, in `~/.config/shakers/auth-session.json`. **Logged in**
610
619
  means "an unexpired token is present" — an unparseable or missing
package/bin/ai-usage.js CHANGED
@@ -6,6 +6,10 @@ const { detectReportLang, getCatalog } = require('../src/i18n');
6
6
  const { parseArgs } = require('../src/cli-args');
7
7
  const { loadConsentState } = require('../src/share');
8
8
  const { handleOneShotCommand } = require('../src/usage-oneshot-commands');
9
+ const { prefetchGitLogs } = require('../src/git-log-prefetch');
10
+ const { GIT_ACTIVITY_LOG_ARGS } = require('../src/git-activity');
11
+ const { WORK_STREAMS_LOG_ARGS } = require('../src/work-streams');
12
+ const { AUTHORSHIP_LOG_ARGS } = require('../src/authorship');
9
13
  const {
10
14
  resolveRepoScope,
11
15
  machineWideToplevels,
@@ -56,6 +60,7 @@ async function run(argv = process.argv.slice(2), { ask: injectedAsk = null, bypa
56
60
  const agentRoots = scope.toplevels;
57
61
 
58
62
  // 5. Aggregate scoped signals + rebuild scan/org-chart over the selection, re-classify.
63
+ await prefetchGitLogs(agentRoots, [GIT_ACTIVITY_LOG_ARGS, WORK_STREAMS_LOG_ARGS, AUTHORSHIP_LOG_ARGS]);
59
64
  runAggregation({ report, scope, consentEmail, agentRoots, root, catalog });
60
65
  const maturity = classify(report);
61
66
  const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
package/bin/login.js CHANGED
@@ -78,7 +78,7 @@ async function run(
78
78
  if (opts.google) return runSocial('google', c, { openBrowser, sleep, lang });
79
79
  if (opts.linkedin) return runSocial('linkedin', c, { openBrowser, sleep, lang });
80
80
 
81
- // No method flag: offer the picker (ADR-041/046 — Email and Google only, LinkedIn stays parked).
81
+ // No method flag: offer the picker (ADR-041/046 — Email, Google and LinkedIn; LinkedIn pending Hub OAuth provisioning).
82
82
  if (!hasMethodFlag(opts)) {
83
83
  const stdinIsTTY = ttyOverride !== undefined ? ttyOverride : !!process.stdin.isTTY;
84
84
  if (stdinIsTTY) {
package/bin/mcp.js CHANGED
@@ -25,6 +25,7 @@ const { makeDeviceLoginTools } = require('../src/mcp-device-login-tools');
25
25
  const { makeRegisterTools } = require('../src/mcp-register-tools');
26
26
  const { detectFlowLang } = require('../src/i18n');
27
27
  const { makeReportTools } = require('../src/mcp-report-tools');
28
+ const { makePrompts } = require('../src/mcp-prompts');
28
29
  const { ensureFreshSession } = require('../src/session-refresh');
29
30
 
30
31
  let VERSION = '';
@@ -57,22 +58,33 @@ const HELP = `\n shakers mcp — local MCP server (stdio)\n\n`
57
58
  // schemas on demand only see the tool the model picked first, so the flow lives
58
59
  // here, where the client keeps it in context.
59
60
  const SERVER_INSTRUCTIONS = [
60
- 'Shakers talent tools. When the talent asks to register or sign up on Shakers, the goal is that they type almost nothing: you fill the registration from what you know and they confirm.',
61
+ 'Shakers talent tools. When the talent asks to register or sign up on Shakers, go straight to a filled draft: you fill the registration from what you know and from their CV, and they only confirm. Never open with a questionnaire or a list of what you do not know.',
61
62
  'Register flow — load and use these tools in this order:',
62
- '1. suggest_register_context: timezone, git name/email, GitHub login and CV file candidates on this machine.',
63
- '2. If a CV candidate is clearly theirs (nameMatch) propose it; if only a generic one like cv.pdf exists, ask whether it is theirs and whether you may read it and send it with the registration. With permission call read_cv and use its text.',
64
- '3. Pre-fill EVERY register field you can infer from your memory of the talent, the chat, that context and the CV, pricing and motivation included (an estimate, labelled as such). Then call register_preview with that payload.',
65
- '4. Show ONE short summary saying where each value comes from, ask only for what you have no basis for, offer unknown optional fields in a single closing line, and let them confirm or correct once.',
66
- '5. Only then call register with the confirmed payload.',
67
- 'Do not open with a questionnaire of empty fields: propose first, ask second.',
63
+ '1. First reply: ONE short question — may you search for and read their CV on this machine to fill everything in?',
64
+ '2. With their yes: suggest_register_context (with talentName), then read_cv on the candidates (talentConsent:true, talentName) until one has mentionsTalent:true. Drop any CV with mentionsTalent:false (someone else\'s) without describing it. Take LinkedIn, GitHub, experience, languages and location from their CV.',
65
+ 'No CV, no permission, or none of the CVs is theirs: do NOT fall back to questions. Build the same draft from everything else you know about them: your memory of past conversations, a search of those conversations if you have that tool, this conversation and any context or memory files you can see. The CV adds facts; it is never a precondition.',
66
+ '3. Infer EVERY field: employment from what you know (a company email or employer means employed there), pricing as a concrete estimate from role, seniority and location, motivation as your best match. Call register_preview.',
67
+ '4. Show ONE summary saying where each value comes from; only truly unknown required fields as a short question at the end, optional unknowns in one line. The sign-up goes to suggest_register_context\'s `environment`: never call a test environment production.',
68
+ '5. After their confirmation, call register.',
68
69
  'Onboarding interview (onboarding_interview_start / onboarding_interview_turn): follow the ANSWER MODE described in onboarding_interview_start.',
69
70
  ].join('\n');
70
71
 
71
- function buildServer() {
72
+ // The LOCAL (filesystem/machine) tools — the only ones the .mcpb bundle exposes; the
73
+ // server-backed tools belong to the remote connector.
74
+ const LOCAL_ONLY_TOOL_NAMES = new Set(['ai_usage', 'ai_usage_result', 'read_cv', 'suggest_register_context']);
75
+
76
+ // `localOnly` builds the scan-only server for the Claude .mcpb bundle, reusing the SAME
77
+ // tool registry (just filtered) — no duplicate registration.
78
+ function buildServer({ localOnly = false } = {}) {
79
+ const allTools = [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools(), ...makeDeviceLoginTools(), ...makeRegisterTools({ lang: detectFlowLang() }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAskTools(), ...makeAlmaTools()];
80
+ const tools = localOnly ? allTools.filter((t) => LOCAL_ONLY_TOOL_NAMES.has(t.name)) : allTools;
81
+ // Slash-command prompts, kept to the tools actually registered in this mode.
82
+ const prompts = makePrompts(new Set(tools.map((t) => t.name)));
72
83
  return createMcpServer({
73
- tools: [makeAiUsageTool(), makeAiUsageResultTool(), ...makeOnboardingTools(), ...makeRoleTools(), ...makeDeviceLoginTools(), ...makeRegisterTools({ lang: detectFlowLang() }), ...makeReportTools(), ...makeCertifyDimensionTools(), ...makeFindProjectsTools(), ...makeShowProjectTools(), ...makeSavedPositionsTools(), ...makeInvitationsTools(), ...makeApplicationsTools(), ...makeAvailabilityTools(), ...makeCertificationsTools(), ...makeProfileTools(), ...makeRateTools(), ...makeLangTools(), ...makeSocialsTools(), ...makePortfolioTools(), ...makeAskTools(), ...makeAlmaTools()],
74
- serverInfo: { name: 'Shakers', version: VERSION || '0.0.0' },
75
- instructions: SERVER_INSTRUCTIONS,
84
+ tools,
85
+ prompts,
86
+ serverInfo: { name: localOnly ? 'Shakers (local scan)' : 'Shakers', version: VERSION || '0.0.0' },
87
+ instructions: localOnly ? null : SERVER_INSTRUCTIONS,
76
88
  // Long-lived server: refresh the email session's hub JWT from the stored
77
89
  // cookie before each tool call so a stale (~15-min) JWT never reaches a tool.
78
90
  beforeCall: () => ensureFreshSession(process.env),
@@ -92,7 +104,8 @@ async function run(argv = process.argv.slice(2)) {
92
104
 
93
105
  require('../src/config').sanitizeStaleBakedEndpoints(process.env);
94
106
 
95
- const server = buildServer();
107
+ // `--local` = the scan-only server shipped in the Claude .mcpb bundle.
108
+ const server = buildServer({ localOnly: argv.includes('--local') });
96
109
  const rl = server.start({
97
110
  input: process.stdin,
98
111
  output: process.stdout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.8.0-beta.4",
3
+ "version": "0.9.0",
4
4
  "description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -15,7 +15,9 @@
15
15
  "scripts": {
16
16
  "start": "node bin/shakers.js",
17
17
  "shakers": "node bin/shakers.js",
18
- "test": "node --test test/*.test.js"
18
+ "test": "node --test test/*.test.js",
19
+ "build:mcpb": "sh scripts/build-mcpb.sh",
20
+ "publish:beta": "node scripts/prepare-beta.js && npm publish --tag latest && git checkout -- package.json"
19
21
  },
20
22
  "engines": {
21
23
  "node": ">=18"
package/src/authorship.js CHANGED
@@ -5,9 +5,13 @@ const path = require('path');
5
5
 
6
6
  // Git AUTHORSHIP + provenance for the certify phase (skill-code-certification, ADR-017).
7
7
 
8
+ const { takePrefetchedGitLog } = require('./git-log-prefetch');
9
+
8
10
  const GIT_TIMEOUT_MS = 15000;
9
11
  const SHORT_SHA_LEN = 10;
10
12
 
13
+ const AUTHORSHIP_LOG_ARGS = ['log', '--no-merges', '--pretty=format:\x01%ae\x1f%at', '--name-only'];
14
+
11
15
  function runGit(root, args) {
12
16
  try {
13
17
  return execFileSync('git', ['-C', root, ...args], {
@@ -72,12 +76,8 @@ function getRootPrefix(root) {
72
76
  function buildPathIndex(root) {
73
77
  const authorsByPath = new Map();
74
78
  const timeByPath = new Map();
75
- const log = runGit(root, [
76
- 'log',
77
- '--no-merges',
78
- '--pretty=format:\x01%ae\x1f%at',
79
- '--name-only',
80
- ]);
79
+ const prefetched = takePrefetchedGitLog(root, AUTHORSHIP_LOG_ARGS);
80
+ const log = prefetched !== undefined ? prefetched : runGit(root, AUTHORSHIP_LOG_ARGS);
81
81
  if (log === null) return { authorsByPath, timeByPath };
82
82
 
83
83
  let currentEmail = null;
@@ -196,6 +196,7 @@ function attributeSample(sample, verifiedEmail, authorship, authorizedSet = null
196
196
 
197
197
  module.exports = {
198
198
  collectAuthorship,
199
+ AUTHORSHIP_LOG_ARGS,
199
200
  attributeSample,
200
201
  // exported for unit tests
201
202
  normalizeRemote,
@@ -4,7 +4,6 @@
4
4
 
5
5
  const { postJsonWithTimeout } = require('./backend-request');
6
6
  const { reasonForError } = require('./onboarding-client');
7
- const { toUpperLang } = require('./lang-codes');
8
7
 
9
8
  const DEFAULT_TIMEOUT_MS = 20000;
10
9
 
@@ -154,62 +153,9 @@ async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessTo
154
153
  return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
155
154
  }
156
155
 
157
- async function requestCreateCertificationInterview(
158
- { dimensionKey, languageCode, accessToken } = {},
159
- { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
160
- ) {
161
- if (!endpoint) return { ok: false, reason: 'no-endpoint' };
162
- if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
163
- const body = { dimensionKey };
164
- if (languageCode) body.languageCode = toUpperLang(languageCode);
165
- let res;
166
- try {
167
- res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, bearerHeaders(accessToken));
168
- } catch (e) {
169
- return { ok: false, reason: (e && e.kind) || 'network-error' };
170
- }
171
- if (res.status < 200 || res.status >= 300) {
172
- const reason = reasonForError(res.status, res.raw);
173
- if (reason !== 'dimension-on-cooldown') return { ok: false, reason };
174
- // certs puts the date in the message only: "... available on 2026-10-28T14:50:38.265Z".
175
- const available = String(res.raw || '').match(/available on (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/);
176
- return { ok: false, reason, availableOn: available ? available[1] : null };
177
- }
178
- const data = payloadOf(res.raw);
179
- const interviewId = pick(data || {}, ['interviewId', 'id']);
180
- if (!interviewId) return { ok: false, reason: 'bad-response' };
181
- return { ok: true, interviewId, language: pick(data || {}, ['language', 'languageCode']) || null };
182
- }
183
-
184
- // Poll the verdict; a 2xx/404/425/202 with no band yet resolves `ready:false` (retriable).
185
- async function requestCertificationReport(
186
- { interviewId, accessToken } = {},
187
- { base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
188
- ) {
189
- if (!base) return { ok: false, reason: 'no-endpoint' };
190
- if (!interviewId) return { ok: false, reason: 'no-interview' };
191
- const url = `${base.replace(/\/+$/, '')}/${encodeURIComponent(interviewId)}/report`;
192
- let res;
193
- try {
194
- res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
195
- } catch (e) {
196
- return { ok: false, reason: (e && e.kind) || 'network-error' };
197
- }
198
- if (res.status === 404 || res.status === 425 || res.status === 202) return { ok: true, ready: false, report: null };
199
- if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
200
- const data = payloadOf(res.raw);
201
- if (data === undefined || data === null || typeof data !== 'object') return { ok: true, ready: false, report: null };
202
- const band = pick(data, ['band', 'level', 'combinedLevel']);
203
- // A report with too little evidence for a level has band null; it is still finished.
204
- if (!band && !pick(data, ['evaluationId'])) return { ok: true, ready: false, report: null };
205
- return { ok: true, ready: true, report: data };
206
- }
207
-
208
156
  module.exports = {
209
157
  requestMyCertifications,
210
158
  requestTemplatesByDimension,
211
- requestCreateCertificationInterview,
212
- requestCertificationReport,
213
159
  composeDimensionKey,
214
160
  composeOfferableDimensions,
215
161
  discoverOfferableDimensions,
@@ -1,23 +1,15 @@
1
1
  'use strict';
2
2
 
3
- // `certify` dimension flow: discover -> pick -> create -> LiveKit interview -> poll verdict.
3
+ // `certify` dimension flow: discover -> pick -> hand off to the web interview.
4
4
 
5
5
  const { runInteractiveMultiSelect } = require('./interactive-select');
6
6
 
7
7
  function makeCertifyDimensionDeps(overrides = {}) {
8
8
  const config = require('./config');
9
9
  const client = require('./certify-dimension-client');
10
- const onboardingFlow = require('./onboarding-flow');
11
10
  return {
12
- getCertificationInterviewsEndpoint: config.getCertificationInterviewsEndpoint,
13
- // `{certsBase}/interviews` — shared base for LiveKit start/complete + the report poll.
14
- getInterviewsBase: config.getOnboardingInterviewsEndpoint,
11
+ getTalentProfileUrl: config.getTalentProfileUrl,
15
12
  discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
16
- requestCreateCertificationInterview: client.requestCreateCertificationInterview,
17
- requestCertificationReport: client.requestCertificationReport,
18
- conductLivekitInterview: onboardingFlow.conductLivekitInterview,
19
- onboardingDeps: onboardingFlow.makeDeps(),
20
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
21
13
  ...overrides,
22
14
  };
23
15
  }
@@ -61,41 +53,19 @@ function resolveDimensionArg(arg, items) {
61
53
  return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
62
54
  }
63
55
 
64
- // The evaluation lands one to two minutes after the interview ends, four when the queue is busy.
65
- async function pollReport(deps, { interviewId, accessToken }, { base, attempts = 60, intervalMs = 5000 } = {}) {
66
- for (let i = 0; i < attempts; i++) {
67
- const res = await deps.requestCertificationReport({ interviewId, accessToken }, { base });
68
- if (!res.ok) return { ok: false, reason: res.reason };
69
- if (res.ready) return { ok: true, report: res.report };
70
- if (i < attempts - 1) await deps.sleep(intervalMs);
56
+ // Deep link into the talent's Shakers web profile, filtered to the chosen dimension.
57
+ // One dimension can publish more than one assessment, so the filtered catalog is the
58
+ // robust target rather than a single templateId.
59
+ function buildWebLink(profileUrl, dimension) {
60
+ if (!profileUrl || !dimension || !dimension.clusterId || !dimension.slug) return null;
61
+ let origin;
62
+ try {
63
+ origin = new URL(profileUrl).origin;
64
+ } catch {
65
+ return null;
71
66
  }
72
- return { ok: false, reason: 'timeout' };
73
- }
74
-
75
- function formatDay(iso, lang) {
76
- const d = iso ? new Date(iso) : null;
77
- if (!d || Number.isNaN(d.getTime())) return null;
78
- return d.toLocaleDateString(lang === 'en' ? 'en-GB' : 'es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
79
- }
80
-
81
- function renderVerdict(report, cd) {
82
- const lines = [];
83
- const band = report.band || report.level || report.combinedLevel;
84
- const dimension = report.dimensionName || report.dimension || report.dimensionKey;
85
- if (dimension) lines.push(cd.verdictDimension(dimension));
86
- if (band) lines.push(cd.verdictBand(band));
87
- if (typeof report.certified === 'boolean') lines.push(report.certified ? cd.verdictCertified : cd.verdictNotCertified);
88
- const summary = report.summary || report.rationale || report.feedback;
89
- if (typeof summary === 'string' && summary.trim()) lines.push(summary.trim());
90
- const areas = Array.isArray(report.areas) ? report.areas : null;
91
- if (areas && areas.length) {
92
- for (const a of areas) {
93
- const name = a && (a.name || a.area || a.tag);
94
- const note = a && (a.note || a.comment);
95
- if (name) lines.push(`- ${name}${note ? `: ${note}` : ''}`);
96
- }
97
- }
98
- return lines.join('\n ');
67
+ const key = `${encodeURIComponent(dimension.clusterId)}/${encodeURIComponent(dimension.slug)}`;
68
+ return `${origin}/certifications?dimension=${key}`;
99
69
  }
100
70
 
101
71
  async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
@@ -146,41 +116,11 @@ async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog
146
116
  }
147
117
  }
148
118
 
149
- const createEndpoint = deps.getCertificationInterviewsEndpoint();
150
- const created = await io.withProgress(cd.creating, () =>
151
- deps.requestCreateCertificationInterview(
152
- { dimensionKey: chosen.dimensionKey, languageCode: lang, accessToken },
153
- { endpoint: createEndpoint },
154
- ));
155
- if (!created.ok) {
156
- io.error(created.reason === 'dimension-on-cooldown'
157
- ? cd.onCooldown(formatDay(created.availableOn, lang))
158
- : cd.createFailed(created.reason));
159
- return { ok: false, reason: created.reason, step: 'create' };
160
- }
161
-
162
- const interview = await deps.conductLivekitInterview(io, deps.onboardingDeps, {
163
- interviewId: created.interviewId,
164
- language: lang,
165
- plain: true,
166
- });
167
- if (!interview.ok) {
168
- io.warn(interview.reason === 'needs-web' ? cd.needsWeb : cd.interviewUnavailable(interview.reason));
169
- return { ok: false, reason: interview.reason, step: 'interview' };
170
- }
171
-
172
- const reportBase = deps.getInterviewsBase();
173
- const polled = await io.withProgress(cd.reportPolling, () =>
174
- pollReport(deps, { interviewId: created.interviewId, accessToken }, { base: reportBase }));
175
- if (!polled.ok) {
176
- io.warn(polled.reason === 'timeout' ? cd.reportTimeout : cd.reportFailed(polled.reason));
177
- return { ok: true, completed: 'interview', verdict: null };
178
- }
179
-
180
- io.section(cd.verdictTitle);
181
- io.notify(renderVerdict(polled.report, cd));
182
- io.success(cd.done);
183
- return { ok: true, completed: 'interview', verdict: polled.report };
119
+ const webLink = buildWebLink(deps.getTalentProfileUrl(), chosen);
120
+ io.notify(cd.webHandoff);
121
+ if (webLink) io.success(cd.webHandoffLink(webLink));
122
+ else io.notify(cd.webHandoffNoLink);
123
+ return { ok: true, handoff: true, dimensionKey: chosen.dimensionKey, webLink };
184
124
  }
185
125
 
186
126
  module.exports = {
@@ -188,6 +128,5 @@ module.exports = {
188
128
  runCertifyDimension,
189
129
  chooseDimension,
190
130
  resolveDimensionArg,
191
- pollReport,
192
- renderVerdict,
131
+ buildWebLink,
193
132
  };
package/src/config.js CHANGED
@@ -318,13 +318,6 @@ function getTemplatesByDimensionEndpoint(env = process.env) {
318
318
  return base ? `${base}/templates/by-dimension` : null;
319
319
  }
320
320
 
321
- function getCertificationInterviewsEndpoint(env = process.env) {
322
- const explicit = readEnv(env, 'CERTIFICATION_INTERVIEWS_ENDPOINT');
323
- if (explicit && explicit.trim()) return explicit.trim();
324
- const base = getCertsBase(env);
325
- return base ? `${base}/users/me/certification-interviews` : null;
326
- }
327
-
328
321
  // Find Positions list (hub `works/positions/find`, talent Bearer). Serves the
329
322
  // All/Recommended/Saved tabs via `criteria.savedByMe`; ordered by match desc.
330
323
  function getFindPositionsEndpoint(env = process.env) {
@@ -731,7 +724,6 @@ module.exports = {
731
724
  getSetMainRoleEndpoint,
732
725
  getAssignedClustersEndpoint,
733
726
  getTemplatesByDimensionEndpoint,
734
- getCertificationInterviewsEndpoint,
735
727
  // Find Positions list (`find-projects` command / `find_projects` MCP tool).
736
728
  getFindPositionsEndpoint,
737
729
  // Marketplace (show-project / save-project / unsave-project / invitations).
package/src/cv-text.js CHANGED
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
- // Plain text out of the talent's CV PDF, so the AI can pre-fill from what the CV
4
- // says. Zero-dependency on purpose (install.sh copies the CLI without npm
5
- // install): macOS reads it with the system PDFKit through JXA, elsewhere with
6
- // poppler's pdftotext when it is installed. With neither, the caller asks the
7
- // talent to attach the CV to the chat instead.
3
+ // Plain text out of the talent's CV (PDF, DOC, DOCX), so the AI can pre-fill from
4
+ // what the CV says. Zero-dependency on purpose (install.sh copies the CLI without
5
+ // npm install): on macOS PDFs go through the system PDFKit (JXA) and Word files
6
+ // through textutil; elsewhere PDFs through poppler's pdftotext when installed.
7
+ // With none, the caller asks the talent to attach the CV to the chat instead.
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
@@ -22,16 +22,44 @@ function run(cmd, args, exec) {
22
22
  return String(exec(cmd, args, { encoding: 'utf8', timeout: 20000, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }) || '');
23
23
  }
24
24
 
25
- function extractors(platform) {
25
+ function extractors(platform, file) {
26
26
  const list = [];
27
- if (platform === 'darwin') list.push({ name: 'pdfkit', cmd: 'osascript', args: (file) => ['-l', 'JavaScript', '-e', JXA_PDF_TEXT, file] });
28
- list.push({ name: 'pdftotext', cmd: 'pdftotext', args: (file) => ['-layout', '-q', file, '-'] });
27
+ if (/\.docx?$/i.test(file)) {
28
+ if (platform === 'darwin') list.push({ name: 'textutil', cmd: 'textutil', args: (f) => ['-convert', 'txt', '-stdout', f] });
29
+ return list;
30
+ }
31
+ if (platform === 'darwin') list.push({ name: 'pdfkit', cmd: 'osascript', args: (f) => ['-l', 'JavaScript', '-e', JXA_PDF_TEXT, f] });
32
+ list.push({ name: 'pdftotext', cmd: 'pdftotext', args: (f) => ['-layout', '-q', f, '-'] });
29
33
  return list;
30
34
  }
31
35
 
32
- function readCvText(input, { platform = process.platform, exec = execFileSync, env = process.env } = {}) {
36
+ const fold = (text) => String(text || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
37
+
38
+ // Whether the CV text names the talent: every name token of 3+ letters present.
39
+ // Lets the AI drop a stranger's CV without showing its contents.
40
+ function mentionsName(text, talentName) {
41
+ const tokens = fold(talentName).split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
42
+ if (tokens.length === 0) return null;
43
+ const body = fold(text);
44
+ return tokens.every((t) => body.includes(t));
45
+ }
46
+
47
+ const uniq = (list) => [...new Set(list)];
48
+
49
+ // Contact links a CV header usually carries, so the AI can fill them directly.
50
+ function extractLinks(text) {
51
+ const t = String(text || '');
52
+ return {
53
+ linkedin: uniq((t.match(/(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/in\/[A-Za-z0-9_%-]+/gi) || []).map((u) => `https://www.${u.replace(/^https?:\/\//i, '').replace(/^(?:[a-z]{2,3}\.)?linkedin/i, 'linkedin')}`)),
54
+ github: uniq((t.match(/(?:https?:\/\/)?github\.com\/[A-Za-z0-9-]+/gi) || []).map((u) => `https://${u.replace(/^https?:\/\//i, '')}`)),
55
+ emails: uniq(t.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) || []),
56
+ phones: uniq((t.match(/\+\d{1,3}[\s.-]?\d[\d\s.-]{6,14}\d/g) || []).map((p) => p.replace(/[\s.-]+/g, ' ').trim())),
57
+ };
58
+ }
59
+
60
+ function readCvText(input, { platform = process.platform, exec = execFileSync, env = process.env, talentName = null } = {}) {
33
61
  const file = expandHome(input, env);
34
- if (typeof file !== 'string' || !path.isAbsolute(file) || !/\.pdf$/i.test(file)) return { ok: false, reason: 'not-a-pdf-path' };
62
+ if (typeof file !== 'string' || !path.isAbsolute(file) || !/\.(pdf|docx?)$/i.test(file)) return { ok: false, reason: 'not-a-cv-path' };
35
63
  let stat;
36
64
  try {
37
65
  stat = fs.statSync(file);
@@ -41,7 +69,7 @@ function readCvText(input, { platform = process.platform, exec = execFileSync, e
41
69
  if (!stat.isFile()) return { ok: false, reason: 'not-found' };
42
70
  if (stat.size > MAX_BYTES) return { ok: false, reason: 'too-large' };
43
71
 
44
- for (const extractor of extractors(platform)) {
72
+ for (const extractor of extractors(platform, file)) {
45
73
  let text;
46
74
  try {
47
75
  text = run(extractor.cmd, extractor.args(file), exec).trim();
@@ -49,9 +77,16 @@ function readCvText(input, { platform = process.platform, exec = execFileSync, e
49
77
  continue;
50
78
  }
51
79
  if (!text) continue;
52
- return { ok: true, extractor: extractor.name, truncated: text.length > MAX_CHARS, text: text.slice(0, MAX_CHARS) };
80
+ return {
81
+ ok: true,
82
+ extractor: extractor.name,
83
+ mentionsTalent: mentionsName(text, talentName),
84
+ links: extractLinks(text),
85
+ truncated: text.length > MAX_CHARS,
86
+ text: text.slice(0, MAX_CHARS),
87
+ };
53
88
  }
54
89
  return { ok: false, reason: 'no-text-extracted' };
55
90
  }
56
91
 
57
- module.exports = { readCvText, MAX_CHARS };
92
+ module.exports = { readCvText, extractLinks, mentionsName, MAX_CHARS };
@@ -3,9 +3,14 @@
3
3
  const { execFileSync } = require('child_process');
4
4
  const path = require('path');
5
5
 
6
+ const { takePrefetchedGitLog } = require('./git-log-prefetch');
7
+
6
8
  const GIT_TIMEOUT_MS = 15000;
7
9
  const MAX_FILE_TYPES = 50;
8
10
 
11
+ // The one history read behind the activity numbers (\x01 = commit marker, \x1f = field sep).
12
+ const GIT_ACTIVITY_LOG_ARGS = ['log', '--no-merges', '--numstat', '--pretty=format:\x01%ae\x1f%at'];
13
+
9
14
  function runGit(root, args) {
10
15
  try {
11
16
  return execFileSync('git', ['-C', root, ...args], {
@@ -56,12 +61,8 @@ function collectGitActivity(root, verifiedEmail) {
56
61
 
57
62
  const sep = String.fromCharCode(1);
58
63
  const unit = String.fromCharCode(31);
59
- const log = runGit(root, [
60
- 'log',
61
- '--no-merges',
62
- '--numstat',
63
- `--pretty=format:${sep}%ae${unit}%at`,
64
- ]);
64
+ const prefetchedLog = takePrefetchedGitLog(root, GIT_ACTIVITY_LOG_ARGS);
65
+ const log = prefetchedLog !== undefined ? prefetchedLog : runGit(root, GIT_ACTIVITY_LOG_ARGS);
65
66
  if (log === null) return null;
66
67
 
67
68
  const target = String(verifiedEmail || '')
@@ -120,4 +121,4 @@ function collectGitActivity(root, verifiedEmail) {
120
121
  };
121
122
  }
122
123
 
123
- module.exports = { collectGitActivity, extensionOf, isGitRepo };
124
+ module.exports = { collectGitActivity, extensionOf, isGitRepo, GIT_ACTIVITY_LOG_ARGS };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const { execFile } = require('child_process');
4
+
5
+ // Runs the aggregation's per-repo git logs concurrently with the collectors' exact args; each result is taken once and expires.
6
+
7
+ const GIT_TIMEOUT_MS = 15000;
8
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
9
+ const MAX_CONCURRENT = 8;
10
+ const TTL_MS = 60 * 1000;
11
+
12
+ const prefetched = new Map();
13
+
14
+ const keyOf = (root, args) => JSON.stringify([root, args]);
15
+
16
+ function runAsync(root, args) {
17
+ return new Promise((resolve) => {
18
+ execFile(
19
+ 'git',
20
+ ['-C', root, ...args],
21
+ { encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER },
22
+ (err, stdout) => resolve(err ? null : stdout),
23
+ );
24
+ });
25
+ }
26
+
27
+ // Runs every (root, args) pair, at most MAX_CONCURRENT at a time. Never throws.
28
+ async function prefetchGitLogs(roots, argSets) {
29
+ const jobs = [];
30
+ for (const root of roots || []) {
31
+ for (const args of argSets) jobs.push({ root, args });
32
+ }
33
+ let next = 0;
34
+ const worker = async () => {
35
+ while (next < jobs.length) {
36
+ const { root, args } = jobs[next++];
37
+ const out = await runAsync(root, args);
38
+ if (out !== null) prefetched.set(keyOf(root, args), { out, at: Date.now() });
39
+ }
40
+ };
41
+ await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT, jobs.length) }, worker));
42
+ }
43
+
44
+ // The prefetched stdout for exactly this command, once; `undefined` on a miss.
45
+ function takePrefetchedGitLog(root, args) {
46
+ const key = keyOf(root, args);
47
+ const hit = prefetched.get(key);
48
+ if (!hit) return undefined;
49
+ prefetched.delete(key);
50
+ return Date.now() - hit.at <= TTL_MS ? hit.out : undefined;
51
+ }
52
+
53
+ module.exports = { prefetchGitLogs, takePrefetchedGitLog };