shakers-cli-beta 0.8.0-beta.5 → 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 +9 -4
- package/bin/ai-usage.js +5 -0
- package/bin/login.js +1 -1
- package/bin/mcp.js +18 -5
- package/package.json +2 -1
- package/src/authorship.js +7 -6
- package/src/certify-dimension-client.js +0 -54
- package/src/certify-dimension-flow.js +20 -81
- package/src/config.js +0 -8
- package/src/git-activity.js +8 -7
- package/src/git-log-prefetch.js +53 -0
- package/src/i18n.js +10 -36
- package/src/mcp-auth-form.js +35 -21
- package/src/mcp-certify-dimension-tools.js +2 -2
- package/src/mcp-onboarding-tools.js +1 -1
- package/src/mcp-prompts.js +71 -0
- package/src/mcp-register-tools.js +4 -2
- package/src/mcp-server.js +38 -2
- package/src/scanner.js +13 -1
- package/src/session-normalizers.js +174 -0
- package/src/session-scan.js +149 -10
- package/src/work-streams.js +7 -2
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/
|
|
100
|
+
curl -fsSL https://raw.githubusercontent.com/devShakers/ai-usage-evaluator/release/install.sh | bash
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
The installer:
|
|
@@ -139,7 +139,7 @@ env vars and `config.json` always win over the baked default.
|
|
|
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 `
|
|
142
|
+
existing version). The private one publishes from a checked-out `release` with the
|
|
143
143
|
committed `@shakers/shakers-cli` manifest, unchanged. For the beta, bump
|
|
144
144
|
`BETA.version` in `scripts/prepare-beta.js` (manual, one line), then run the
|
|
145
145
|
single command `npm run publish:beta`: it stamps `name`/`version`/`publishConfig`,
|
|
@@ -587,9 +587,10 @@ run without a session (that is how you obtain/drop one); `logout` with no
|
|
|
587
587
|
session is a silent no-op.
|
|
588
588
|
|
|
589
589
|
```bash
|
|
590
|
-
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
|
|
591
591
|
shakers login --email you@shakers.com # skip the picker, prompt only for the password
|
|
592
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
|
|
593
594
|
shakers logout # forget the stored session (no-op if none)
|
|
594
595
|
```
|
|
595
596
|
|
|
@@ -608,7 +609,11 @@ shakers logout # forget the stored session (no-op if none)
|
|
|
608
609
|
your machine — no client id, no loopback server, no secret; the Hub owns all
|
|
609
610
|
Google config and does the token exchange server-side. After you authorize, the
|
|
610
611
|
Hub session is exchanged for the same API JWT the email flow stores.
|
|
611
|
-
- **LinkedIn
|
|
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.
|
|
612
617
|
- The session (`{ accessToken, expiresAt, email }`) is stored **atomically**,
|
|
613
618
|
permissions `600`, in `~/.config/shakers/auth-session.json`. **Logged in**
|
|
614
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
|
|
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 = '';
|
|
@@ -68,11 +69,22 @@ const SERVER_INSTRUCTIONS = [
|
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"start": "node bin/shakers.js",
|
|
17
17
|
"shakers": "node bin/shakers.js",
|
|
18
18
|
"test": "node --test test/*.test.js",
|
|
19
|
+
"build:mcpb": "sh scripts/build-mcpb.sh",
|
|
19
20
|
"publish:beta": "node scripts/prepare-beta.js && npm publish --tag latest && git checkout -- package.json"
|
|
20
21
|
},
|
|
21
22
|
"engines": {
|
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
|
|
76
|
-
|
|
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 ->
|
|
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
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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/git-activity.js
CHANGED
|
@@ -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
|
|
60
|
-
|
|
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 };
|
package/src/i18n.js
CHANGED
|
@@ -702,11 +702,11 @@ const catalogs = {
|
|
|
702
702
|
// language the Talent may not read. Vocabulario CONTEXT: Talent, Skill.
|
|
703
703
|
certifyDimension: {
|
|
704
704
|
help:
|
|
705
|
-
'certify
|
|
705
|
+
'certify - certifica una DIMENSIÓN de tu rol. La entrevista se hace en la web.\n\n'
|
|
706
706
|
+ 'Uso:\n'
|
|
707
707
|
+ ' certify [--lang es|en] [--dimension <clave|slug|n>]\n\n'
|
|
708
708
|
+ 'Requiere una sesión activa (ejecuta antes: shakers login).\n'
|
|
709
|
-
+ '
|
|
709
|
+
+ 'Eliges la dimensión aquí y te damos el enlace para certificarla en la web.\n',
|
|
710
710
|
title: 'Certificar una dimensión',
|
|
711
711
|
loginRequired: '"certify" requiere una sesión activa.\n Falta: iniciar sesión. Ejecuta primero: shakers login',
|
|
712
712
|
discovering: 'Buscando las dimensiones que puedes certificar…',
|
|
@@ -722,22 +722,9 @@ const catalogs = {
|
|
|
722
722
|
stateExpired: 'caducada',
|
|
723
723
|
dimensionInvalid: 'Esa dimensión no está entre las certificables ahora mismo.',
|
|
724
724
|
dimensionUsing: (slug) => `Dimensión elegida: ${slug}`,
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
? `Ya hiciste esta dimensión hace poco. Podrás repetirla a partir del ${day}.`
|
|
729
|
-
: 'Ya hiciste esta dimensión hace poco y todavía no se puede repetir.'),
|
|
730
|
-
needsWeb: 'Esta dimensión lleva un ejercicio que por ahora solo se hace en la web, no por terminal.',
|
|
731
|
-
interviewUnavailable: (reason) => `La entrevista no pudo completarse (${reason}).`,
|
|
732
|
-
reportPolling: 'Calculando tu veredicto…',
|
|
733
|
-
reportTimeout: 'El veredicto todavía se está calculando. Consúltalo en tu perfil en unos minutos.',
|
|
734
|
-
reportFailed: (reason) => `No se pudo obtener el veredicto (${reason}). Consúltalo en tu perfil.`,
|
|
735
|
-
verdictTitle: 'Veredicto',
|
|
736
|
-
verdictDimension: (dim) => `Dimensión: ${dim}`,
|
|
737
|
-
verdictBand: (band) => `Nivel: ${band}`,
|
|
738
|
-
verdictCertified: 'Resultado: certificada',
|
|
739
|
-
verdictNotCertified: 'Resultado: no certificada esta vez',
|
|
740
|
-
done: 'Certificación completada.',
|
|
725
|
+
webHandoff: 'La entrevista de certificación se hace en la web.',
|
|
726
|
+
webHandoffLink: (url) => `Ábrela aquí para certificar esta dimensión:\n ${url}`,
|
|
727
|
+
webHandoffNoLink: 'Entra en tu perfil de Shakers en la web y ve a Certificaciones para hacerla.',
|
|
741
728
|
},
|
|
742
729
|
findProjects: {
|
|
743
730
|
help:
|
|
@@ -2310,11 +2297,11 @@ const catalogs = {
|
|
|
2310
2297
|
// English mirror of the `certify` catalog. Same content/invariants.
|
|
2311
2298
|
certifyDimension: {
|
|
2312
2299
|
help:
|
|
2313
|
-
'certify
|
|
2300
|
+
'certify - certify a DIMENSION of your role. The interview is done on the web.\n\n'
|
|
2314
2301
|
+ 'Usage:\n'
|
|
2315
2302
|
+ ' certify [--lang es|en] [--dimension <key|slug|n>]\n\n'
|
|
2316
2303
|
+ 'Requires an active session (run `shakers login` first).\n'
|
|
2317
|
-
+ '
|
|
2304
|
+
+ 'You pick the dimension here and we hand you the link to certify it on the web.\n',
|
|
2318
2305
|
title: 'Certify a dimension',
|
|
2319
2306
|
loginRequired: '"certify" requires an active session.\n Missing: sign-in. Run `shakers login` first.',
|
|
2320
2307
|
discovering: 'Looking up the dimensions you can certify…',
|
|
@@ -2330,22 +2317,9 @@ const catalogs = {
|
|
|
2330
2317
|
stateExpired: 'expired',
|
|
2331
2318
|
dimensionInvalid: 'That dimension is not among the certifiable ones right now.',
|
|
2332
2319
|
dimensionUsing: (slug) => `Chosen dimension: ${slug}`,
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
? `You took this dimension recently. You can retake it from ${day}.`
|
|
2337
|
-
: 'You took this dimension recently and cannot retake it yet.'),
|
|
2338
|
-
needsWeb: 'This dimension has an exercise that for now is only taken on the web, not in the terminal.',
|
|
2339
|
-
interviewUnavailable: (reason) => `The interview could not be completed (${reason}).`,
|
|
2340
|
-
reportPolling: 'Computing your verdict…',
|
|
2341
|
-
reportTimeout: 'The verdict is still being computed. Check your profile in a few minutes.',
|
|
2342
|
-
reportFailed: (reason) => `Could not fetch the verdict (${reason}). Check your profile.`,
|
|
2343
|
-
verdictTitle: 'Verdict',
|
|
2344
|
-
verdictDimension: (dim) => `Dimension: ${dim}`,
|
|
2345
|
-
verdictBand: (band) => `Level: ${band}`,
|
|
2346
|
-
verdictCertified: 'Result: certified',
|
|
2347
|
-
verdictNotCertified: 'Result: not certified this time',
|
|
2348
|
-
done: 'Certification complete.',
|
|
2320
|
+
webHandoff: 'The certification interview is done on the web.',
|
|
2321
|
+
webHandoffLink: (url) => `Open it here to certify this dimension:\n ${url}`,
|
|
2322
|
+
webHandoffNoLink: 'Open your Shakers profile on the web and go to Certifications to take it.',
|
|
2349
2323
|
},
|
|
2350
2324
|
findProjects: {
|
|
2351
2325
|
help:
|