shakers-cli-beta 0.10.3 → 0.10.4
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/package.json +1 -1
- package/src/certify-dimension-client.js +79 -0
- package/src/certify-dimension-flow.js +104 -20
- package/src/config.js +9 -0
- package/src/i18n.js +98 -32
- package/src/mcp-ai-usage-tool.js +13 -32
- package/src/mcp-certify-dimension-tools.js +7 -1
- package/src/mcp-choice.js +41 -19
- package/src/mcp-instructions.js +9 -9
- package/src/mcp-register-tools.js +87 -29
- package/src/mcp-role-tools.js +79 -11
- package/src/mcp-signup-tools.js +187 -59
- package/src/register-context.js +3 -1
- package/src/signup-copy.js +49 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
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": {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
6
6
|
const { reasonForError } = require('./onboarding-client');
|
|
7
|
+
const { toUpperLang } = require('./lang-codes');
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 20000;
|
|
9
10
|
|
|
@@ -153,9 +154,87 @@ async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessTo
|
|
|
153
154
|
return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
|
|
154
155
|
}
|
|
155
156
|
|
|
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
|
+
// The case the web preview shows before starting (works `get-dimension-case`): the exercise statement, else the
|
|
209
|
+
// version's brief. `case: null` for a template with neither.
|
|
210
|
+
async function requestDimensionCase(
|
|
211
|
+
{ dimensionKey, accessToken } = {},
|
|
212
|
+
{ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
213
|
+
) {
|
|
214
|
+
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
215
|
+
if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
|
|
216
|
+
const path = String(dimensionKey).split('/').map(encodeURIComponent).join('/');
|
|
217
|
+
const url = `${base.replace(/\/+$/, '')}/dimension/${path}/case`;
|
|
218
|
+
let res;
|
|
219
|
+
try {
|
|
220
|
+
res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
|
|
221
|
+
} catch (e) {
|
|
222
|
+
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
223
|
+
}
|
|
224
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
|
|
225
|
+
const data = payloadOf(res.raw);
|
|
226
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: 'bad-response' };
|
|
227
|
+
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
228
|
+
const brief = text(data.statement) || text(data.candidateBrief);
|
|
229
|
+
return { ok: true, case: brief ? { brief, testType: text(data.testType) } : null };
|
|
230
|
+
}
|
|
231
|
+
|
|
156
232
|
module.exports = {
|
|
157
233
|
requestMyCertifications,
|
|
158
234
|
requestTemplatesByDimension,
|
|
235
|
+
requestCreateCertificationInterview,
|
|
236
|
+
requestCertificationReport,
|
|
237
|
+
requestDimensionCase,
|
|
159
238
|
composeDimensionKey,
|
|
160
239
|
composeOfferableDimensions,
|
|
161
240
|
discoverOfferableDimensions,
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// `certify` dimension flow: discover -> pick ->
|
|
3
|
+
// `certify` dimension flow: discover -> pick -> create -> LiveKit interview -> poll verdict.
|
|
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');
|
|
10
11
|
return {
|
|
11
|
-
|
|
12
|
+
getCertificationInterviewsEndpoint: config.getCertificationInterviewsEndpoint,
|
|
13
|
+
// `{certsBase}/interviews` — shared base for LiveKit start/complete + the report poll.
|
|
14
|
+
getInterviewsBase: config.getOnboardingInterviewsEndpoint,
|
|
12
15
|
discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
|
|
16
|
+
requestCreateCertificationInterview: client.requestCreateCertificationInterview,
|
|
17
|
+
requestCertificationReport: client.requestCertificationReport,
|
|
18
|
+
requestDimensionCase: client.requestDimensionCase,
|
|
19
|
+
conductLivekitInterview: onboardingFlow.conductLivekitInterview,
|
|
20
|
+
onboardingDeps: onboardingFlow.makeDeps(),
|
|
21
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
13
22
|
...overrides,
|
|
14
23
|
};
|
|
15
24
|
}
|
|
@@ -53,19 +62,51 @@ function resolveDimensionArg(arg, items) {
|
|
|
53
62
|
return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
origin = new URL(profileUrl).origin;
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
65
|
+
// The evaluation lands one to two minutes after the interview ends, four when the queue is busy.
|
|
66
|
+
async function pollReport(deps, { interviewId, accessToken }, { base, attempts = 60, intervalMs = 5000 } = {}) {
|
|
67
|
+
for (let i = 0; i < attempts; i++) {
|
|
68
|
+
const res = await deps.requestCertificationReport({ interviewId, accessToken }, { base });
|
|
69
|
+
if (!res.ok) return { ok: false, reason: res.reason };
|
|
70
|
+
if (res.ready) return { ok: true, report: res.report };
|
|
71
|
+
if (i < attempts - 1) await deps.sleep(intervalMs);
|
|
66
72
|
}
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
return { ok: false, reason: 'timeout' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatDay(iso, lang) {
|
|
77
|
+
const d = iso ? new Date(iso) : null;
|
|
78
|
+
if (!d || Number.isNaN(d.getTime())) return null;
|
|
79
|
+
return d.toLocaleDateString(lang === 'en' ? 'en-GB' : 'es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderVerdict(report, cd) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
const band = report.band || report.level || report.combinedLevel;
|
|
85
|
+
const dimension = report.dimensionName || report.dimension || report.dimensionKey;
|
|
86
|
+
if (dimension) lines.push(cd.verdictDimension(dimension));
|
|
87
|
+
if (band) lines.push(cd.verdictBand(band));
|
|
88
|
+
if (typeof report.certified === 'boolean') lines.push(report.certified ? cd.verdictCertified : cd.verdictNotCertified);
|
|
89
|
+
const summary = report.summary || report.rationale || report.feedback;
|
|
90
|
+
if (typeof summary === 'string' && summary.trim()) lines.push(summary.trim());
|
|
91
|
+
const areas = Array.isArray(report.areas) ? report.areas : null;
|
|
92
|
+
if (areas && areas.length) {
|
|
93
|
+
for (const a of areas) {
|
|
94
|
+
const name = a && (a.name || a.area || a.tag);
|
|
95
|
+
const note = a && (a.note || a.comment);
|
|
96
|
+
if (name) lines.push(`- ${name}${note ? `: ${note}` : ''}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines.join('\n ');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The statement is Markdown; the terminal shows it without the emphasis markers.
|
|
103
|
+
function renderCase(dimensionCase, cd) {
|
|
104
|
+
const brief = dimensionCase.brief.replace(/(\*\*|__)(.+?)\1/g, '$2');
|
|
105
|
+
const expectations = cd.caseExpectations[dimensionCase.testType];
|
|
106
|
+
const lines = brief.split('\n');
|
|
107
|
+
if (expectations) lines.push('', cd.caseExpectationsTitle, ...Object.values(expectations).map((e) => `- ${e}`));
|
|
108
|
+
lines.push('', cd.caseBeforeStart);
|
|
109
|
+
return lines.join('\n ');
|
|
69
110
|
}
|
|
70
111
|
|
|
71
112
|
async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
|
|
@@ -116,11 +157,52 @@ async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog
|
|
|
116
157
|
}
|
|
117
158
|
}
|
|
118
159
|
|
|
119
|
-
const
|
|
120
|
-
io.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
160
|
+
const createEndpoint = deps.getCertificationInterviewsEndpoint();
|
|
161
|
+
const created = await io.withProgress(cd.creating, () =>
|
|
162
|
+
deps.requestCreateCertificationInterview(
|
|
163
|
+
{ dimensionKey: chosen.dimensionKey, languageCode: lang, accessToken },
|
|
164
|
+
{ endpoint: createEndpoint },
|
|
165
|
+
));
|
|
166
|
+
if (!created.ok) {
|
|
167
|
+
io.error(created.reason === 'dimension-on-cooldown'
|
|
168
|
+
? cd.onCooldown(formatDay(created.availableOn, lang))
|
|
169
|
+
: cd.createFailed(created.reason));
|
|
170
|
+
return { ok: false, reason: created.reason, step: 'create' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A failed read leaves the run as it was before the case existed, like the web preview does.
|
|
174
|
+
const caseRead = await deps.requestDimensionCase(
|
|
175
|
+
{ dimensionKey: chosen.dimensionKey, accessToken },
|
|
176
|
+
{ base: deps.getInterviewsBase() },
|
|
177
|
+
);
|
|
178
|
+
if (caseRead.ok && caseRead.case) {
|
|
179
|
+
io.section(cd.caseTitle);
|
|
180
|
+
io.notify(renderCase(caseRead.case, cd));
|
|
181
|
+
if (stdinIsTTY) await ask(` ${cd.caseReadyPrompt}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const interview = await deps.conductLivekitInterview(io, deps.onboardingDeps, {
|
|
185
|
+
interviewId: created.interviewId,
|
|
186
|
+
language: lang,
|
|
187
|
+
plain: true,
|
|
188
|
+
});
|
|
189
|
+
if (!interview.ok) {
|
|
190
|
+
io.warn(interview.reason === 'needs-web' ? cd.needsWeb : cd.interviewUnavailable(interview.reason));
|
|
191
|
+
return { ok: false, reason: interview.reason, step: 'interview' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const reportBase = deps.getInterviewsBase();
|
|
195
|
+
const polled = await io.withProgress(cd.reportPolling, () =>
|
|
196
|
+
pollReport(deps, { interviewId: created.interviewId, accessToken }, { base: reportBase }));
|
|
197
|
+
if (!polled.ok) {
|
|
198
|
+
io.warn(polled.reason === 'timeout' ? cd.reportTimeout : cd.reportFailed(polled.reason));
|
|
199
|
+
return { ok: true, completed: 'interview', verdict: null };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
io.section(cd.verdictTitle);
|
|
203
|
+
io.notify(renderVerdict(polled.report, cd));
|
|
204
|
+
io.success(cd.done);
|
|
205
|
+
return { ok: true, completed: 'interview', verdict: polled.report };
|
|
124
206
|
}
|
|
125
207
|
|
|
126
208
|
module.exports = {
|
|
@@ -128,5 +210,7 @@ module.exports = {
|
|
|
128
210
|
runCertifyDimension,
|
|
129
211
|
chooseDimension,
|
|
130
212
|
resolveDimensionArg,
|
|
131
|
-
|
|
213
|
+
pollReport,
|
|
214
|
+
renderVerdict,
|
|
215
|
+
renderCase,
|
|
132
216
|
};
|
package/src/config.js
CHANGED
|
@@ -318,6 +318,14 @@ function getTemplatesByDimensionEndpoint(env = process.env) {
|
|
|
318
318
|
return base ? `${base}/templates/by-dimension` : null;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
// The Talent's assessments with their retry availability (certs, talent Bearer).
|
|
322
|
+
function getMyAssessmentsEndpoint(env = process.env) {
|
|
323
|
+
const explicit = readEnv(env, 'MY_ASSESSMENTS_ENDPOINT');
|
|
324
|
+
if (explicit && explicit.trim()) return explicit.trim();
|
|
325
|
+
const base = getCertsBase(env);
|
|
326
|
+
return base ? `${base}/users/me/assessments` : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
321
329
|
// Find Positions list (hub `works/positions/find`, talent Bearer). Serves the
|
|
322
330
|
// All/Recommended/Saved tabs via `criteria.savedByMe`; ordered by match desc.
|
|
323
331
|
function getFindPositionsEndpoint(env = process.env) {
|
|
@@ -749,6 +757,7 @@ module.exports = {
|
|
|
749
757
|
getSetMainRoleEndpoint,
|
|
750
758
|
getAssignedClustersEndpoint,
|
|
751
759
|
getTemplatesByDimensionEndpoint,
|
|
760
|
+
getMyAssessmentsEndpoint,
|
|
752
761
|
// Find Positions list (`find-projects` command / `find_projects` MCP tool).
|
|
753
762
|
getFindPositionsEndpoint,
|
|
754
763
|
// Marketplace (show-project / save-project / unsave-project / invitations).
|
package/src/i18n.js
CHANGED
|
@@ -203,26 +203,26 @@ const catalogs = {
|
|
|
203
203
|
blockingLabel: 'Criterio exacto que te impide subir de tier:',
|
|
204
204
|
maxTierNote: 'Cumples todos los criterios de la escalera T0-T7: no hay un criterio adicional bloqueando tu progreso.',
|
|
205
205
|
criterion: {
|
|
206
|
-
t1Met: (n) => `Tienes al menos una herramienta de IA detectada y configurada en tu entorno (
|
|
207
|
-
t2Met: (n) => `Dispones de al menos un fichero de contexto persistente — instrucciones, configuración o reglas — para alguna herramienta (
|
|
208
|
-
t3Met: (n) => `Tienes al menos un servidor MCP conectado, dando a la IA acceso a datos o herramientas externas (
|
|
209
|
-
t4Met: (n) => `Has creado activos propios — skills, comandos o reglas personalizadas — más allá de la configuración por defecto (
|
|
210
|
-
t5Met: (hasAgentic, mcp, custom) => `Operas con una CLI agéntica (Claude Code, Aider, Gemini CLI, Codex CLI o Amazon Q Developer) combinada con MCP y activos propios (
|
|
211
|
-
t6Met: (n) => `Tienes un equipo de al menos 2 agentes especializados definidos (
|
|
212
|
-
t7Met: (n) => `Tienes automatización basada en hooks configurada (
|
|
213
|
-
t1Blocking: (n) => `Para subir a T1 (Primera herramienta) necesitas al menos una herramienta de IA detectada —
|
|
214
|
-
t2Blocking: (n) => `Para subir a T2 (Banco con notas) necesitas al menos un fichero de contexto persistente (instrucciones, configuración o reglas) —
|
|
215
|
-
t3Blocking: (n) => `Para subir a T3 (Banco conectado) necesitas conectar al menos un servidor MCP —
|
|
216
|
-
t4Blocking: (n) => `Para subir a T4 (Herramienta propia) necesitas crear al menos un activo propio — skill, comando o regla — más allá de la configuración por defecto —
|
|
206
|
+
t1Met: (n) => `Tienes al menos una herramienta de IA detectada y configurada en tu entorno (detectadas: ${n}).`,
|
|
207
|
+
t2Met: (n) => `Dispones de al menos un fichero de contexto persistente — instrucciones, configuración o reglas — para alguna herramienta (tienes ${n}).`,
|
|
208
|
+
t3Met: (n) => `Tienes al menos un servidor MCP conectado, dando a la IA acceso a datos o herramientas externas (tienes ${n}).`,
|
|
209
|
+
t4Met: (n) => `Has creado activos propios — skills, comandos o reglas personalizadas — más allá de la configuración por defecto (tienes ${n}).`,
|
|
210
|
+
t5Met: (hasAgentic, mcp, custom) => `Operas con una CLI agéntica (Claude Code, Aider, Gemini CLI, Codex CLI o Amazon Q Developer) combinada con MCP y activos propios (${mcp} servidores MCP y ${custom} activos propios).`,
|
|
211
|
+
t6Met: (n) => `Tienes un equipo de al menos 2 agentes especializados definidos (tienes ${n}).`,
|
|
212
|
+
t7Met: (n) => `Tienes automatización basada en hooks configurada (tienes ${n}).`,
|
|
213
|
+
t1Blocking: (n) => `Para subir a T1 (Primera herramienta) necesitas al menos una herramienta de IA detectada — ahora tienes ${n}.`,
|
|
214
|
+
t2Blocking: (n) => `Para subir a T2 (Banco con notas) necesitas al menos un fichero de contexto persistente (instrucciones, configuración o reglas) — ahora tienes ${n}.`,
|
|
215
|
+
t3Blocking: (n) => `Para subir a T3 (Banco conectado) necesitas conectar al menos un servidor MCP — ahora tienes ${n}.`,
|
|
216
|
+
t4Blocking: (n) => `Para subir a T4 (Herramienta propia) necesitas crear al menos un activo propio — skill, comando o regla — más allá de la configuración por defecto — ahora tienes ${n}.`,
|
|
217
217
|
t5Blocking: (hasAgentic, mcp, custom) => {
|
|
218
218
|
const missing = [];
|
|
219
219
|
if (!hasAgentic) missing.push('una CLI agéntica (Claude Code, Aider, Gemini CLI, Codex CLI o Amazon Q Developer)');
|
|
220
220
|
if (mcp < 1) missing.push('al menos 1 servidor MCP');
|
|
221
221
|
if (custom < 1) missing.push('al menos 1 activo propio (skill, comando o regla)');
|
|
222
|
-
return `Para subir a T5 (Operador agéntico) te falta: ${missing.join('; ')}
|
|
222
|
+
return `Para subir a T5 (Operador agéntico) te falta: ${missing.join('; ')}.`;
|
|
223
223
|
},
|
|
224
224
|
t6Blocking: (n) => `Para subir a T6 (Multi-agente) necesitas al menos 2 agentes especializados definidos en \`.claude/agents/\` — actualmente tienes ${n}.`,
|
|
225
|
-
t7Blocking: (n) => `Para subir a T7 (Taller orquestado) necesitas al menos un hook de automatización configurado —
|
|
225
|
+
t7Blocking: (n) => `Para subir a T7 (Taller orquestado) necesitas al menos un hook de automatización configurado — ahora tienes ${n}.`,
|
|
226
226
|
},
|
|
227
227
|
},
|
|
228
228
|
ladder: {
|
|
@@ -714,6 +714,8 @@ const catalogs = {
|
|
|
714
714
|
discoverFailed: (reason) => `No se pudieron obtener tus dimensiones (${reason}). Inténtalo de nuevo en un momento.`,
|
|
715
715
|
unmatchedNote: (keys) => `Aviso: algunas dimensiones no tienen plantilla asociada y no se pueden ofrecer aquí (${keys}).`,
|
|
716
716
|
noneOfferable: 'Ahora mismo no hay dimensiones certificables por aquí. Vuelve a intentarlo cuando tengas alguna disponible.',
|
|
717
|
+
onCooldownNote: (items) => `Aún no puedes repetir: ${items}.`,
|
|
718
|
+
onCooldownItem: (slug, day) => `${slug} (disponible el ${day})`,
|
|
717
719
|
mainRole: (role) => `Tu rol principal: ${role}`,
|
|
718
720
|
selectHeading: 'Elige la dimensión que quieres certificar:',
|
|
719
721
|
selectHint: '↑/↓ para moverte, Enter para elegir, Esc para cancelar.',
|
|
@@ -722,10 +724,41 @@ const catalogs = {
|
|
|
722
724
|
selectNoneChosen: 'No se eligió ninguna dimensión.',
|
|
723
725
|
stateExpired: 'caducada',
|
|
724
726
|
dimensionInvalid: 'Esa dimensión no está entre las certificables ahora mismo.',
|
|
727
|
+
dimensionOnCooldown: (slug, day) => `Aún no puedes repetir ${slug}: estará disponible el ${day}.`,
|
|
725
728
|
dimensionUsing: (slug) => `Dimensión elegida: ${slug}`,
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
+
creating: 'Creando la entrevista…',
|
|
730
|
+
createFailed: (reason) => `No se pudo crear la entrevista (${reason}).`,
|
|
731
|
+
onCooldown: (day) => (day
|
|
732
|
+
? `Ya hiciste esta dimensión hace poco. Podrás repetirla a partir del ${day}.`
|
|
733
|
+
: 'Ya hiciste esta dimensión hace poco y todavía no se puede repetir.'),
|
|
734
|
+
needsWeb: 'Esta dimensión lleva un ejercicio que por ahora solo se hace en la web, no por terminal.',
|
|
735
|
+
interviewUnavailable: (reason) => `La entrevista no pudo completarse (${reason}).`,
|
|
736
|
+
reportPolling: 'Calculando tu veredicto…',
|
|
737
|
+
reportTimeout: 'El veredicto todavía se está calculando. Consúltalo en tu perfil en unos minutos.',
|
|
738
|
+
reportFailed: (reason) => `No se pudo obtener el veredicto (${reason}). Consúltalo en tu perfil.`,
|
|
739
|
+
verdictTitle: 'Veredicto',
|
|
740
|
+
verdictDimension: (dim) => `Dimensión: ${dim}`,
|
|
741
|
+
verdictBand: (band) => `Nivel: ${band}`,
|
|
742
|
+
verdictCertified: 'Resultado: certificada',
|
|
743
|
+
verdictNotCertified: 'Resultado: no certificada esta vez',
|
|
744
|
+
done: 'Certificación completada.',
|
|
745
|
+
// The same case copy the web preview shows (works `certifications.preview`).
|
|
746
|
+
caseTitle: 'El caso',
|
|
747
|
+
caseExpectationsTitle: 'Qué se espera',
|
|
748
|
+
caseExpectations: {
|
|
749
|
+
CASE_STUDY: {
|
|
750
|
+
reasoning: 'Se evalúa tu razonamiento y los supuestos que haces, no una respuesta perfecta.',
|
|
751
|
+
askForData: 'Puedes pedir datos que no están en el caso: di para qué los quieres.',
|
|
752
|
+
takeTime: 'Puedes pararte a pensar antes de responder.',
|
|
753
|
+
},
|
|
754
|
+
ROLE_PLAY: {
|
|
755
|
+
reasoning: 'Se evalúa cómo razonas en la conversación y los supuestos que haces, no una respuesta perfecta.',
|
|
756
|
+
askForData: 'Puedes pedir a la otra parte datos que no están en el caso.',
|
|
757
|
+
takeTime: 'Puedes pararte a pensar antes de responder.',
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
caseBeforeStart: 'Alma te va a hacer preguntas sobre este caso. Si te falta algún dato, pídeselo durante la entrevista.',
|
|
761
|
+
caseReadyPrompt: 'Pulsa Enter cuando lo hayas leído para empezar la entrevista.',
|
|
729
762
|
},
|
|
730
763
|
findProjects: {
|
|
731
764
|
help:
|
|
@@ -1805,26 +1838,26 @@ const catalogs = {
|
|
|
1805
1838
|
blockingLabel: 'Exact criterion blocking your next tier:',
|
|
1806
1839
|
maxTierNote: "You meet every criterion in the T0-T7 ladder: there's no additional criterion blocking your progress.",
|
|
1807
1840
|
criterion: {
|
|
1808
|
-
t1Met: (n) => `You have at least one AI tool detected and configured in your environment (
|
|
1809
|
-
t2Met: (n) => `You have at least one persistent context file — instructions, config or rules — for some tool (
|
|
1810
|
-
t3Met: (n) => `You have at least one connected MCP server, giving the AI access to external data or tools (
|
|
1811
|
-
t4Met: (n) => `You've created your own assets — skills, commands or custom rules — beyond the default configuration (
|
|
1812
|
-
t5Met: (hasAgentic, mcp, custom) => `You operate an agentic CLI (Claude Code, Aider, Gemini CLI, Codex CLI or Amazon Q Developer) combined with MCP and your own assets (
|
|
1813
|
-
t6Met: (n) => `You have a team of at least 2 specialized agents defined (
|
|
1814
|
-
t7Met: (n) => `You have hook-based automation configured (
|
|
1815
|
-
t1Blocking: (n) => `To reach T1 (First tool) you need at least one detected AI tool —
|
|
1816
|
-
t2Blocking: (n) => `To reach T2 (Bench with notes) you need at least one persistent context file (instructions, config or rules) —
|
|
1817
|
-
t3Blocking: (n) => `To reach T3 (Connected bench) you need to connect at least one MCP server —
|
|
1818
|
-
t4Blocking: (n) => `To reach T4 (Own tooling) you need to create at least one asset of your own — skill, command or rule —
|
|
1841
|
+
t1Met: (n) => `You have at least one AI tool detected and configured in your environment (detected: ${n}).`,
|
|
1842
|
+
t2Met: (n) => `You have at least one persistent context file — instructions, config or rules — for some tool (you have ${n}).`,
|
|
1843
|
+
t3Met: (n) => `You have at least one connected MCP server, giving the AI access to external data or tools (you have ${n}).`,
|
|
1844
|
+
t4Met: (n) => `You've created your own assets — skills, commands or custom rules — beyond the default configuration (you have ${n}).`,
|
|
1845
|
+
t5Met: (hasAgentic, mcp, custom) => `You operate an agentic CLI (Claude Code, Aider, Gemini CLI, Codex CLI or Amazon Q Developer) combined with MCP and your own assets (${mcp} MCP servers and ${custom} assets of your own).`,
|
|
1846
|
+
t6Met: (n) => `You have a team of at least 2 specialized agents defined (you have ${n}).`,
|
|
1847
|
+
t7Met: (n) => `You have hook-based automation configured (you have ${n}).`,
|
|
1848
|
+
t1Blocking: (n) => `To reach T1 (First tool) you need at least one detected AI tool — you have ${n} now.`,
|
|
1849
|
+
t2Blocking: (n) => `To reach T2 (Bench with notes) you need at least one persistent context file (instructions, config or rules) — you have ${n} now.`,
|
|
1850
|
+
t3Blocking: (n) => `To reach T3 (Connected bench) you need to connect at least one MCP server — you have ${n} now.`,
|
|
1851
|
+
t4Blocking: (n) => `To reach T4 (Own tooling) you need to create at least one asset of your own — skill, command or rule — you have ${n} now.`,
|
|
1819
1852
|
t5Blocking: (hasAgentic, mcp, custom) => {
|
|
1820
1853
|
const missing = [];
|
|
1821
1854
|
if (!hasAgentic) missing.push('an agentic CLI (Claude Code, Aider, Gemini CLI, Codex CLI or Amazon Q Developer)');
|
|
1822
1855
|
if (mcp < 1) missing.push('at least 1 MCP server');
|
|
1823
1856
|
if (custom < 1) missing.push('at least 1 asset of your own (skill, command or rule)');
|
|
1824
|
-
return `To reach T5 (Agentic operator) you're missing: ${missing.join('; ')}
|
|
1857
|
+
return `To reach T5 (Agentic operator) you're missing: ${missing.join('; ')}.`;
|
|
1825
1858
|
},
|
|
1826
1859
|
t6Blocking: (n) => `To reach T6 (Multi-agent) you need at least 2 specialized agents defined under \`.claude/agents/\` — currently you have ${n}.`,
|
|
1827
|
-
t7Blocking: (n) => `To reach T7 (Orchestrated workshop) you need at least one automation hook configured —
|
|
1860
|
+
t7Blocking: (n) => `To reach T7 (Orchestrated workshop) you need at least one automation hook configured — you have ${n} now.`,
|
|
1828
1861
|
},
|
|
1829
1862
|
},
|
|
1830
1863
|
// Progression ladder (skill-code-certification, report req 1) — see the es block.
|
|
@@ -2309,6 +2342,8 @@ const catalogs = {
|
|
|
2309
2342
|
discoverFailed: (reason) => `Could not fetch your dimensions (${reason}). Try again in a moment.`,
|
|
2310
2343
|
unmatchedNote: (keys) => `Note: some dimensions have no matching template and can't be offered here (${keys}).`,
|
|
2311
2344
|
noneOfferable: 'There are no dimensions you can certify here right now. Check back when one becomes available.',
|
|
2345
|
+
onCooldownNote: (items) => `Not available to retake yet: ${items}.`,
|
|
2346
|
+
onCooldownItem: (slug, day) => `${slug} (available on ${day})`,
|
|
2312
2347
|
mainRole: (role) => `Your main role: ${role}`,
|
|
2313
2348
|
selectHeading: 'Pick the dimension you want to certify:',
|
|
2314
2349
|
selectHint: '↑/↓ to move, Enter to pick, Esc to cancel.',
|
|
@@ -2317,10 +2352,41 @@ const catalogs = {
|
|
|
2317
2352
|
selectNoneChosen: 'No dimension chosen.',
|
|
2318
2353
|
stateExpired: 'expired',
|
|
2319
2354
|
dimensionInvalid: 'That dimension is not among the certifiable ones right now.',
|
|
2355
|
+
dimensionOnCooldown: (slug, day) => `You can't retake ${slug} yet: it will be available on ${day}.`,
|
|
2320
2356
|
dimensionUsing: (slug) => `Chosen dimension: ${slug}`,
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2357
|
+
creating: 'Creating the interview…',
|
|
2358
|
+
createFailed: (reason) => `Could not create the interview (${reason}).`,
|
|
2359
|
+
onCooldown: (day) => (day
|
|
2360
|
+
? `You took this dimension recently. You can retake it from ${day}.`
|
|
2361
|
+
: 'You took this dimension recently and cannot retake it yet.'),
|
|
2362
|
+
needsWeb: 'This dimension has an exercise that for now is only taken on the web, not in the terminal.',
|
|
2363
|
+
interviewUnavailable: (reason) => `The interview could not be completed (${reason}).`,
|
|
2364
|
+
reportPolling: 'Computing your verdict…',
|
|
2365
|
+
reportTimeout: 'The verdict is still being computed. Check your profile in a few minutes.',
|
|
2366
|
+
reportFailed: (reason) => `Could not fetch the verdict (${reason}). Check your profile.`,
|
|
2367
|
+
verdictTitle: 'Verdict',
|
|
2368
|
+
verdictDimension: (dim) => `Dimension: ${dim}`,
|
|
2369
|
+
verdictBand: (band) => `Level: ${band}`,
|
|
2370
|
+
verdictCertified: 'Result: certified',
|
|
2371
|
+
verdictNotCertified: 'Result: not certified this time',
|
|
2372
|
+
done: 'Certification complete.',
|
|
2373
|
+
// The same case copy the web preview shows (works `certifications.preview`).
|
|
2374
|
+
caseTitle: 'The case',
|
|
2375
|
+
caseExpectationsTitle: 'What is expected',
|
|
2376
|
+
caseExpectations: {
|
|
2377
|
+
CASE_STUDY: {
|
|
2378
|
+
reasoning: 'We assess your reasoning and the assumptions you make, not a perfect answer.',
|
|
2379
|
+
askForData: 'You can ask for data that is not in the case: say what you want it for.',
|
|
2380
|
+
takeTime: 'You can stop to think before you answer.',
|
|
2381
|
+
},
|
|
2382
|
+
ROLE_PLAY: {
|
|
2383
|
+
reasoning: 'We assess how you reason in the conversation and the assumptions you make, not a perfect answer.',
|
|
2384
|
+
askForData: 'You can ask the other side for data that is not in the case.',
|
|
2385
|
+
takeTime: 'You can stop to think before you answer.',
|
|
2386
|
+
},
|
|
2387
|
+
},
|
|
2388
|
+
caseBeforeStart: 'Alma is going to ask you questions about this case. If you are missing any data, ask her for it during the interview.',
|
|
2389
|
+
caseReadyPrompt: 'Press Enter once you have read it to start the interview.',
|
|
2324
2390
|
},
|
|
2325
2391
|
findProjects: {
|
|
2326
2392
|
help:
|
package/src/mcp-ai-usage-tool.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const { execFileSync } = require('child_process');
|
|
5
5
|
const { isRefusedWalkRoot } = require('./scan-exclusions');
|
|
6
|
-
const { askChoice } = require('./mcp-choice');
|
|
6
|
+
const { askChoice, chatChoice, queueNotice } = require('./mcp-choice');
|
|
7
7
|
|
|
8
8
|
function isExistingDir(p) {
|
|
9
9
|
try {
|
|
@@ -31,17 +31,13 @@ const AI_USAGE_INPUT_SCHEMA = {
|
|
|
31
31
|
consent: {
|
|
32
32
|
type: 'object',
|
|
33
33
|
description:
|
|
34
|
-
"The talent's explicit answer to the AI-usage question
|
|
34
|
+
"The talent's explicit answer to the AI-usage question this tool asks together with both disclaimers. Nothing is scanned without granted:true. A yes to anything else is not this answer.",
|
|
35
35
|
properties: {
|
|
36
36
|
granted: { type: 'boolean', description: 'true ONLY after the talent explicitly said yes to the AI-usage question; false when they said no. Omit it to get the disclaimers and the question to ask.' },
|
|
37
37
|
email: { type: 'string', description: 'The talent email the report is attributed to. Required when granted is true.' },
|
|
38
38
|
},
|
|
39
39
|
required: ['granted'],
|
|
40
40
|
},
|
|
41
|
-
disclaimersShown: {
|
|
42
|
-
type: 'boolean',
|
|
43
|
-
description: 'true only after you showed the talent both AI-usage disclaimers word for word, right before this call. Without consent, it then asks the AI-usage question in a dialog when the client has one.',
|
|
44
|
-
},
|
|
45
41
|
repoScope: {
|
|
46
42
|
type: 'object',
|
|
47
43
|
description: 'Which repositories feed the evaluation. Defaults to the current repository only.',
|
|
@@ -152,35 +148,21 @@ function makeAiUsageTool(deps = {}) {
|
|
|
152
148
|
}, () => ({ ok: false, skipped: true, reason: 'scan-failed' }));
|
|
153
149
|
return current.uploading;
|
|
154
150
|
}
|
|
155
|
-
function consentRequired(lang, ctx) {
|
|
156
|
-
const c = signupCopy(lang);
|
|
157
|
-
const disclaimers = Object.values(usageDisclaimers(lang));
|
|
158
|
-
if (ctx && ctx.elicitation) {
|
|
159
|
-
return {
|
|
160
|
-
ok: false,
|
|
161
|
-
reason: 'consent-required',
|
|
162
|
-
relayVerbatim: disclaimers,
|
|
163
|
-
message: 'Nothing was scanned. Show the talent each relayVerbatim text word for word, each as its own block, without labels or paraphrase; then call ai_usage again with disclaimersShown:true and no consent: it asks the question in a dialog.',
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
return {
|
|
167
|
-
ok: false,
|
|
168
|
-
reason: 'consent-required',
|
|
169
|
-
relayVerbatim: disclaimers,
|
|
170
|
-
question: c.aiUsageQuestion,
|
|
171
|
-
options: [c.aiUsageYes, c.aiUsageSkip],
|
|
172
|
-
message: 'Nothing was scanned. Show the talent each relayVerbatim text word for word, each as its own block, without labels or paraphrase, then `question` with its `options` (your native choice buttons if you have them, otherwise a short numbered list). Then call again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.',
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
151
|
function declined(lang) {
|
|
177
152
|
recordConsent('denied');
|
|
178
|
-
|
|
153
|
+
if (signupPhase() === 'account') {
|
|
154
|
+
queueNotice(signupCopy(lang).aiUsageSkipped);
|
|
155
|
+
return { ok: false, reason: 'consent-declined', message: 'Nothing was scanned. Say nothing about it: its fixed line for the talent comes with the next question. Carry on with the interview offer.' };
|
|
156
|
+
}
|
|
157
|
+
return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word.' };
|
|
179
158
|
}
|
|
180
159
|
|
|
181
160
|
async function askConsent(lang, ctx) {
|
|
182
161
|
const c = signupCopy(lang);
|
|
183
|
-
const
|
|
162
|
+
const texts = Object.values(usageDisclaimers(lang));
|
|
163
|
+
const asked = ctx && ctx.elicitation
|
|
164
|
+
? await askChoice(ctx, { texts, question: c.aiUsageQuestion, options: [c.aiUsageYes, c.aiUsageSkip] })
|
|
165
|
+
: { chat: chatChoice(c.aiUsageQuestion, [c.aiUsageYes, c.aiUsageSkip], texts) };
|
|
184
166
|
if (asked.answer === c.aiUsageSkip) {
|
|
185
167
|
dialogConsent = 'denied';
|
|
186
168
|
return declined(lang);
|
|
@@ -194,7 +176,7 @@ function makeAiUsageTool(deps = {}) {
|
|
|
194
176
|
reason: 'consent-required',
|
|
195
177
|
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
196
178
|
...asked.chat,
|
|
197
|
-
message: `Nothing was scanned.
|
|
179
|
+
message: `Nothing was scanned. ${asked.chat.choiceMessage} Then call ai_usage again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.`,
|
|
198
180
|
};
|
|
199
181
|
}
|
|
200
182
|
|
|
@@ -214,7 +196,6 @@ function makeAiUsageTool(deps = {}) {
|
|
|
214
196
|
return { ok: false, reason: 'consent-declined', message: 'The talent skipped the AI-usage step in the dialog: nothing is scanned. Carry on without it. If they later ask for the scan themselves, call ai_usage without consent to ask them again.' };
|
|
215
197
|
}
|
|
216
198
|
if (consent.granted !== true) {
|
|
217
|
-
if (args.disclaimersShown !== true || !(ctx && ctx.elicitation)) return consentRequired(textLang, ctx);
|
|
218
199
|
const answered = await askConsent(textLang, ctx);
|
|
219
200
|
// In the sign-up a yes in the dialog runs the scan right away, in the background.
|
|
220
201
|
if (!(answered.reason === 'consent-granted' && phase === 'account')) return answered;
|
|
@@ -405,7 +386,7 @@ function makeAiUsageTool(deps = {}) {
|
|
|
405
386
|
return {
|
|
406
387
|
name: 'ai_usage',
|
|
407
388
|
description:
|
|
408
|
-
"Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity), scores their AI-maturity tier (T0-T7), and, with the talent's consent, submits the report to Shakers so their profile reflects it. Runs entirely on the talent's own machine. Before any scan,
|
|
389
|
+
"Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity), scores their AI-maturity tier (T0-T7), and, with the talent's consent, submits the report to Shakers so their profile reflects it. Runs entirely on the talent's own machine. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. In the sign-up it is the step right after the account exists and before the interview: on yes it runs and uploads in the background, never wait for it. Submission also requires an active, email-verified Shakers session (via `shakers login` / registration OTP): without a verified session the report stays on this machine and nothing is sent — check the `send` field for the outcome. Always fast and deterministic: it runs no LLM on the machine. Agent descriptions and catalog classification are produced by Shakers server-side. This waits briefly (~30s) and, if classification finishes, returns the enriched agents inline; otherwise it returns enrichment:'pending' (normal progress, not an error) and you should call `ai_usage_result` to get them. The `text` field is a ready-to-show plain-text 'My work with AI' report (Markdown, no ANSI) mirroring the CLI terminal — render it verbatim to the talent; the matrix grid is inside a fenced code block, keep it fenced so it stays aligned. Use the `display` block for structured access: show the tier (with the tierLegend explaining T0-T7) and the agent list by NAME and category/role. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show raw IDs or codes to the talent.",
|
|
409
390
|
inputSchema: AI_USAGE_INPUT_SCHEMA,
|
|
410
391
|
handler,
|
|
411
392
|
};
|
|
@@ -39,13 +39,19 @@ function makeCertifyDimensionTools(deps = {}) {
|
|
|
39
39
|
state: d.state,
|
|
40
40
|
band: d.band || null,
|
|
41
41
|
})),
|
|
42
|
+
onCooldown: (res.onCooldown || []).map((d) => ({
|
|
43
|
+
dimensionKey: d.dimensionKey,
|
|
44
|
+
slug: d.slug,
|
|
45
|
+
clusterId: d.clusterId,
|
|
46
|
+
availableAt: d.availableAt,
|
|
47
|
+
})),
|
|
42
48
|
};
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
return [
|
|
46
52
|
{
|
|
47
53
|
name: 'list_certifiable_dimensions',
|
|
48
|
-
description: "List the dimensions the talent can certify right now: their main role's hub dimensions whose interview runs by text (terminal or spoken-case templates) and not already CERTIFIED (UNCERTIFIED/EXPIRED). Requires an active session. The certification interview itself is done on the web; running `shakers certify` in the terminal hands the talent the web link to take it. The dimensionKey/clusterId in each item are INTERNAL handles for tools only — never show or mention them to the talent; refer to a dimension by its name.",
|
|
54
|
+
description: "List the dimensions the talent can certify right now: their main role's hub dimensions whose interview runs by text (terminal or spoken-case templates) and not already CERTIFIED (UNCERTIFIED/EXPIRED). Dimensions the talent cannot retake yet are NOT in `dimensions`; they come in `onCooldown` with the ISO date they free up, so tell the talent when they can retry instead of offering them. Requires an active session. The certification interview itself is done on the web; running `shakers certify` in the terminal hands the talent the web link to take it. The dimensionKey/clusterId in each item are INTERNAL handles for tools only — never show or mention them to the talent; refer to a dimension by its name.",
|
|
49
55
|
inputSchema: LIST_CERTIFIABLE_DIMENSIONS_SCHEMA,
|
|
50
56
|
handler: listCertifiableDimensions,
|
|
51
57
|
},
|