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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakers-cli-beta",
3
- "version": "0.10.3",
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 -> hand off to the web interview.
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
- getTalentProfileUrl: config.getTalentProfileUrl,
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
- // 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;
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
- const key = `${encodeURIComponent(dimension.clusterId)}/${encodeURIComponent(dimension.slug)}`;
68
- return `${origin}/certifications?dimension=${key}`;
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 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 };
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
- buildWebLink,
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 (\`totalDetected = ${n}\`).`,
207
- t2Met: (n) => `Dispones de al menos un fichero de contexto persistente — instrucciones, configuración o reglas — para alguna herramienta (\`context = ${n}\`).`,
208
- t3Met: (n) => `Tienes al menos un servidor MCP conectado, dando a la IA acceso a datos o herramientas externas (\`mcpServers = ${n}\`).`,
209
- t4Met: (n) => `Has creado activos propios — skills, comandos o reglas personalizadas — más allá de la configuración por defecto (\`custom = ${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 (\`hasAgentic = ${hasAgentic}\`, \`mcpServers = ${mcp}\`, \`custom = ${custom}\`).`,
211
- t6Met: (n) => `Tienes un equipo de al menos 2 agentes especializados definidos (\`agentCounts.agents = ${n}\`).`,
212
- t7Met: (n) => `Tienes automatización basada en hooks configurada (\`hooks = ${n}\`).`,
213
- t1Blocking: (n) => `Para subir a T1 (Primera herramienta) necesitas al menos una herramienta de IA detectada — actualmente \`totalDetected = ${n}\`.`,
214
- t2Blocking: (n) => `Para subir a T2 (Banco con notas) necesitas al menos un fichero de contexto persistente (instrucciones, configuración o reglas) — actualmente \`context = ${n}\`.`,
215
- t3Blocking: (n) => `Para subir a T3 (Banco conectado) necesitas conectar al menos un servidor MCP — actualmente \`mcpServers = ${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 — actualmente \`custom = ${n}\`.`,
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('; ')} (\`hasAgentic = ${hasAgentic}\`, \`mcpServers = ${mcp}\`, \`custom = ${custom}\`).`;
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 — actualmente \`hooks = ${n}\`.`,
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
- webHandoff: 'La entrevista de certificación se hace en la web.',
727
- webHandoffLink: (url) => `Ábrela aquí para certificar esta dimensión:\n ${url}`,
728
- webHandoffNoLink: 'Entra en tu perfil de Shakers en la web y ve a Certificaciones para hacerla.',
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 (\`totalDetected = ${n}\`).`,
1809
- t2Met: (n) => `You have at least one persistent context file — instructions, config or rules — for some tool (\`context = ${n}\`).`,
1810
- t3Met: (n) => `You have at least one connected MCP server, giving the AI access to external data or tools (\`mcpServers = ${n}\`).`,
1811
- t4Met: (n) => `You've created your own assets — skills, commands or custom rules — beyond the default configuration (\`custom = ${n}\`).`,
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 (\`hasAgentic = ${hasAgentic}\`, \`mcpServers = ${mcp}\`, \`custom = ${custom}\`).`,
1813
- t6Met: (n) => `You have a team of at least 2 specialized agents defined (\`agentCounts.agents = ${n}\`).`,
1814
- t7Met: (n) => `You have hook-based automation configured (\`hooks = ${n}\`).`,
1815
- t1Blocking: (n) => `To reach T1 (First tool) you need at least one detected AI tool — currently \`totalDetected = ${n}\`.`,
1816
- t2Blocking: (n) => `To reach T2 (Bench with notes) you need at least one persistent context file (instructions, config or rules) — currently \`context = ${n}\`.`,
1817
- t3Blocking: (n) => `To reach T3 (Connected bench) you need to connect at least one MCP server — currently \`mcpServers = ${n}\`.`,
1818
- t4Blocking: (n) => `To reach T4 (Own tooling) you need to create at least one asset of your own — skill, command or rule — currently \`custom = ${n}\`.`,
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('; ')} (\`hasAgentic = ${hasAgentic}\`, \`mcpServers = ${mcp}\`, \`custom = ${custom}\`).`;
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 — currently \`hooks = ${n}\`.`,
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
- webHandoff: 'The certification interview is done on the web.',
2322
- webHandoffLink: (url) => `Open it here to certify this dimension:\n ${url}`,
2323
- webHandoffNoLink: 'Open your Shakers profile on the web and go to Certifications to take it.',
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:
@@ -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, asked right after showing both disclaimers verbatim. Nothing is scanned without granted:true. A yes to anything else is not this answer.",
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
- return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word, and carry on with the interview offer.' };
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 asked = await askChoice(ctx, { question: c.aiUsageQuestion, options: [c.aiUsageYes, c.aiUsageSkip] });
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. The disclaimers are already shown: do not repeat them. ${asked.chat.choiceMessage} Then call again with consent.granted:true only after an explicit yes, or consent.granted:false if they say no.`,
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, show the talent both AI-usage disclaimers word for word, each as its own block, right before the AI-usage question (call without consent to get those texts and the question); 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.",
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
  },